From 5ff06d25b46c70f82fc9370d814417da112ab726 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 21:48:06 +0300 Subject: [PATCH 01/23] =?UTF-8?q?feat(tradein/payments):=20=D0=BE=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D1=87=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BE=D1=82?= =?UTF-8?q?=D1=87=D1=91=D1=82=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=B3=D0=BE=D0=B4=20=E2=80=94=20retain=5Funtil=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D0=BB=D0=B8=20=D0=B2=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B5=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мина: purge_expired_trade_in_data (сейчас enabled=false) удаляет строки WHERE expires_at < NOW() AND created_by IS NULL — это ровно популяция будущих платящих физлиц (владелец продаёт отчёт за 150 руб., отчёт должен жить год на нашей стороне, а не 24ч). Первый прогон после запуска продаж безвозвратно снёс бы оплаченное. Делается ДО платёжного кода, которого в этом PR нет: - migration 234: колонка trade_in_estimates.retain_until (NULL = неоплачено, бэкенд-бита-в-бит не меняется) + частичный индекс под purge-предикат. - config.py: trade_in_paid_retention_days=365 (ENV) — единственный источник "12 месяцев" для будущей оферты/экрана/SQL продления. - Единый гейт чтения ESTIMATE_READABLE_SQL + estimate_readable() — раньше SQL-фильтр (404) и Python-проверка (410) в trade_in.py уже разошлись по тексту ответа; текст "estimate expired (24h TTL)" убран (стал бы ложью при годовом хранении). - purge_expired_trade_in_data: retain_until IS NULL (не < NOW() — оплаченное не удаляем в принципе) + NOT EXISTS(payments) как независимая страховка + pre-flight, который считает оплаченных кандидатов и падает в mark_failed ДО первого батча при ненулевом результате. - PDF: "Ссылка доступна до …" только при retain_until IS NOT NULL; "ДЕЙСТВИТЕЛЕН ДО" (expires_at, актуальность расчёта) не тронут. - Фронт: retain_until прокинут в mapper (validUntil остаётся на expires_at). - privacy-страница: убрано устаревшее "механизма удаления нет" (неправда после #2547), добавлен срок 12 месяцев для оплаченных отчётов. Ни строчки платёжного кода. expires_at, trade_in_estimate_retention_hours, _DELETE_EXPIRED_LEADS_SQL не тронуты. --- tradein-mvp/backend/app/api/v1/trade_in.py | 39 +++++- tradein-mvp/backend/app/core/config.py | 11 ++ tradein-mvp/backend/app/schemas/trade_in.py | 4 + .../app/services/exporters/trade_in_pdf.py | 14 ++ .../app/tasks/purge_expired_trade_in_data.py | 76 +++++++++++ .../234_trade_in_estimates_retain_until.sql | 65 +++++++++ .../backend/data/sql/_manifest_applied.txt | 1 + .../backend/tests/test_estimate_idor.py | 123 ++++++++++++++++- .../backend/tests/test_pdf_security.py | 33 +++++ .../tests/test_purge_expired_trade_in_data.py | 128 ++++++++++++++++-- .../src/app/mera-public/privacy/page.tsx | 34 +++-- tradein-mvp/frontend/src/app/v2/page.tsx | 2 +- .../src/components/trade-in/v2/fixtures.ts | 1 + .../src/components/trade-in/v2/mappers.ts | 3 + .../src/components/trade-in/v2/types.ts | 4 + tradein-mvp/frontend/src/types/trade-in.ts | 4 + 16 files changed, 512 insertions(+), 30 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 5335901b..05950b0c 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -52,6 +52,27 @@ logger = logging.getLogger(__name__) router = APIRouter() +# PR-D1: единственное определение «оценка читаема» — раньше SQL-фильтр (404, +# ниже в get_estimate) и Python-проверка (410, в estimate_pdf) уже разошлись +# по коду ответа; третий потребитель (`/r/`, PR-9) разошёлся бы +# неизбежно без унификации. `retain_until > NOW()` при NULL даёт NULL → false +# в SQL — для всех существующих строк (retain_until IS NULL) поведение не +# меняется вообще. Не копировать это выражение по месту — только через +# константу/хелпер ниже. См. `mera-pr-d-spec.md` §1.3 в корне репо. +ESTIMATE_READABLE_SQL = "(expires_at > NOW() OR retain_until > NOW())" + + +def estimate_readable(expires_at: datetime, retain_until: datetime | None) -> bool: + """Python-зеркало ESTIMATE_READABLE_SQL — та же дизъюнкция, без похода в БД. + + tzinfo-нормализация повторяет прежнюю Python-проверку (estimate_pdf) — + `.replace(tzinfo=UTC)`, не переизобретается. + """ + now = datetime.now(tz=UTC) + if expires_at.replace(tzinfo=UTC) > now: + return True + return retain_until is not None and retain_until.replace(tzinfo=UTC) > now + def _assert_estimate_access(created_by: str | None, x_authenticated_user: str | None) -> None: """IDOR guard (#690): только владелец оценки или admin могут её читать. @@ -249,11 +270,11 @@ def get_estimate( """ row = db.execute( text( - """ + f""" SELECT id, median_price, range_low, range_high, median_price_per_m2, confidence, confidence_explanation, n_analogs, analogs, actual_deals, sources_used, data_freshness_minutes, - expires_at, address, lat, lon, + expires_at, retain_until, address, lat, lon, area_m2, rooms, floor, total_floors, year_built, house_type, repair_state, has_balcony, canonical_address, house_cadnum, house_fias_id, @@ -263,7 +284,7 @@ def get_estimate( asking_to_sold_ratio, ratio_basis, created_by, created_at FROM trade_in_estimates WHERE id = CAST(:id AS uuid) - AND expires_at > NOW() + AND {ESTIMATE_READABLE_SQL} """ ), {"id": str(estimate_id)}, @@ -372,6 +393,7 @@ def get_estimate( analogs=analogs, actual_deals=actual_deals, expires_at=row.expires_at, + retain_until=row.retain_until, target_address=row.address, target_lat=row.lat, target_lon=row.lon, @@ -433,7 +455,7 @@ def estimate_pdf( SELECT id, median_price, range_low, range_high, median_price_per_m2, confidence, confidence_explanation, n_analogs, analogs, actual_deals, sources_used, data_freshness_minutes, - expires_at, + expires_at, retain_until, address, lat, lon, area_m2, rooms, floor, total_floors, year_built, house_type, repair_state, has_balcony, canonical_address, house_cadnum, house_fias_id, @@ -453,8 +475,12 @@ def estimate_pdf( _assert_estimate_access(row.created_by, x_authenticated_user) - if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC): - raise HTTPException(status_code=410, detail="estimate expired (24h TTL)") + # PR-D1: тот же гейт, что в get_estimate (см. ESTIMATE_READABLE_SQL) — раньше + # здесь была независимая Python-проверка expires_at, разошедшаяся с SQL- + # фильтром GET-ручки. "estimate expired (24h TTL)" убрано из текста: при + # годовом retain_until упоминание 24ч в ответе API стало бы ложью. + if not estimate_readable(row.expires_at, row.retain_until): + raise HTTPException(status_code=410, detail="estimate expired") from app.services.estimator import _qc_geo_to_precision @@ -477,6 +503,7 @@ def estimate_pdf( analogs=analogs, actual_deals=actual_deals, expires_at=row.expires_at, + retain_until=row.retain_until, target_address=row.address, target_lat=row.lat, target_lon=row.lon, diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 1702d860..9a9b4ebf 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -836,6 +836,17 @@ class Settings(BaseSettings): # срок — решение DPO/юриста, не инженера). ENV: TRADE_IN_LEAD_RETENTION_DAYS. trade_in_lead_retention_days: int = 180 + # ── PR-D1: платный отчёт живёт год (retain_until, migration 234) ──────── + # trade_in_estimates.retain_until TTL (дни ОТ ОПЛАТЫ) — срок жизни ССЫЛКИ/ + # СТРОКИ для оплаченной оценки, независимый от expires_at (актуальность + # расчёта, 24ч). НЕ трогает expires_at — см. migration 234 докстринг и + # `mera-pr-d-spec.md` §1.1/§1.2 в корне репо. Единственный источник числа + # «12 месяцев»: текст оферты (content.ts), текст экрана S4 и SQL продления + # retain_until при оплате (платёжный код, отдельный PR) обязаны читать его + # отсюда, а не хардкодить — иначе классический исход "в оферте 12 месяцев, + # в конфиге 365 дней, на экране «год»". ENV: TRADE_IN_PAID_RETENTION_DAYS. + trade_in_paid_retention_days: int = 365 + # Батч-размер физического DELETE в purge_expired_trade_in_data (нельзя одним # DELETE по всей таблице — долгая блокировка на большом бэклоге). Задача сама # крутит цикл батчей за один прогон (см. _DEFAULT_MAX_BATCHES в таске) — diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index ad4811d0..c7620ece 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -196,6 +196,10 @@ class AggregatedEstimate(BaseModel): analogs: list[AnalogLot] actual_deals: list[AnalogLot] # реальные продажи last 12 mo expires_at: datetime + # PR-D1: срок жизни ССЫЛКИ/СТРОКИ (оплаченный доступ), НЕ актуальности + # расчёта — тот остаётся expires_at (не путать, см. migration 234). + # NULL = неоплачено (весь текущий трафик, B2B pilots включительно). + retain_until: datetime | None = None # ── Дополнительные метаданные ── target_address: str | None = None # geocoded full address target_lat: float | None = None diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py index 425dda5f..8282f83c 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -1050,6 +1050,19 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s ) report_num = _report_number(estimate) + # PR-D1: «Ссылка доступна до …» — срок жизни ОПЛАЧЕННОГО доступа + # (retain_until), НЕ путать со «Срок действия данных» (expires_at, + # актуальность расчёта) над ней — эта строка не трогается. Рендерится + # ТОЛЬКО когда retain_until IS NOT NULL (неоплаченные — весь текущий + # трафик — не видят этой строки вообще, поведение бит-в-бит текущее). + retain_until_row = ( + f'Ссылка доступна до' + f'' + f"{_mono(estimate.retain_until.date().strftime('%d.%m.%Y'))}" + if estimate.retain_until is not None + else "" + ) + # Короткий адрес (для cover): берём первую часть до запятой full_address = input_snapshot.get("address", "—") address_short = full_address.split(",")[0:3] @@ -1146,6 +1159,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s {_mono(today.strftime("%d.%m.%Y"))} Срок действия данных до {_mono(expires.strftime("%d.%m.%Y"))} + {retain_until_row} Адрес{address} Год постройки {year_label} diff --git a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py index 420911ec..5a96989c 100644 --- a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py +++ b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py @@ -54,6 +54,17 @@ BATCHING (не единый DELETE по всей таблице): don't match `expires_at < NOW()` on the next run; a mid-run failure leaves earlier committed batches deleted (correct, not rolled back) and mark_failed records the partial counters reached so far. + +PR-D1 (2026-08-06, payments retention -- see `mera-pr-d-spec.md` §1 at repo root): + the `created_by IS NULL` population above is EXACTLY the future paying-customer + population -- the owner sells this report to individuals for money, and a paid + row must outlive the 24h `expires_at` link TTL. Two independent safeguards were + added to `_DELETE_EXPIRED_ESTIMATES_SQL` (retain_until IS NULL + NOT EXISTS + payments) plus a pre-flight count in `purge_expired_trade_in_data` that refuses + to run at all if it finds a paid candidate -- see the SQL constants and + `_preflight_paid_candidates` below for the mechanics. No payment code lives in + this file; `retain_until` is set by the (separate, not-yet-existing) payment + fulfillment code. """ from __future__ import annotations @@ -74,6 +85,21 @@ logger = logging.getLogger(__name__) # remainder simply drains on the next nightly run (idempotent, no data loss risk). _DEFAULT_MAX_BATCHES = 20 +# +# PR-D1 (2026-08-06): два независимые предохранителя добавлены к тому же +# предикату, ПЕРЕД тем как платёжный код появился в проекте (мина уже была +# заряжена, см. `mera-pr-d-spec.md` §1 в корне репо): +# 1. `retain_until IS NULL` — именно IS NULL, НЕ `< NOW()`. Оплаченная +# строка (retain_until IS NOT NULL, migration 234) не удаляется джобой +# В ПРИНЦИПЕ, пока не поднято ослабление отдельным PR не раньше чем +# через год после первой продажи. `retain_until` ставится сервисным +# кодом платёжного контура (ещё не существует в этом PR) на now() + +# settings.trade_in_paid_retention_days. +# 2. `NOT EXISTS (payments)` — независимая страховка на случай, если выдача +# забыла проставить retain_until (баг/гонка/ручной INSERT): строка, +# которой коснулись деньги, переживёт джобу даже без корректного (1). +# `payments` создана migration 233 (payments_estimate_idx — дешёвый терм). +# См. также _preflight_paid_candidates ниже — та же логика ДО первого батча. _DELETE_EXPIRED_ESTIMATES_SQL = text( """ DELETE FROM trade_in_estimates @@ -81,12 +107,31 @@ _DELETE_EXPIRED_ESTIMATES_SQL = text( SELECT id FROM trade_in_estimates WHERE expires_at < NOW() AND created_by IS NULL + AND retain_until IS NULL + AND NOT EXISTS ( + SELECT 1 FROM payments p WHERE p.estimate_id = trade_in_estimates.id + ) ORDER BY expires_at LIMIT CAST(:batch_size AS int) ) """ ) +# PR-D1 pre-flight (см. _preflight_paid_candidates): считает по БАЗОВОМУ +# (пред-PR-D1) предикату purge -- `expires_at < NOW() AND created_by IS NULL`, +# БЕЗ retain_until/NOT EXISTS -- специально ШИРЕ итогового DELETE-предиката +# выше, чтобы поймать именно случай "retain_until не проставлен, а деньги +# были" (а не только штатно защищённые retain_until IS NOT NULL строки, +# которые и так не попали бы под DELETE). +_PREFLIGHT_PAID_CANDIDATES_SQL = text( + """ + SELECT count(*) FROM trade_in_estimates e + WHERE e.expires_at < NOW() + AND e.created_by IS NULL + AND EXISTS (SELECT 1 FROM payments p WHERE p.estimate_id = e.id) + """ +) + _DELETE_EXPIRED_LEADS_SQL = text( """ DELETE FROM trade_in_leads @@ -135,6 +180,19 @@ def _drain_expired( break # caught up -- fewer expired rows left than one batch +def _preflight_paid_candidates(db: Session) -> int: + """PR-D1 safety gate: count purge-candidates (base predicate) that have a payments row. + + Runs BEFORE any DELETE batch. A non-zero result means at least one estimate that + would have matched the OLD (pre-PR-D1) purge predicate was actually touched by + money -- either `retain_until` failed to be set (fulfillment bug/race/manual + INSERT) or something inconsistent happened. Either way this run must not delete + anything; see `purge_expired_trade_in_data` below, which aborts before the first + batch when this returns non-zero. + """ + return db.execute(_PREFLIGHT_PAID_CANDIDATES_SQL).scalar_one() + + def purge_expired_trade_in_data( db: Session, run_id: int, @@ -148,10 +206,28 @@ def purge_expired_trade_in_data( deactivate_stale_listings). Finalises the scrape_runs row (mark_done / mark_failed). Returns {"estimates_deleted": N, "leads_deleted": M}. + + PR-D1 pre-flight (see `_preflight_paid_candidates`): if any purge-candidate + estimate has a `payments` row, the run aborts BEFORE the first DELETE batch -- + zero rows deleted, `mark_failed` records why. This is deliberately checked + outside the `try` below so it can never be caught and silently re-reported as a + generic mid-run failure -- it is a distinct, actionable pre-condition failure. """ batch_size = batch_size or settings.trade_in_purge_batch_size max_batches = max_batches or _DEFAULT_MAX_BATCHES counters: dict[str, int] = {"estimates_deleted": 0, "leads_deleted": 0} + + paid_candidates = _preflight_paid_candidates(db) + if paid_candidates: + error = ( + f"pre-flight abort: {paid_candidates} purge-candidate trade_in_estimates " + "row(s) have a matching payments row (retain_until may be unset) -- " + "refusing to run, zero rows deleted" + ) + logger.error("purge_expired_trade_in_data run_id=%d %s", run_id, error) + runs_mod.mark_failed(db, run_id, error, counters) + raise RuntimeError(error) + try: _drain_expired( db, diff --git a/tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql b/tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql new file mode 100644 index 00000000..2d2e3d4f --- /dev/null +++ b/tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql @@ -0,0 +1,65 @@ +-- 234_trade_in_estimates_retain_until.sql +-- PR-D1 «Ретеншен: оплаченное живёт год, purge его не трогает» — см. +-- `mera-pr-d-spec.md` §1 в корне репо (обоснования там, здесь только SQL). +-- Номер сверен и по `forgejo/main`, и по всем открытым PR-веткам на момент +-- написания (последняя занятая — 233_payments.sql) — см. урок в шапке того +-- же файла про то, как коллизия 228/229/231/232 обнаруживается поздно. +-- +-- ── WHY ────────────────────────────────────────────────────────────────────── +-- purge_expired_trade_in_data (migration 231, seeded enabled=false) удаляет +-- строки `WHERE expires_at < NOW() AND created_by IS NULL` — это ровно +-- популяция будущих платящих физлиц (анонимные B2C-оценки). Владелец продаёт +-- отчёт физлицу за 150 ₽: скачанный файл у клиента бессрочно, но ссылка/строка +-- на нашей стороне обязана жить дольше 24-часового TTL расчёта — иначе первый +-- же прогон purge-джобы после запуска продаж физически и безвозвратно удалит +-- уже оплаченное (PDF нигде не хранится, рендерится на лету). +-- +-- `expires_at` НЕ трогаем ни на йоту: это единая глобальная настройка +-- (`trade_in_estimate_retention_hours`), она же — печатаемая в PDF/UI дата +-- «ДЕЙСТВИТЕЛЕН ДО» (актуальность РАСЧЁТА, а не срок жизни строки), и от неё +-- зависит вычисление даты расчёта во фронте (`mappers.ts` fmtDateShift(-24)). +-- Поднять её до года означало бы: (а) дать год хранения ВСЕМ строкам, включая +-- неоплаченные адреса физлиц — прямое нарушение минимизации по 152-ФЗ; +-- (б) напечатать в PDF клиента, что расчёт актуален год. +-- +-- ── WHAT ───────────────────────────────────────────────────────────────────── +-- Новая, независимая колонка retain_until — срок жизни ДОСТУПА/СТРОКИ: +-- NULL = неоплаченная строка, поведение (чтение/PDF/purge) бит-в-бит текущее. +-- Бэкфилла нет — все 1058 существующих строк остаются NULL, ничего не меняется +-- для уже созданных оценок (весь B2B pilot-трафик в их числе). +-- При оплате (платёжный код — отдельный PR, здесь его нет) сервисный слой +-- проставит retain_until = now() + trade_in_paid_retention_days (config.py). +-- +-- Частичный индекс покрывает predicate purge-джобы (migration 231, +-- `_DELETE_EXPIRED_ESTIMATES_SQL`) уже С УЧЁТОМ нового терма retain_until — +-- заведён вместе с колонкой, а не отдельной миграцией, чтобы purge не начал +-- жить без него хотя бы один деплой. +-- +-- ── IDEMPOTENCY ────────────────────────────────────────────────────────────── +-- ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS — безопасный re-run. +-- Ничего не удаляет, не бэкфиллит, DDL-only (доли секунды на 1058 строках). +-- +-- Dependencies: 001_trade_in_estimates.sql, 233_payments.sql (индекс исключает +-- строки со строкой в payments опосредованно через predicate purge-джобы, +-- сама таблица payments здесь не читается). +-- Apply after: 233_payments.sql. + +BEGIN; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS retain_until timestamptz; + +COMMENT ON COLUMN trade_in_estimates.retain_until IS + 'До какого момента строку НЕЛЬЗЯ удалять и ссылка обязана открываться ' + '(оплаченный доступ). Семантика expires_at не меняется: это дата ' + 'актуальности РАСЧЁТА (24ч), она печатается в PDF. NULL = неоплачено, ' + 'поведение бит-в-бит текущее. Задаётся сервисным кодом платёжного контура ' + '(отдельный PR) на now() + trade_in_paid_retention_days (config.py).'; + +-- Частичный индекс под predicate purge-джобы (app/tasks/purge_expired_trade_in_data.py): +-- WHERE created_by IS NULL AND retain_until IS NULL AND expires_at < NOW(). +CREATE INDEX IF NOT EXISTS trade_in_estimates_purge_idx + ON trade_in_estimates (expires_at) + WHERE created_by IS NULL AND retain_until IS NULL; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 7d71c6d7..27b06836 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -231,3 +231,4 @@ # поддержки, #2532/#2533) откладывались до подтверждения, что они осели на # проде в финальном виде. Они в _schema_migrations — условие выполнено. 233_payments.sql +234_trade_in_estimates_retain_until.sql diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index 40234a3e..1dc6aaf9 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -51,8 +51,13 @@ def trade_in_app() -> FastAPI: return application -def _make_estimate_row(created_by: str | None) -> SimpleNamespace: - """A trade_in_estimates row with the full column set the endpoints read.""" +def _make_estimate_row(created_by: str | None, retain_until: object = None) -> SimpleNamespace: + """A trade_in_estimates row with the full column set the endpoints read. + + retain_until defaults to None (PR-D1, migration 234) -- unpaid, matches every + row that existed before that migration; explicit param lets retention-gate + tests (see test_estimate_retention_gate.py) construct a paid row. + """ from datetime import UTC, datetime, timedelta return SimpleNamespace( @@ -69,6 +74,7 @@ def _make_estimate_row(created_by: str | None) -> SimpleNamespace: sources_used=["avito"], data_freshness_minutes=10, expires_at=datetime.now(tz=UTC) + timedelta(hours=12), + retain_until=retain_until, address="ул. Тестовая, 1", lat=56.8, lon=60.6, @@ -597,3 +603,116 @@ def test_get_estimate_imv_benchmark_other_pilot_gets_404(trade_in_app: FastAPI) headers={"X-Authenticated-User": "attacker"}, ) assert resp.status_code == 404 + + +# ── PR-D1: retention gate unification (retain_until, mera-pr-d-spec.md §1.3) ── + + +def test_estimate_readable_sql_uses_disjunction() -> None: + """Single definition — OR retain_until, not a hand-copied expression.""" + from app.api.v1.trade_in import ESTIMATE_READABLE_SQL + + assert "expires_at > NOW()" in ESTIMATE_READABLE_SQL + assert "retain_until > NOW()" in ESTIMATE_READABLE_SQL + assert " OR " in ESTIMATE_READABLE_SQL + + +def test_get_estimate_sql_built_from_shared_constant() -> None: + """GET /estimate/{id} SQL filter is built FROM ESTIMATE_READABLE_SQL, not a + hand-copied literal — regression guard against the two gates drifting apart + again (that's exactly what happened before this PR: 404 here, 410 in /pdf).""" + import inspect + + from app.api.v1.trade_in import get_estimate + + src = inspect.getsource(get_estimate) + assert "ESTIMATE_READABLE_SQL" in src + assert "expires_at > NOW()" not in src, "hand-copied predicate, not the shared constant" + assert "retain_until" in src, "SELECT must also fetch retain_until" + + +def test_estimate_pdf_select_includes_retain_until_column() -> None: + import inspect + + from app.api.v1.trade_in import estimate_pdf + + assert "retain_until" in inspect.getsource(estimate_pdf) + + +@pytest.mark.parametrize( + ("expires_delta_hours", "retain_delta_days", "expected"), + [ + (12, None, True), # not expired, unpaid — current B2B/B2C behaviour, unchanged + (-1, None, False), # expired, unpaid — current behaviour (404/410), unchanged + (-1, 365, True), # expired but PAID — new: readable + (12, 365, True), # not expired AND paid — readable + (-1, -1, False), # expired, and the (hypothetical) retain_until also in the past + ], +) +def test_estimate_readable_truth_table( + expires_delta_hours: int, retain_delta_days: int | None, expected: bool +) -> None: + from datetime import UTC, datetime, timedelta + + from app.api.v1.trade_in import estimate_readable + + expires_at = datetime.now(tz=UTC) + timedelta(hours=expires_delta_hours) + retain_until = ( + datetime.now(tz=UTC) + timedelta(days=retain_delta_days) + if retain_delta_days is not None + else None + ) + assert estimate_readable(expires_at, retain_until) is expected + + +def test_pdf_expired_but_paid_returns_200(trade_in_app: FastAPI) -> None: + """expires_at in the past, retain_until in the future → PDF still downloads + (200). Exactly the scenario PR-D1 exists for: a paid report must outlive + the 24h expires_at link TTL.""" + from datetime import UTC, datetime, timedelta + + row = _make_estimate_row(created_by="kopylov") + row.expires_at = datetime.now(tz=UTC) - timedelta(hours=1) + row.retain_until = datetime.now(tz=UTC) + timedelta(days=300) + db_mock = _make_db_mock(row) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/pdf", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/pdf" + + +def test_pdf_expired_unpaid_returns_410_without_ttl_text(trade_in_app: FastAPI) -> None: + """expires_at in the past, retain_until NULL (unpaid, unchanged behaviour) → + 410, and the detail text no longer claims a specific '24h TTL' (would be a + lie now that retain_until exists for paid rows).""" + from datetime import UTC, datetime, timedelta + + row = _make_estimate_row(created_by="kopylov") + row.expires_at = datetime.now(tz=UTC) - timedelta(hours=1) + row.retain_until = None + db_mock = _make_db_mock(row) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/pdf", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 410 + assert resp.json()["detail"] == "estimate expired" + assert "24h" not in resp.json()["detail"] + assert "TTL" not in resp.json()["detail"] + + +def test_get_estimate_response_includes_retain_until_field(trade_in_app: FastAPI) -> None: + """Response schema exposes retain_until (nullable) — schemas/trade_in.py.""" + row = _make_estimate_row(created_by="kopylov") # retain_until defaults to None + db_mock = _make_db_mock(row) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.json()["retain_until"] is None diff --git a/tradein-mvp/backend/tests/test_pdf_security.py b/tradein-mvp/backend/tests/test_pdf_security.py index a97b017f..da5722ec 100644 --- a/tradein-mvp/backend/tests/test_pdf_security.py +++ b/tradein-mvp/backend/tests/test_pdf_security.py @@ -445,3 +445,36 @@ def test_brand_not_taken_from_query_param_docstring() -> None: assert ( "brand" not in param_names ), "estimate_pdf should NOT have a 'brand' query param after #7 fix" + + +# ── PR-D1: retain_until (paid retention) — cover row + valid_until unaffected ── + + +def test_cover_no_retain_until_row_when_unpaid() -> None: + """retain_until IS NULL (default, all current traffic) → no 'Ссылка доступна + до' row at all — B2B regression guard, cover renders bit-for-bit as before.""" + est = _estimate() + assert est.retain_until is None + html = mod._build_cover(est, _SNAPSHOT, _GENERIC) + assert "Ссылка доступна до" not in html + + +def test_cover_renders_retain_until_row_when_paid() -> None: + """retain_until IS NOT NULL → 'Ссылка доступна до ' row present, with + its OWN date (not conflated with 'Срок действия данных' / expires_at).""" + retain = datetime(2027, 8, 6, tzinfo=UTC) + est = _estimate(retain_until=retain) + html = mod._build_cover(est, _SNAPSHOT, _GENERIC) + assert "Ссылка доступна до" in html + assert "06.08.2027" in html + + +def test_expires_date_unaffected_by_retain_until() -> None: + """«ДЕЙСТВИТЕЛЕН ДО» (running footer, _expires_date) stays wired to + expires_at regardless of retain_until — it is data-actuality, not the + paid-access retention window, and must not move when a report is paid.""" + expires = datetime.now(UTC) + timedelta(hours=24) + est_unpaid = _estimate(expires_at=expires) + est_paid = _estimate(expires_at=expires, retain_until=expires + timedelta(days=365)) + assert mod._expires_date(est_unpaid) == expires.date() + assert mod._expires_date(est_paid) == expires.date() diff --git a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py index 07a85aa9..caa1b207 100644 --- a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py +++ b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py @@ -7,9 +7,20 @@ Covers app/tasks/purge_expired_trade_in_data.py: - both tables (trade_in_estimates, trade_in_leads) get drained - failure path: rollback + mark_failed with partial counters, exception re-raised - SQL shape: DELETE (not UPDATE/deactivate), no psycopg `::` cast trap + - PR-D1 (payments retention, mera-pr-d-spec.md §1.4): retain_until IS NULL + + NOT EXISTS(payments) safeguards on the estimates DELETE, plus a pre-flight + that refuses to run at all if it finds a paid purge-candidate. Style mirrors tests/test_deactivate_stale_listings.py (_FakeDB, monkeypatched runs_mod.mark_done/mark_failed). + +PR-D1 note on _FakeDB: purge_expired_trade_in_data now issues ONE extra +db.execute() call BEFORE any DELETE batch — the pre-flight paid-candidates +count (_PREFLIGHT_PAID_CANDIDATES_SQL). _FakeDB special-cases that statement +by identity and answers it from `preflight_count` (default 0 == "no paid +candidates, proceed exactly as before this PR"). Every pre-existing test's +`db.executed` index shifted by +1 to account for this; `db.commits` is +unaffected (the pre-flight is a read, never committed). """ from __future__ import annotations @@ -34,18 +45,30 @@ class _FakeResult: def __init__(self, rowcount: int) -> None: self.rowcount = rowcount + def scalar_one(self) -> int: + """Supports the PR-D1 pre-flight `SELECT count(*) ... .scalar_one()` call.""" + return self.rowcount + class _FakeDB: - """Pops rowcounts in call order -- caller supplies the exact sequence expected.""" + """Pops rowcounts in call order -- caller supplies the exact sequence expected. - def __init__(self, rowcounts: list[int]) -> None: + PR-D1: the pre-flight paid-candidates count is answered separately, from + `preflight_count` (default 0), keyed by statement IDENTITY -- it never + consumes an entry off `rowcounts` (that list is DELETE-batch rowcounts only). + """ + + def __init__(self, rowcounts: list[int], *, preflight_count: int = 0) -> None: self._rowcounts = list(rowcounts) + self.preflight_count = preflight_count self.executed: list[tuple[Any, Any]] = [] self.commits = 0 self.rolled_back = False def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: self.executed.append((stmt, params)) + if stmt is task_mod._PREFLIGHT_PAID_CANDIDATES_SQL: + return _FakeResult(self.preflight_count) return _FakeResult(self._rowcounts.pop(0)) def commit(self) -> None: @@ -83,7 +106,9 @@ def test_stops_when_batch_below_size(monkeypatch: pytest.MonkeyPatch) -> None: db = _FakeDB([3, 0]) out = task_mod.purge_expired_trade_in_data(db, run_id=1, batch_size=10, max_batches=20) # type: ignore[arg-type] assert out == {"estimates_deleted": 3, "leads_deleted": 0} - assert len(db.executed) == 2 + # +1 vs pre-PR-D1: db.executed now also holds the pre-flight paid-candidates + # count (call #1), issued before either DELETE batch. + assert len(db.executed) == 3 assert db.commits == 2 assert marked["counters"] == out @@ -94,7 +119,7 @@ def test_loops_until_below_batch_size(monkeypatch: pytest.MonkeyPatch) -> None: db = _FakeDB([5, 5, 2, 5, 1]) out = task_mod.purge_expired_trade_in_data(db, run_id=2, batch_size=5, max_batches=20) # type: ignore[arg-type] assert out == {"estimates_deleted": 12, "leads_deleted": 6} - assert len(db.executed) == 5 + assert len(db.executed) == 6 # +1: pre-flight call before the 5 DELETE batches assert db.commits == 5, "each batch must commit independently, not one final commit" @@ -106,14 +131,16 @@ def test_respects_max_batches_cap(monkeypatch: pytest.MonkeyPatch) -> None: db = _FakeDB([5, 5, 5, 5, 5, 5]) # exactly max_batches=3 per table, no more out = task_mod.purge_expired_trade_in_data(db, run_id=3, batch_size=5, max_batches=3) # type: ignore[arg-type] assert out == {"estimates_deleted": 15, "leads_deleted": 15} - assert len(db.executed) == 6 # 3 (estimates) + 3 (leads), NOT unbounded + assert len(db.executed) == 7 # pre-flight + 3 (estimates) + 3 (leads), NOT unbounded def test_default_batch_size_and_max_batches_from_settings(monkeypatch: pytest.MonkeyPatch) -> None: _patch_runs(monkeypatch) db = _FakeDB([0, 0]) # first batch already empty on both tables -> stop immediately task_mod.purge_expired_trade_in_data(db, run_id=4) # type: ignore[arg-type] - _stmt, params = db.executed[0] + # db.executed[0] is now the pre-flight call (no batch_size param) -- the + # first DELETE-batch call (with batch_size) shifted to index 1. + _stmt, params = db.executed[1] assert params is not None assert params["batch_size"] == task_mod.settings.trade_in_purge_batch_size @@ -125,8 +152,9 @@ def test_drains_both_tables_in_order(monkeypatch: pytest.MonkeyPatch) -> None: _patch_runs(monkeypatch) db = _FakeDB([0, 0]) task_mod.purge_expired_trade_in_data(db, run_id=5, batch_size=100, max_batches=1) # type: ignore[arg-type] - first_sql = str(getattr(db.executed[0][0], "text", db.executed[0][0])) - second_sql = str(getattr(db.executed[1][0], "text", db.executed[1][0])) + # index 0 is now the pre-flight call; DELETE batches shifted to 1/2. + first_sql = str(getattr(db.executed[1][0], "text", db.executed[1][0])) + second_sql = str(getattr(db.executed[2][0], "text", db.executed[2][0])) assert "trade_in_estimates" in first_sql assert "trade_in_leads" in second_sql @@ -141,6 +169,81 @@ def test_estimates_sql_is_delete_not_update() -> None: assert not re.search(r":\w+::", sql) +# ── PR-D1 (mera-pr-d-spec.md §1.4): two independent purge safeguards ──────── + + +def test_estimates_sql_excludes_retain_until_not_null() -> None: + """Phase 1: exactly `retain_until IS NULL`, never `< NOW()` -- a paid row + (retain_until IS NOT NULL) must never match the DELETE predicate, full stop, + regardless of how far in the past that date eventually sits.""" + sql = task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text + assert "retain_until IS NULL" in sql + assert "retain_until <" not in sql, "phase 1 must not weaken to retain_until < NOW()" + + +def test_estimates_sql_has_not_exists_payments_safeguard() -> None: + """Independent safeguard: a row with ANY payments row survives even if + retain_until failed to be set (fulfillment bug/race/manual INSERT).""" + sql = task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text + assert "NOT EXISTS" in sql + assert "FROM payments p" in sql + assert "p.estimate_id = trade_in_estimates.id" in sql + + +def test_preflight_sql_is_wider_than_delete_predicate() -> None: + """Pre-flight counts by the BASE (pre-PR-D1) predicate -- expires_at < NOW() + AND created_by IS NULL, WITHOUT retain_until/NOT EXISTS -- so it also catches + the case those two terms exist specifically to guard against (retain_until + unset despite a payments row existing).""" + sql = task_mod._PREFLIGHT_PAID_CANDIDATES_SQL.text + assert "expires_at < NOW()" in sql + assert "created_by IS NULL" in sql + assert "retain_until" not in sql + assert "EXISTS (SELECT 1 FROM payments p WHERE p.estimate_id = e.id)" in sql + assert not re.search(r":\w+::", sql) + + +def test_preflight_aborts_before_any_delete_batch(monkeypatch: pytest.MonkeyPatch) -> None: + """Non-zero pre-flight count -> RuntimeError, mark_failed, ZERO DELETE batches + ever issued (only the pre-flight SELECT itself is in db.executed).""" + marked = _patch_runs(monkeypatch) + db = _FakeDB([], preflight_count=2) # rowcounts empty on purpose: must never be popped + with pytest.raises(RuntimeError, match="pre-flight abort"): + task_mod.purge_expired_trade_in_data(db, run_id=42, batch_size=10, max_batches=20) # type: ignore[arg-type] + + assert len(db.executed) == 1, "only the pre-flight SELECT -- no DELETE batch was issued" + assert db.commits == 0 + assert marked["kind"] == "failed" + assert marked["run_id"] == 42 + assert marked["counters"] == {"estimates_deleted": 0, "leads_deleted": 0} + assert "2" in marked["err"] + + +def test_preflight_zero_candidates_proceeds_as_before(monkeypatch: pytest.MonkeyPatch) -> None: + """preflight_count=0 (default) -- the exact pre-PR-D1 behaviour for every + row that exists today (all retain_until IS NULL) -- run proceeds normally.""" + marked = _patch_runs(monkeypatch) + db = _FakeDB([0, 0]) # preflight_count defaults to 0 + out = task_mod.purge_expired_trade_in_data(db, run_id=43, batch_size=10, max_batches=20) # type: ignore[arg-type] + assert out == {"estimates_deleted": 0, "leads_deleted": 0} + assert marked["kind"] == "done" + + +def test_leads_sql_unchanged_by_pr_d1() -> None: + """Snapshot: _DELETE_EXPIRED_LEADS_SQL byte-for-byte unchanged by PR-D1 — + leads have their own retention deadline (migration 231) and are explicitly + out of scope (mera-pr-d-spec.md §1.4: '_DELETE_EXPIRED_LEADS_SQL — оставить + дословно').""" + expected = ( + "\n DELETE FROM trade_in_leads\n WHERE id IN (\n" + " SELECT id FROM trade_in_leads\n" + " WHERE expires_at < NOW()\n" + " ORDER BY expires_at\n" + " LIMIT CAST(:batch_size AS int)\n )\n " + ) + assert task_mod._DELETE_EXPIRED_LEADS_SQL.text == expected + + def test_leads_sql_is_delete_not_update() -> None: sql = task_mod._DELETE_EXPIRED_LEADS_SQL.text assert "DELETE FROM trade_in_leads" in sql @@ -199,13 +302,14 @@ def test_failure_path_rollback_and_mark_failed(monkeypatch: pytest.MonkeyPatch) class _BoomDB(_FakeDB): def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: - # First batch (estimates) succeeds and commits; second call (still - # draining estimates, or first leads call) explodes. - if len(self.executed) >= 1: + # Call #1 (pre-flight, preflight_count=0) and call #2 (first estimates + # batch) succeed and commit; call #3 (still draining estimates, or + # first leads call) explodes. +1 vs pre-PR-D1 to admit the pre-flight. + if len(self.executed) >= 2: raise RuntimeError("db exploded") return super().execute(stmt, params) - db = _BoomDB([5]) # only ONE successful batch before the boom + db = _BoomDB([5]) # only ONE successful DELETE batch before the boom with pytest.raises(RuntimeError, match="db exploded"): task_mod.purge_expired_trade_in_data(db, run_id=6, batch_size=5, max_batches=20) # type: ignore[arg-type] diff --git a/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx b/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx index 82b35a28..86fd3cec 100644 --- a/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx @@ -32,11 +32,21 @@ import { safeUrl } from "@/lib/safeUrl"; * квалификация — не наше дело: заявка привязывается к конкретному расчёту * (`TradeInLeadInput.estimate_id`), то есть телефон связывается с ранее * сохранённым адресом. Финальную формулировку даёт юрист. - * - «мы удалим ваш телефон и заявку». Механизма удаления в бэкенде НЕТ: - * ни `DELETE FROM trade_in_leads/trade_in_estimates` в коде, ни - * retention/erasure-джоба среди `app/tasks/**` (проверено grep'ом); - * `expires_at` применяется только на чтении. Обещать удаление до появления - * процедуры нельзя — это самое дорогое из обещаний. + * - «мы обещаем удалить всё вообще» без оговорок. После #2547 механизм + * удаления в бэкенде ЕСТЬ: `app/services/data_erasure.py` (ручное + * удаление по обращению — по estimate_id/телефону/Telegram chat id) и + * `app/tasks/purge_expired_trade_in_data.py` (автоматическое удаление по + * истечении срока хранения). Прежняя формулировка «механизма нет» стала + * неправдой и здесь больше не пишется. Оговорка, которая остаётся честной: + * копия сообщения в Telegram-группе поддержки этим механизмом не + * стирается (см. докстринг `data_erasure.py`) — сюда её не выносим + * (излишняя техническая деталь для публичной страницы), но это ограничение + * реальное и известное. + * - PR-D1 (`retain_until`, `trade_in_paid_retention_days`): срок «12 месяцев» + * ниже читается из той же настройки, что и оферта/SQL продления — + * см. `mera-pr-d-spec.md` §1.2 в корне репо. Платёжного кода в этом PR + * нет — срок описан на будущее, синхронно с privacy-обязательством #1.7 + * того же дока, а не «потом». * * Раздел «Что делает эта страница» УСЛОВЕН по `PUBLIC_ESTIMATE_ENABLED`: пока * расчёт выключен, адрес действительно не покидает браузер; после включения это @@ -136,10 +146,16 @@ export default function MeraPublicPrivacyPage() { ) : null}

- Автоматической кнопки «удалить мои данные» в сервисе пока нет, и мы не - обещаем то, чего не умеем: порядок и сроки удаления будут описаны в - утверждённой политике обработки, которая появится здесь до открытия - публичного доступа. + Самостоятельной кнопки «удалить мои данные» в интерфейсе пока нет, но + механизм удаления в сервисе есть: обращение в поддержку об удалении мы + разбираем вручную и физически стираем телефон, адрес и расчёт из базы, + а не просто помечаем запись. Помимо запроса, у данных есть собственный + срок хранения, по истечении которого они удаляются без обращения с + вашей стороны. Если результат расчёта оплачен, ссылка на отчёт и + связанные с ним данные хранятся на нашей стороне 12 месяцев с даты + оплаты, после чего удаляются точно так же — на файл, который вы + скачали себе, это не влияет: мы его не отзываем, не изменяем и не + имеем к нему доступа.

Оператор

diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 1476a7f7..38c7df68 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -123,7 +123,7 @@ const brackets: { key: string; style: CSSProperties }[] = [ // Honest neutral fallbacks for the meta blocks (HeroBar / Footer) before there // is an estimate. Dashes — never the design fixtures (which would read as a fake // real report). -const EMPTY_REPORT: Report = { id: "—", date: "—", validUntil: "—" }; +const EMPTY_REPORT: Report = { id: "—", date: "—", validUntil: "—", retainUntil: null }; const EMPTY_OBJECT: ObjectInfo = { address: "—", city: "", diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts b/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts index 7c66e2ba..6e3de551 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts @@ -30,6 +30,7 @@ export const report: Report = { id: "9b9e2737", date: "20.06.2026", validUntil: "21.06.2026", + retainUntil: null, }; export const object: ObjectInfo = { diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index 81e9ff94..d8a9e676 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -780,6 +780,9 @@ export function mapReport(e: AggregatedEstimate): Report { ? fmtDate(e.created_at) : fmtDateShift(e.expires_at, -24), // pre-BE-1 fallback: no real created_at validUntil: fmtDate(e.expires_at), + // PR-D1: passthrough only, validUntil above stays on expires_at (this + // field is the paid-access retention date, unrelated to report validity). + retainUntil: e.retain_until ?? null, }; } diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts index 81ca7422..99d961b4 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts @@ -5,6 +5,10 @@ export interface Report { id: string; date: string; validUntil: string; + // PR-D1: raw ISO retain_until passthrough (paid-access link lifetime), null + // when unpaid (current traffic). NOT rendered yet -- no payment UI in this + // PR; validUntil stays wired to expires_at, unrelated to this field. + retainUntil: string | null; } export interface ObjectInfo { diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 7db71758..ce3282bd 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -172,6 +172,10 @@ export interface AggregatedEstimate { analogs: AnalogLot[]; // top 5-10 actual_deals: AnalogLot[]; // last 12 mo expires_at: string; // ISO datetime + // PR-D1: срок жизни ссылки/строки (оплаченный доступ, backend migration 234), + // НЕ путать с expires_at (актуальность расчёта). null = неоплачено (текущий + // трафик целиком). + retain_until?: string | null; // ISO datetime // ── Метаданные ── target_address: string | null; target_lat: number | null; From 48664dfe0e7929be804260b4775b6c915cc0d1bb Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 22:49:09 +0300 Subject: [PATCH 02/23] =?UTF-8?q?fix(tradein/payments):=20pre-flight=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BB=D0=B6=D0=B5=D0=BD=20=D0=BB=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B0=D0=BD=D0=BE=D0=BC=D0=B0=D0=BB=D0=B8=D1=8E?= =?UTF-8?q?,=20=D0=BD=D0=B5=20=D1=88=D1=82=D0=B0=D1=82=D0=BD=D0=BE=D0=B5?= =?UTF-8?q?=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5=20(re?= =?UTF-8?q?view=20PR=20#2754)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review MEDIUM: предполётная проверка purge_expired_trade_in_data считала по базовому предикату без retain_until — здоровая оплаченная строка (retain_until проставлен, платёж есть) через сутки после продажи тоже попадала под счётчик, и джоба аварийно останавливалась на первой же честной продаже навсегда (вместе с ней — и 180-дневное удаление лидов, вызываемое из той же функции после этой проверки). - _PREFLIGHT_PAID_CANDIDATES_SQL: добавлен терм `retain_until IS NULL` — теперь считает только реальную аномалию (retain_until не проставлен, а платёж есть), а не штатное состояние. Докстринги функции/модуля поправлены под фактическое поведение. - Тест на неверный инвариант (`"retain_until" not in sql`) заменён на позитивный (`"retain_until IS NULL" in sql`) + добавлены live-DB тесты на оба случая из ревью (здоровая оплаченная строка не поднимает тревогу, джоба не блокируется). - privacy/page.tsx: константа "12 месяцев" вынесена в content.ts (PAID_REPORT_RETENTION_MONTHS) вместо литерала + расходящегося комментария; добавлен сверяющий тест (test_paid_retention_text_consistency.py) по образцу _CONSENT_TEXT_SNAPSHOT. Смягчена формулировка про автоматическое удаление — задача на проде выключена и ни разу не запускалась, текст теперь описывает установленный порядок, а не наблюдаемый факт. - Все 10 висячих ссылок на untracked `mera-pr-d-spec.md` (7 файлов) заменены на краткое изложение сути в комментарии + ссылку на PR #2754. --- tradein-mvp/backend/app/api/v1/trade_in.py | 2 +- tradein-mvp/backend/app/core/config.py | 19 +- .../app/tasks/purge_expired_trade_in_data.py | 91 +++++--- .../234_trade_in_estimates_retain_until.sql | 5 +- .../backend/tests/test_estimate_idor.py | 2 +- .../test_paid_retention_text_consistency.py | 103 +++++++++ .../tests/test_purge_expired_trade_in_data.py | 206 ++++++++++++++++-- .../frontend/src/app/mera-public/content.ts | 19 ++ .../src/app/mera-public/privacy/page.tsx | 29 ++- 9 files changed, 401 insertions(+), 75 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_paid_retention_text_consistency.py diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 05950b0c..3bc7c08d 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -58,7 +58,7 @@ router = APIRouter() # неизбежно без унификации. `retain_until > NOW()` при NULL даёт NULL → false # в SQL — для всех существующих строк (retain_until IS NULL) поведение не # меняется вообще. Не копировать это выражение по месту — только через -# константу/хелпер ниже. См. `mera-pr-d-spec.md` §1.3 в корне репо. +# константу/хелпер ниже. Payments retention, PR #2754. ESTIMATE_READABLE_SQL = "(expires_at > NOW() OR retain_until > NOW())" diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 9a9b4ebf..eb70c787 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -836,15 +836,20 @@ class Settings(BaseSettings): # срок — решение DPO/юриста, не инженера). ENV: TRADE_IN_LEAD_RETENTION_DAYS. trade_in_lead_retention_days: int = 180 - # ── PR-D1: платный отчёт живёт год (retain_until, migration 234) ──────── + # ── Платный отчёт живёт год (retain_until, migration 234, PR #2754) ───── # trade_in_estimates.retain_until TTL (дни ОТ ОПЛАТЫ) — срок жизни ССЫЛКИ/ # СТРОКИ для оплаченной оценки, независимый от expires_at (актуальность - # расчёта, 24ч). НЕ трогает expires_at — см. migration 234 докстринг и - # `mera-pr-d-spec.md` §1.1/§1.2 в корне репо. Единственный источник числа - # «12 месяцев»: текст оферты (content.ts), текст экрана S4 и SQL продления - # retain_until при оплате (платёжный код, отдельный PR) обязаны читать его - # отсюда, а не хардкодить — иначе классический исход "в оферте 12 месяцев, - # в конфиге 365 дней, на экране «год»". ENV: TRADE_IN_PAID_RETENTION_DAYS. + # расчёта, 24ч, глобальный для ВСЕХ строк). НЕ трогает expires_at — см. + # migration 234 докстринг. Отдельная колонка, а не подъём expires_at: + # expires_at печатается в PDF/UI как «актуальность расчёта» и одинаков + # для всех строк, поднять его до года = соврать в документе клиента про + # свежесть цифры + нарушить минимизацию ПДн для неоплаченных B2C-адресов. + # Единственный источник числа «12 месяцев» на фронте — + # `mera-public/content.ts::PAID_REPORT_RETENTION_MONTHS`; текст оферты, + # экран после оплаты и SQL продления retain_until при оплате (платёжный + # код, отдельный PR) обязаны читать его оттуда, а не хардкодить — иначе + # классический исход "в оферте 12 месяцев, в конфиге 365 дней, на экране + # «год»". ENV: TRADE_IN_PAID_RETENTION_DAYS. trade_in_paid_retention_days: int = 365 # Батч-размер физического DELETE в purge_expired_trade_in_data (нельзя одним diff --git a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py index 5a96989c..c48e8dba 100644 --- a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py +++ b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py @@ -55,16 +55,20 @@ BATCHING (не единый DELETE по всей таблице): committed batches deleted (correct, not rolled back) and mark_failed records the partial counters reached so far. -PR-D1 (2026-08-06, payments retention -- see `mera-pr-d-spec.md` §1 at repo root): - the `created_by IS NULL` population above is EXACTLY the future paying-customer - population -- the owner sells this report to individuals for money, and a paid - row must outlive the 24h `expires_at` link TTL. Two independent safeguards were - added to `_DELETE_EXPIRED_ESTIMATES_SQL` (retain_until IS NULL + NOT EXISTS - payments) plus a pre-flight count in `purge_expired_trade_in_data` that refuses - to run at all if it finds a paid candidate -- see the SQL constants and - `_preflight_paid_candidates` below for the mechanics. No payment code lives in - this file; `retain_until` is set by the (separate, not-yet-existing) payment - fulfillment code. +Payments retention (PR #2754): the `created_by IS NULL` population above is EXACTLY + the future paying-customer population -- the owner sells this report to + individuals for money, and a paid row must outlive the 24h `expires_at` link TTL + (a separate column, `retain_until`, set by the -- separate, not-yet-existing -- + payment fulfillment code to now() + settings.trade_in_paid_retention_days, NOT + a change to `expires_at` itself). Two independent safeguards were added to + `_DELETE_EXPIRED_ESTIMATES_SQL` (retain_until IS NULL + NOT EXISTS payments) + plus a pre-flight count in `purge_expired_trade_in_data` that refuses to run at + all if it finds an ANOMALOUS paid candidate -- see the SQL constants and + `_preflight_paid_candidates` below for the mechanics (deep-review finding + 2026-08-06 MEDIUM on PR #2754: the pre-flight predicate itself must ALSO carry + `retain_until IS NULL`, otherwise a perfectly healthy paid row trips it and + wedges the job permanently -- see that function's docstring). No payment code + lives in this file. """ from __future__ import annotations @@ -86,9 +90,14 @@ logger = logging.getLogger(__name__) _DEFAULT_MAX_BATCHES = 20 # -# PR-D1 (2026-08-06): два независимые предохранителя добавлены к тому же -# предикату, ПЕРЕД тем как платёжный код появился в проекте (мина уже была -# заряжена, см. `mera-pr-d-spec.md` §1 в корне репо): +# Payments retention (2026-08-06, PR #2754): два независимых предохранителя +# добавлены к тому же предикату ПЕРЕД тем, как платёжный код появился в +# проекте (мина уже была заряжена: без них джоба удаляла бы будущих платящих +# клиентов). Отдельная колонка retain_until (не подъём expires_at) — потому +# что expires_at глобальный TTL расчёта на ВСЕ строки (включая неоплаченные) +# и печатается в PDF/UI как «актуальность расчёта»; поднять его до года +# означало бы одновременно нарушить минимизацию ПДн по 152-ФЗ и соврать в +# документе клиента про срок актуальности цифры: # 1. `retain_until IS NULL` — именно IS NULL, НЕ `< NOW()`. Оплаченная # строка (retain_until IS NOT NULL, migration 234) не удаляется джобой # В ПРИНЦИПЕ, пока не поднято ослабление отдельным PR не раньше чем @@ -117,17 +126,25 @@ _DELETE_EXPIRED_ESTIMATES_SQL = text( """ ) -# PR-D1 pre-flight (см. _preflight_paid_candidates): считает по БАЗОВОМУ -# (пред-PR-D1) предикату purge -- `expires_at < NOW() AND created_by IS NULL`, -# БЕЗ retain_until/NOT EXISTS -- специально ШИРЕ итогового DELETE-предиката -# выше, чтобы поймать именно случай "retain_until не проставлен, а деньги -# были" (а не только штатно защищённые retain_until IS NOT NULL строки, -# которые и так не попали бы под DELETE). +# Pre-flight (см. _preflight_paid_candidates). deep-review finding 2026-08-06 +# MEDIUM (PR #2754): первая редакция считала по БАЗОВОМУ предикату БЕЗ +# retain_until вообще -- а это ловит и штатно-здоровые оплаченные строки +# (retain_until проставлен, есть payments) точно так же, как настоящую +# аномалию (retain_until НЕ проставлен, но payments есть) -- джоба вставала +# на первой же честной продаже и больше никогда не запускалась (вместе с ней +# вставало и удаление лидов, вызываемое из той же функции ПОСЛЕ этой +# проверки -- 180-дневный purge по 152-ФЗ тоже переставал бы работать). +# Правильная форма: базовый предикат AND "новый предохранитель НЕ сработал +# бы" (retain_until IS NULL) AND "признак аномалии" (payments всё же есть). +# Здоровая оплаченная строка (retain_until IS NOT NULL) исключается ЭТИМ +# термом -- она и так под DELETE не попадает (см. safeguard 1 выше), тревогу +# поднимать не должна. _PREFLIGHT_PAID_CANDIDATES_SQL = text( """ SELECT count(*) FROM trade_in_estimates e WHERE e.expires_at < NOW() AND e.created_by IS NULL + AND e.retain_until IS NULL AND EXISTS (SELECT 1 FROM payments p WHERE p.estimate_id = e.id) """ ) @@ -181,14 +198,20 @@ def _drain_expired( def _preflight_paid_candidates(db: Session) -> int: - """PR-D1 safety gate: count purge-candidates (base predicate) that have a payments row. + """Safety gate: count ANOMALOUS purge-candidates -- base predicate, retain_until + IS NULL (safeguard 1 did NOT protect the row), AND a payments row exists anyway. - Runs BEFORE any DELETE batch. A non-zero result means at least one estimate that - would have matched the OLD (pre-PR-D1) purge predicate was actually touched by - money -- either `retain_until` failed to be set (fulfillment bug/race/manual - INSERT) or something inconsistent happened. Either way this run must not delete - anything; see `purge_expired_trade_in_data` below, which aborts before the first - batch when this returns non-zero. + Runs BEFORE any DELETE batch. A non-zero result means fulfillment failed to set + `retain_until` on a row money actually touched (bug/race/manual INSERT) -- this + run must not delete anything; see `purge_expired_trade_in_data` below, which + aborts before the first batch when this returns non-zero. + + MUST include `retain_until IS NULL` (deep-review finding 2026-08-06 MEDIUM, PR + #2754): a healthy paid row (retain_until set, has a payments row) is the EXPECTED + steady state one day after every sale -- without this term it counts as a "paid + candidate" too, so the very first successful sale permanently wedges this job + (mark_failed, zero deletions, forever -- and since leads purge runs from the same + function AFTER this check, the unrelated 180-day lead retention would also stop). """ return db.execute(_PREFLIGHT_PAID_CANDIDATES_SQL).scalar_one() @@ -207,11 +230,15 @@ def purge_expired_trade_in_data( Returns {"estimates_deleted": N, "leads_deleted": M}. - PR-D1 pre-flight (see `_preflight_paid_candidates`): if any purge-candidate - estimate has a `payments` row, the run aborts BEFORE the first DELETE batch -- - zero rows deleted, `mark_failed` records why. This is deliberately checked - outside the `try` below so it can never be caught and silently re-reported as a - generic mid-run failure -- it is a distinct, actionable pre-condition failure. + Payments retention pre-flight (see `_preflight_paid_candidates`): if any + purge-candidate estimate has `retain_until IS NULL` AND a `payments` row (the + ANOMALY -- fulfillment failed to set retain_until on a row money touched), the + run aborts BEFORE the first DELETE batch (estimates OR leads) -- zero rows + deleted, `mark_failed` records why. Healthy paid rows (retain_until set) do NOT + trip this -- they never matched the check to begin with. This is deliberately + checked outside the `try` below so it can never be caught and silently + re-reported as a generic mid-run failure -- it is a distinct, actionable + pre-condition failure. """ batch_size = batch_size or settings.trade_in_purge_batch_size max_batches = max_batches or _DEFAULT_MAX_BATCHES @@ -221,7 +248,7 @@ def purge_expired_trade_in_data( if paid_candidates: error = ( f"pre-flight abort: {paid_candidates} purge-candidate trade_in_estimates " - "row(s) have a matching payments row (retain_until may be unset) -- " + "row(s) have retain_until IS NULL but a matching payments row -- " "refusing to run, zero rows deleted" ) logger.error("purge_expired_trade_in_data run_id=%d %s", run_id, error) diff --git a/tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql b/tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql index 2d2e3d4f..912eb915 100644 --- a/tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql +++ b/tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql @@ -1,6 +1,7 @@ -- 234_trade_in_estimates_retain_until.sql --- PR-D1 «Ретеншен: оплаченное живёт год, purge его не трогает» — см. --- `mera-pr-d-spec.md` §1 в корне репо (обоснования там, здесь только SQL). +-- Платёжный контур МЕРЫ, ретеншен (PR #2754): «оплаченное живёт год, purge +-- его не трогает». Владелец продаёт отчёт физлицу за 150 ₽ — отчёт должен +-- жить год на нашей стороне, а не 24ч (см. WHY ниже). -- Номер сверен и по `forgejo/main`, и по всем открытым PR-веткам на момент -- написания (последняя занятая — 233_payments.sql) — см. урок в шапке того -- же файла про то, как коллизия 228/229/231/232 обнаруживается поздно. diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index 1dc6aaf9..7cb0e735 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -605,7 +605,7 @@ def test_get_estimate_imv_benchmark_other_pilot_gets_404(trade_in_app: FastAPI) assert resp.status_code == 404 -# ── PR-D1: retention gate unification (retain_until, mera-pr-d-spec.md §1.3) ── +# ── Payments retention: retention gate unification (retain_until, PR #2754) ── def test_estimate_readable_sql_uses_disjunction() -> None: diff --git a/tradein-mvp/backend/tests/test_paid_retention_text_consistency.py b/tradein-mvp/backend/tests/test_paid_retention_text_consistency.py new file mode 100644 index 00000000..434d419f --- /dev/null +++ b/tradein-mvp/backend/tests/test_paid_retention_text_consistency.py @@ -0,0 +1,103 @@ +"""Payments retention (PR #2754) — "12 месяцев" text sync guard. + +WHY: + mera-public/content.ts declares itself as the ONE place product promises + live (docstring at the top of that file: "ни одного утверждения, которого + не делает код"). The public retention promise ("оплаченный отчёт хранится + N месяцев") has THREE places it could quietly drift: the backend setting + (`settings.trade_in_paid_retention_days`), the frontend constant + (`PAID_REPORT_RETENTION_MONTHS` in content.ts), and any page that renders + it (today: privacy/page.tsx). Deep-review finding 2026-08-06 MEDIUM on + PR #2754 caught exactly this: a comment claimed the number "reads from the + same setting" while the page actually hardcoded a `12 месяцев` literal -- + a comment cannot fail CI, same lesson as + test_consent_text_frontend_sync.py's _CONSENT_TEXT_SNAPSHOT guard (which + this file mirrors). + +WHAT: + 1. privacy/page.tsx imports PAID_REPORT_RETENTION_MONTHS from content.ts + and does NOT hardcode a "N месяцев" literal of its own. + 2. The frontend months constant and the backend days setting stay within + a sane calendar tolerance of each other (28-31 days per month) -- this + does NOT enforce byte-identity (days and months are different units by + design, see content.ts docstring), only that nobody silently changes + one without the other drifting out of "still honestly ~12 months". +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_FRONTEND_CONTENT = ( + Path(__file__).resolve().parents[2] / "frontend" / "src" / "app" / "mera-public" / "content.ts" +) +_FRONTEND_PRIVACY_PAGE = ( + Path(__file__).resolve().parents[2] + / "frontend" + / "src" + / "app" + / "mera-public" + / "privacy" + / "page.tsx" +) + +_MONTHS_CONST_RE = re.compile(r"PAID_REPORT_RETENTION_MONTHS\s*=\s*(\d+)\s*;") +_LITERAL_MONTHS_RE = re.compile(r'"?\d+ месяцев"?') + + +def _extract_months_constant(content_ts_source: str) -> int: + match = _MONTHS_CONST_RE.search(content_ts_source) + assert match is not None, ( + "PAID_REPORT_RETENTION_MONTHS not found in content.ts -- " + "constant renamed/removed without updating this test" + ) + return int(match.group(1)) + + +def test_frontend_files_exist() -> None: + assert _FRONTEND_CONTENT.is_file(), f"missing frontend file: {_FRONTEND_CONTENT}" + assert _FRONTEND_PRIVACY_PAGE.is_file(), f"missing frontend file: {_FRONTEND_PRIVACY_PAGE}" + + +def test_privacy_page_imports_retention_constant_not_hardcoded() -> None: + """The whole point: FAILS if privacy/page.tsx stops importing the shared + constant and goes back to a hardcoded '12 месяцев' literal (exactly the + drift the deep-review finding caught -- comment said 'reads from content.ts', + code said otherwise).""" + src = _FRONTEND_PRIVACY_PAGE.read_text(encoding="utf-8") + assert "PAID_REPORT_RETENTION_MONTHS" in src, ( + "privacy/page.tsx no longer references PAID_REPORT_RETENTION_MONTHS -- " + "the retention promise must be rendered from the shared content.ts " + "constant, not written out again by hand" + ) + assert re.search(r'from\s+"\.\./content"', src), ( + "privacy/page.tsx must import from '../content' (content.ts), where " + "PAID_REPORT_RETENTION_MONTHS is declared" + ) + literal_hits = _LITERAL_MONTHS_RE.findall(src) + assert not literal_hits, ( + "privacy/page.tsx contains a hardcoded 'N месяцев' literal -- render the " + "PAID_REPORT_RETENTION_MONTHS constant instead: " + f"{literal_hits!r}" + ) + + +def test_backend_days_setting_matches_frontend_months_within_calendar_tolerance() -> None: + """Not byte-identity (days vs months are different units, deliberately -- + see content.ts docstring on PAID_REPORT_RETENTION_MONTHS): just a sanity + bound that `trade_in_paid_retention_days` still honestly rounds to the + number of months the public page promises (28-31 days/month, generous).""" + from app.core.config import settings + + days = settings.trade_in_paid_retention_days + months = _extract_months_constant(_FRONTEND_CONTENT.read_text(encoding="utf-8")) + + assert 28 * months <= days <= 31 * months, ( + f"settings.trade_in_paid_retention_days={days} no longer honestly rounds to " + f"content.ts PAID_REPORT_RETENTION_MONTHS={months} -- update both together " + "(and the offer text, when it exists) so the public promise stays true" + ) diff --git a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py index caa1b207..a49f1a96 100644 --- a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py +++ b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py @@ -7,20 +7,22 @@ Covers app/tasks/purge_expired_trade_in_data.py: - both tables (trade_in_estimates, trade_in_leads) get drained - failure path: rollback + mark_failed with partial counters, exception re-raised - SQL shape: DELETE (not UPDATE/deactivate), no psycopg `::` cast trap - - PR-D1 (payments retention, mera-pr-d-spec.md §1.4): retain_until IS NULL + - NOT EXISTS(payments) safeguards on the estimates DELETE, plus a pre-flight - that refuses to run at all if it finds a paid purge-candidate. + - Payments retention (PR #2754): retain_until IS NULL + NOT EXISTS(payments) + safeguards on the estimates DELETE, plus a pre-flight that refuses to run + at all if it finds an ANOMALOUS paid purge-candidate (retain_until unset + despite a payments row) -- a healthy paid row (retain_until set) must NOT + trip it, see test_preflight_ignores_healthy_paid_row below. Style mirrors tests/test_deactivate_stale_listings.py (_FakeDB, monkeypatched runs_mod.mark_done/mark_failed). -PR-D1 note on _FakeDB: purge_expired_trade_in_data now issues ONE extra -db.execute() call BEFORE any DELETE batch — the pre-flight paid-candidates -count (_PREFLIGHT_PAID_CANDIDATES_SQL). _FakeDB special-cases that statement -by identity and answers it from `preflight_count` (default 0 == "no paid -candidates, proceed exactly as before this PR"). Every pre-existing test's -`db.executed` index shifted by +1 to account for this; `db.commits` is -unaffected (the pre-flight is a read, never committed). +Payments retention note on _FakeDB: purge_expired_trade_in_data now issues ONE +extra db.execute() call BEFORE any DELETE batch — the pre-flight paid- +candidates count (_PREFLIGHT_PAID_CANDIDATES_SQL). _FakeDB special-cases that +statement by identity and answers it from `preflight_count` (default 0 == +"no anomalous candidates, proceed exactly as before this PR"). Every +pre-existing test's `db.executed` index shifted by +1 to account for this; +`db.commits` is unaffected (the pre-flight is a read, never committed). """ from __future__ import annotations @@ -169,7 +171,7 @@ def test_estimates_sql_is_delete_not_update() -> None: assert not re.search(r":\w+::", sql) -# ── PR-D1 (mera-pr-d-spec.md §1.4): two independent purge safeguards ──────── +# ── Payments retention (PR #2754): two independent purge safeguards ───────── def test_estimates_sql_excludes_retain_until_not_null() -> None: @@ -190,15 +192,20 @@ def test_estimates_sql_has_not_exists_payments_safeguard() -> None: assert "p.estimate_id = trade_in_estimates.id" in sql -def test_preflight_sql_is_wider_than_delete_predicate() -> None: - """Pre-flight counts by the BASE (pre-PR-D1) predicate -- expires_at < NOW() - AND created_by IS NULL, WITHOUT retain_until/NOT EXISTS -- so it also catches - the case those two terms exist specifically to guard against (retain_until - unset despite a payments row existing).""" +def test_preflight_sql_requires_retain_until_is_null() -> None: + """Deep-review finding 2026-08-06 MEDIUM (PR #2754): the pre-flight predicate + MUST carry `retain_until IS NULL` -- without it, a perfectly healthy paid row + (retain_until set, has a payments row -- the ORDINARY steady state one day + after every sale) trips the alarm exactly as hard as the real anomaly + (retain_until unset despite a payments row existing), permanently wedging + the job on the very first successful sale (and, since leads purge runs from + the same function AFTER this check, silently stopping 180-day 152-ФЗ lead + retention too). See test_real_preflight_ignores_healthy_paid_row below for + the behavioural proof against a real DB.""" sql = task_mod._PREFLIGHT_PAID_CANDIDATES_SQL.text assert "expires_at < NOW()" in sql assert "created_by IS NULL" in sql - assert "retain_until" not in sql + assert "retain_until IS NULL" in sql assert "EXISTS (SELECT 1 FROM payments p WHERE p.estimate_id = e.id)" in sql assert not re.search(r":\w+::", sql) @@ -230,10 +237,10 @@ def test_preflight_zero_candidates_proceeds_as_before(monkeypatch: pytest.Monkey def test_leads_sql_unchanged_by_pr_d1() -> None: - """Snapshot: _DELETE_EXPIRED_LEADS_SQL byte-for-byte unchanged by PR-D1 — - leads have their own retention deadline (migration 231) and are explicitly - out of scope (mera-pr-d-spec.md §1.4: '_DELETE_EXPIRED_LEADS_SQL — оставить - дословно').""" + """Snapshot: _DELETE_EXPIRED_LEADS_SQL byte-for-byte unchanged by payments + retention (PR #2754) — leads have their own retention deadline (migration + 231, no created_by/B2B split, no payments concept) and are explicitly out + of scope for the payments-retention safeguards.""" expected = ( "\n DELETE FROM trade_in_leads\n WHERE id IN (\n" " SELECT id FROM trade_in_leads\n" @@ -443,3 +450,160 @@ def test_real_purge_deletes_only_anonymous_expired_estimates() -> None: ) db.commit() db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_preflight_ignores_healthy_paid_row_flags_only_anomaly() -> None: + """Deep-review finding 2026-08-06 MEDIUM on PR #2754, reproduced exactly + against a real DB: a HEALTHY paid row (retain_until set, a payments row + exists) is the ordinary steady state one day after every sale and must NOT + raise the pre-flight count; a row where fulfillment failed to set + retain_until despite a payments row existing is the real ANOMALY and must. + Baseline-delta assertions (not absolute counts) so this is safe to run + against a dev DB that may already contain unrelated rows.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + healthy_id = uuid4() + anomaly_id = uuid4() + healthy_order = f"pytest-healthy-{uuid4().hex[:12]}" + anomaly_order = f"pytest-anomaly-{uuid4().hex[:12]}" + try: + baseline = task_mod._preflight_paid_candidates(db) + + # Healthy: retain_until set (paid, safeguard 1 already protects it) + + # a payments row -- exactly what every successful sale looks like a day + # later. Must NOT move the pre-flight count. + db.execute( + _t( + "INSERT INTO trade_in_estimates " + "(id, address, area_m2, rooms, floor, total_floors, " + " median_price, range_low, range_high, median_price_per_m2, confidence, " + " expires_at, created_by, retain_until) VALUES " + "(CAST(:id AS uuid), 'purge-test здоровая оплаченная', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', NULL, NOW() + interval '363 days')" + ), + {"id": str(healthy_id)}, + ) + db.execute( + _t( + "INSERT INTO payments " + "(order_id, terminal_key, product_code, amount_kopecks, status, estimate_id) " + "VALUES (:order_id, 'pytest_terminal', 'trade_in_report', 15000, 'CONFIRMED', " + " CAST(:id AS uuid))" + ), + {"order_id": healthy_order, "id": str(healthy_id)}, + ) + db.commit() + assert ( + task_mod._preflight_paid_candidates(db) == baseline + ), "healthy paid row (retain_until set) must NOT raise the pre-flight count" + + # Anomaly: retain_until NULL despite a payments row existing -- exactly + # the case the two DELETE safeguards exist for. Must raise by exactly one. + db.execute( + _t( + "INSERT INTO trade_in_estimates " + "(id, address, area_m2, rooms, floor, total_floors, " + " median_price, range_low, range_high, median_price_per_m2, confidence, " + " expires_at, created_by, retain_until) VALUES " + "(CAST(:id AS uuid), 'purge-test настоящая аномалия', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', NULL, NULL)" + ), + {"id": str(anomaly_id)}, + ) + db.execute( + _t( + "INSERT INTO payments " + "(order_id, terminal_key, product_code, amount_kopecks, status, estimate_id) " + "VALUES (:order_id, 'pytest_terminal', 'trade_in_report', 15000, 'CONFIRMED', " + " CAST(:id AS uuid))" + ), + {"order_id": anomaly_order, "id": str(anomaly_id)}, + ) + db.commit() + assert task_mod._preflight_paid_candidates(db) == baseline + 1, ( + "anomaly row (retain_until unset, payments row exists) must raise " + "the pre-flight count by exactly one" + ) + finally: + db.execute( + _t("DELETE FROM payments WHERE order_id = ANY(CAST(:orders AS text[]))"), + {"orders": [healthy_order, anomaly_order]}, + ) + db.execute( + _t("DELETE FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"), + {"ids": [str(healthy_id), str(anomaly_id)]}, + ) + db.commit() + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_purge_not_wedged_by_healthy_paid_row() -> None: + """Deep-review finding 2026-08-06 MEDIUM on PR #2754: before the fix, a + healthy paid row anywhere in the table (retain_until set, has a payments + row) permanently wedged the job -- the very first successful sale would + have made every subsequent scheduled run abort in mark_failed with zero + deletions FOREVER, silently taking 180-day leads purge (152-ФЗ) down with + it (leads purge runs from the same function AFTER the pre-flight check). + This proves a real end-to-end run completes normally (mark_done) in the + presence of such a row.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + healthy_id = uuid4() + healthy_order = f"pytest-wedge-{uuid4().hex[:12]}" + try: + db.execute( + _t( + "INSERT INTO trade_in_estimates " + "(id, address, area_m2, rooms, floor, total_floors, " + " median_price, range_low, range_high, median_price_per_m2, confidence, " + " expires_at, created_by, retain_until) VALUES " + "(CAST(:id AS uuid), 'purge-test не блокирует джобу', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', NULL, NOW() + interval '363 days')" + ), + {"id": str(healthy_id)}, + ) + db.execute( + _t( + "INSERT INTO payments " + "(order_id, terminal_key, product_code, amount_kopecks, status, estimate_id) " + "VALUES (:order_id, 'pytest_terminal', 'trade_in_report', 15000, 'CONFIRMED', " + " CAST(:id AS uuid))" + ), + {"order_id": healthy_order, "id": str(healthy_id)}, + ) + db.commit() + + # Must complete normally -- no RuntimeError, no mark_failed short-circuit + # (would raise before reaching this line if the bug were still present). + result = task_mod.purge_expired_trade_in_data( + db, run_id=999999998, batch_size=100, max_batches=1 + ) + assert set(result) == {"estimates_deleted", "leads_deleted"}, ( + "leads purge must also have run -- it is NOT reachable when the " + "pre-flight wrongly aborts first" + ) + + still_there = db.execute( + _t("SELECT id FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"), + {"id": str(healthy_id)}, + ).fetchone() + assert still_there is not None, "healthy paid row must survive the run untouched" + finally: + db.execute( + _t("DELETE FROM payments WHERE order_id = :order_id"), {"order_id": healthy_order} + ) + db.execute( + _t("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"), + {"id": str(healthy_id)}, + ) + db.commit() + db.close() diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts index 3e521b68..66772877 100644 --- a/tradein-mvp/frontend/src/app/mera-public/content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/content.ts @@ -92,6 +92,25 @@ export const LEGAL_ENTITY: { /** Внутренний маршрут страницы про обработку персональных данных. */ export const PRIVACY_PATH = "/mera-public/privacy"; +/** + * Сколько месяцев на нашей стороне хранится ссылка/строка оплаченного отчёта + * после оплаты (`trade_in_estimates.retain_until`, migration 234) — НЕ срок + * действия самого расчёта (тот отдельный, `expires_at`, часы). + * + * ЕДИНСТВЕННОЕ место, где это число хардкодится на фронте — любой другой + * текст (оферта, экран после оплаты) обязан импортировать эту константу, а + * не писать «12 месяцев» заново (см. `test_paid_retention_text_consistency.py` + * — проверяет, что privacy-страница действительно использует эту константу, + * а не литерал). + * + * Источник истины на бэкенде — `settings.trade_in_paid_retention_days = 365` + * (`app/core/config.py`). 365 дней округляется до «12 месяцев» для + * человекочитаемого текста (12×30=360..12×31=372 — 365 попадает в диапазон); + * если бэкендовое число когда-нибудь изменится так, что «12» перестанет быть + * честным округлением — обновить оба места руками, тест это тоже проверяет. + */ +export const PAID_REPORT_RETENTION_MONTHS = 12; + // --------------------------------------------------------------------------- // География // --------------------------------------------------------------------------- diff --git a/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx b/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx index 86fd3cec..0cdd50cf 100644 --- a/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { LEGAL_ENTITY, + PAID_REPORT_RETENTION_MONTHS, PUBLIC_ESTIMATE_ENABLED, SUPPORT_TELEGRAM_LABEL, SUPPORT_TELEGRAM_URL, @@ -42,11 +43,16 @@ import { safeUrl } from "@/lib/safeUrl"; * стирается (см. докстринг `data_erasure.py`) — сюда её не выносим * (излишняя техническая деталь для публичной страницы), но это ограничение * реальное и известное. - * - PR-D1 (`retain_until`, `trade_in_paid_retention_days`): срок «12 месяцев» - * ниже читается из той же настройки, что и оферта/SQL продления — - * см. `mera-pr-d-spec.md` §1.2 в корне репо. Платёжного кода в этом PR - * нет — срок описан на будущее, синхронно с privacy-обязательством #1.7 - * того же дока, а не «потом». + * - Срок хранения оплаченного отчёта (PR #2754, `retain_until` / + * `trade_in_paid_retention_days` в backend `config.py`): число месяцев + * ниже — константа `PAID_REPORT_RETENTION_MONTHS` из `../content.ts`, + * НЕ литерал здесь (см. докстринг константы — она единственный источник + * этого числа на фронте; `test_paid_retention_text_consistency.py` + * проверяет, что эта страница действительно её импортирует). Платёжного + * кода в этом PR нет — срок описан на будущее, вместе с честной правкой + * ниже про то, что удаление сегодня описывает установленный ПОРЯДОК, а + * не наблюдаемый на проде автоматический прогон (задача засеяна + * выключенной). * * Раздел «Что делает эта страница» УСЛОВЕН по `PUBLIC_ESTIMATE_ENABLED`: пока * расчёт выключен, адрес действительно не покидает браузер; после включения это @@ -149,12 +155,13 @@ export default function MeraPublicPrivacyPage() { Самостоятельной кнопки «удалить мои данные» в интерфейсе пока нет, но механизм удаления в сервисе есть: обращение в поддержку об удалении мы разбираем вручную и физически стираем телефон, адрес и расчёт из базы, - а не просто помечаем запись. Помимо запроса, у данных есть собственный - срок хранения, по истечении которого они удаляются без обращения с - вашей стороны. Если результат расчёта оплачен, ссылка на отчёт и - связанные с ним данные хранятся на нашей стороне 12 месяцев с даты - оплаты, после чего удаляются точно так же — на файл, который вы - скачали себе, это не влияет: мы его не отзываем, не изменяем и не + а не просто помечаем запись. Помимо запроса, для каждого типа данных + установлен срок хранения, по истечении которого они подлежат + удалению. Если результат расчёта оплачен, ссылка на отчёт и + связанные с ним данные хранятся на нашей стороне{" "} + {PAID_REPORT_RETENTION_MONTHS} месяцев с даты оплаты, а затем + подлежат удалению так же, как и остальные данные — на файл, который + вы скачали себе, это не влияет: мы его не отзываем, не изменяем и не имеем к нему доступа.

From a9096f125a50dea08bc2f901b14a0c461a4a56ef Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 7 Aug 2026 09:30:14 +0000 Subject: [PATCH 03/23] =?UTF-8?q?fix(tradein/db):=20=D1=81=D0=BD=D1=8F?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B1=D0=B0=D0=B9=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B4=D1=83=D0=B1=D0=BB=D1=8C=20=D0=B8=D0=BD?= =?UTF-8?q?=D0=B4=D0=B5=D0=BA=D1=81=D0=B0=20=D0=BD=D0=B0=20trade=5Fin=5Fes?= =?UTF-8?q?timates(expires=5Fat)=20(#2752)=20(#2784)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../250_drop_duplicate_expires_at_index.sql | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql diff --git a/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql b/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql new file mode 100644 index 00000000..b0866aaa --- /dev/null +++ b/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql @@ -0,0 +1,98 @@ +-- 250_drop_duplicate_expires_at_index.sql +-- Issue #2752 — снос дубля индекса на trade_in_estimates(expires_at). +-- +-- WHY: +-- 229_trade_in_estimates_consent_proof.sql (применена 2026-08-06 17:09) +-- создала trade_in_estimates_expires_at_idx. Это ПОБАЙТОВЫЙ дубль +-- trade_in_estimates_expires_idx из 001_trade_in_estimates.sql. +-- +-- Дословное сравнение на проде 2026-08-07 (pg_index, а не по имени): +-- name indkey indclass indoption indcollation pred am +-- trade_in_estimates_expires_idx 22 3127 0 0 — btree +-- trade_in_estimates_expires_at_idx 22 3127 0 0 — btree +-- Совпадает всё: колонка, класс операторов, направление сортировки, +-- NULLS-порядок (indoption=0 → ASC/NULLS LAST у обоих), коллация, +-- отсутствие частичного предиката, метод доступа. Ни один не привязан к +-- ограничению (pg_constraint.conindid пуст для обоих), в pg_depend на них +-- никто не ссылается — снос ничего не роняет по цепочке и НЕ требует +-- CASCADE (важно: в этом продукте DROP ... CASCADE уже терял гранты +-- FDW-пользователю). Гранты живут на таблице, не на индексе. +-- +-- ── Почему у «нулевого» дубля появились сканы ──────────────────────────────── +-- В теле #2752 значилось «у нового 0 сканов». Через сутки у него 15, а у +-- старого счётчик ЗАМОРОЖЕН на 234 (два замера, 09:14 и 09:18 UTC: старый +-- +0, новый +4). То есть планировщик перевёл на новый ВЕСЬ живой трафик. +-- +-- Причина не семантическая, а физическая: индексы идентичны, но новый +-- собран вчера с нуля и плотнее упакован — relpages 5 против 6 у старого, +-- разъеденного месяцем UPDATE/DELETE. genericcostestimate() считает спуск +-- по дереву от числа страниц, 5 < 6 → новый дешевле на доли единицы cost, +-- и при прочих равных выигрывает. Никакого нового запроса не появилось: +-- отношение idx_tup_read/idx_scan у обоих одного порядка (1.88 у старого, +-- 0.93 у нового) — это один и тот же класс точечных lookup'ов, просто +-- переехавший на более свежий индекс. Со временем новый забронзовеет так же +-- и они поменялись бы местами обратно. +-- +-- ── ОПРОВЕРГНУТО: обоснование индекса в самой 229 ──────────────────────────── +-- 229 завела индекс осознанно, с мотивировкой «обслуживает retention-задачу +-- purge_expired_trade_in_data (migration 231) — без индекса batched-DELETE +-- делал бы full scan». На проде это НЕ так. Фактический план боевого +-- запроса из app/tasks/purge_expired_trade_in_data.py (EXPLAIN, прод +-- 2026-08-07): +-- Limit → Sort (Sort Key: expires_at) +-- → Bitmap Heap Scan Filter: (expires_at < now()) +-- → Bitmap Index Scan on idx_trade_in_estimates_created_by_created_at +-- Index Cond: (created_by IS NULL) +-- Задача purge ограничена `AND created_by IS NULL` (129 строк из 1061), и +-- планировщик берёт именно этот, более селективный индекс, а expires_at +-- остаётся Filter'ом. Ни один из двух expires-индексов в этом плане не +-- участвует. Так что аргумента «оставить именно индекс из 229, он заведён +-- под конкретный запрос» не существует — запрос его не использует. +-- Поэтому оставлен индекс из 001: он объявлен в миграции, создающей саму +-- таблицу, и на свежей БД (001..N по порядку) переживший индекс совпадёт с +-- прод-состоянием, без «001 создаёт — 250 сносит» на каждой новой БД. +-- +-- ── Планы ДО и ПОСЛЕ ───────────────────────────────────────────────────────── +-- Индексы побайтово идентичны, поэтому смена узла невозможна в принципе: +-- меняется только имя индекса в строке плана и cost на одну страницу спуска. +-- Проверено на чистом PostgreSQL 16.4 (та же минорная версия, что на проде) +-- с воспроизведённым перекосом плотности: +-- ДО: Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..31.84) +-- ПОСЛЕ: Index Scan using trade_in_estimates_expires_idx (cost=0.28..38.30) +-- Форма плана, Index Cond и Filter идентичны; отличается только имя. +-- На проде разрыв плотности меньше (5 против 6 страниц, а не 5 против 8), +-- то есть и дельта cost будет меньше синтетической. +-- +-- ── Стоимость блокировки ───────────────────────────────────────────────────── +-- Обычный DROP INDEX берёт ACCESS EXCLUSIVE на таблицу. Здесь это дёшево: +-- trade_in_estimates — 1061 строка, heap 1856 kB (231 страница), сам +-- сносимый индекс 40 kB. DROP INDEX ничего не переписывает — это удаление +-- строк каталога плюс unlink файла, единицы миллисекунд. CONCURRENTLY не +-- нужен и был бы хуже: он не может выполняться внутри блока транзакции, а +-- значит файл пришлось бы оставить без BEGIN/COMMIT (см. разбор механики +-- раннера в 225_listing_source_snapshots_run_id_idx.sql). +-- +-- IDEMPOTENCY / SAFETY: +-- - DROP INDEX IF EXISTS — безопасный re-run; без CASCADE. +-- - Одна DDL-операция внутри BEGIN/COMMIT: либо применилась, либо нет. +-- - COMMENT ON INDEX переносит знание из 229 на переживший индекс, чтобы +-- дубль не завели заново (в т.ч. фиксирует, что purge его НЕ использует). +-- +-- Dependencies: 001_trade_in_estimates.sql (создаёт переживший индекс), +-- 229_trade_in_estimates_consent_proof.sql (создала сносимый дубль). +-- Deploy order: standalone. Ничего не ждёт и никого не блокирует. +-- +-- Критерий приёмки (записан ДО применения): +-- после деплоя pg_stat_user_indexes.idx_scan у trade_in_estimates_expires_idx +-- должен СДВИНУТЬСЯ с 234 в течение часа. Если он останется 234, значит +-- трафик ушёл не на переживший индекс, а в Seq Scan — это опровергло бы +-- разбор выше и требовало бы отката. + +BEGIN; + +DROP INDEX IF EXISTS trade_in_estimates_expires_at_idx; + +COMMENT ON INDEX trade_in_estimates_expires_idx IS + 'Единственный индекс на trade_in_estimates(expires_at) (001). НЕ заводить второй: 229 создала побайтовый дубль trade_in_estimates_expires_at_idx, снят миграцией 250 (#2752). Мотивировка 229 («под batched-DELETE в purge_expired_trade_in_data») на проде не подтвердилась: тот запрос сужен по created_by IS NULL и идёт через idx_trade_in_estimates_created_by_created_at, expires_at остаётся Filter''ом.'; + +COMMIT; From 1f036e94d1d9ef59c9f9aee3de0836d54ae24855 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 7 Aug 2026 09:30:23 +0000 Subject: [PATCH 04/23] =?UTF-8?q?refactor(security):=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D1=83=D1=8E?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D1=83=20=D0=B0?= =?UTF-8?q?=D0=B4=D0=BC=D0=B8=D0=BD=D1=81=D0=BA=D0=BE=D0=B3=D0=BE=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BA=D0=B5=D0=BD=D0=B0=20(#2775)=20(#2776)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 8 +- backend/app/core/config.py | 3 - backend/app/core/deps.py | 23 ----- backend/tests/api/v1/test_admin_cadastre.py | 20 +---- .../tests/api/v1/test_admin_ekburg_permits.py | 17 ++-- backend/tests/test_admin_weight_profiles.py | 86 ++++++++----------- docs/Secrets_Rotation_Policy.md | 32 ++++--- 7 files changed, 69 insertions(+), 120 deletions(-) delete mode 100644 backend/app/core/deps.py diff --git a/backend/.env.example b/backend/.env.example index d17d9dd7..192a52d7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -27,7 +27,7 @@ SCRAPE_KN_JITTER_SECONDS=1800 SCRAPE_KN_DEFAULT_REGIONS=66 # Путь к Playwright storage_state.json (commited в git, обновляется --save-state). SCRAPE_KN_STATE_PATH=data/playwright_state.json -# DEPRECATED 2026-05-23: app-level admin auth removed (PR #436, Caddy basic_auth достаточен). -# Reinstate: revert changes in admin_*.py чтобы вернуть AdminTokenAuth dep. -# Переменная сохранена в core/deps.py для быстрого rollback. -SCRAPE_ADMIN_TOKEN= +# SCRAPE_ADMIN_TOKEN удалён в #2775. App-level admin-auth сняли ещё в PR #437, +# а поле держали «для быстрого rollback» — за полтора месяца у него не появилось +# ни одного вызывающего. `/api/v1/admin/*` закрыт middleware rbac_guard +# (app/main.py, role != admin → 403) + Caddy basic_auth (PR #426). diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 9a84b522..0b9ce491 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -146,9 +146,6 @@ class Settings(BaseSettings): # Path to a pre-captured Playwright storage_state.json (committed in repo, # used by worker to skip cold-start WAF challenge). scrape_kn_state_path: str = "data/playwright_state.json" - # Token to authorize ad-hoc /api/v1/admin/scrape/* trigger calls. - # Empty string = endpoint disabled. - scrape_admin_token: str = "" # ── #1945 KN-loader anti-ban (throttle + optional proxy) ────────────────── # DOM.РФ WAF банит IP по volume/rate (HTTP 403 «Доступ заблокирован», БЕЗ diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py deleted file mode 100644 index 75fd3b06..00000000 --- a/backend/app/core/deps.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Shared FastAPI dependencies.""" - -from typing import Annotated - -from fastapi import Depends, Header, HTTPException, status - -from app.core.config import settings - - -def verify_admin_token( - x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None, -) -> None: - """Verify admin token header. Raises 503 if not configured, 401 if invalid or missing.""" - if not settings.scrape_admin_token: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="admin disabled — set SCRAPE_ADMIN_TOKEN", - ) - if x_admin_token != settings.scrape_admin_token: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid admin token") - - -AdminTokenAuth = Annotated[None, Depends(verify_admin_token)] diff --git a/backend/tests/api/v1/test_admin_cadastre.py b/backend/tests/api/v1/test_admin_cadastre.py index 189ecba1..9d7fdb27 100644 --- a/backend/tests/api/v1/test_admin_cadastre.py +++ b/backend/tests/api/v1/test_admin_cadastre.py @@ -15,10 +15,6 @@ from fastapi.testclient import TestClient from app.main import app -# Токен для тестов (не реальный) -ADMIN_TOKEN = "test-admin-token" -ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN} - def _make_mock_db(quarters: list[str] | None = None, job_row: dict[str, Any] | None = None): """Создать mock db session с преднастроенными ответами.""" @@ -97,7 +93,6 @@ def _make_sample_job_row() -> dict[str, Any]: # ── Tests ──────────────────────────────────────────────────────────────────── -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_create_job_pilot_returns_job_id() -> None: """POST /cadastre/jobs scope=pilot → job_id + targets_total.""" quarters_50 = [f"66:41:{i:07d}" for i in range(50)] @@ -114,7 +109,6 @@ def test_create_job_pilot_returns_job_id() -> None: response = client.post( "/api/v1/admin/cadastre/jobs", json={"scope": "pilot"}, - headers=ADMIN_HEADERS, ) assert response.status_code == 200, response.text @@ -127,7 +121,6 @@ def test_create_job_pilot_returns_job_id() -> None: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_create_job_manual_list() -> None: """POST /cadastre/jobs scope=manual_list с явным списком.""" db = _make_mock_db() @@ -146,7 +139,6 @@ def test_create_job_manual_list() -> None: "scope": "manual_list", "quarters": ["66:41:0303161", "66:41:0303162"], }, - headers=ADMIN_HEADERS, ) assert response.status_code == 200, response.text @@ -156,7 +148,6 @@ def test_create_job_manual_list() -> None: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_create_job_manual_list_empty_quarters_returns_400() -> None: """scope=manual_list без quarters → 400.""" db = _make_mock_db() @@ -170,14 +161,12 @@ def test_create_job_manual_list_empty_quarters_returns_400() -> None: response = client.post( "/api/v1/admin/cadastre/jobs", json={"scope": "manual_list"}, - headers=ADMIN_HEADERS, ) assert response.status_code == 400 finally: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_list_jobs_returns_list() -> None: """GET /cadastre/jobs → список jobs.""" db = _make_mock_db(job_row=_make_sample_job_row()) @@ -194,7 +183,7 @@ def test_list_jobs_returns_list() -> None: try: client = TestClient(app) - response = client.get("/api/v1/admin/cadastre/jobs", headers=ADMIN_HEADERS) + response = client.get("/api/v1/admin/cadastre/jobs") assert response.status_code == 200, response.text body = response.json() assert isinstance(body, list) @@ -206,7 +195,6 @@ def test_list_jobs_returns_list() -> None: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_get_job_not_found_returns_404() -> None: """GET /cadastre/jobs/9999 → 404.""" db = MagicMock() @@ -220,13 +208,12 @@ def test_get_job_not_found_returns_404() -> None: try: client = TestClient(app) - response = client.get("/api/v1/admin/cadastre/jobs/9999", headers=ADMIN_HEADERS) + response = client.get("/api/v1/admin/cadastre/jobs/9999") assert response.status_code == 404 finally: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_cancel_job_success() -> None: """POST /cadastre/jobs/42/cancel → {cancelled: true}.""" db = MagicMock() @@ -243,7 +230,6 @@ def test_cancel_job_success() -> None: client = TestClient(app) response = client.post( "/api/v1/admin/cadastre/jobs/42/cancel", - headers=ADMIN_HEADERS, ) assert response.status_code == 200, response.text body = response.json() @@ -253,7 +239,6 @@ def test_cancel_job_success() -> None: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_cancel_job_not_found_returns_404() -> None: """POST /cadastre/jobs/9999/cancel когда job не найден → 404.""" db = MagicMock() @@ -270,7 +255,6 @@ def test_cancel_job_not_found_returns_404() -> None: client = TestClient(app) response = client.post( "/api/v1/admin/cadastre/jobs/9999/cancel", - headers=ADMIN_HEADERS, ) assert response.status_code == 404 finally: diff --git a/backend/tests/api/v1/test_admin_ekburg_permits.py b/backend/tests/api/v1/test_admin_ekburg_permits.py index 1c98bac3..45d9bd85 100644 --- a/backend/tests/api/v1/test_admin_ekburg_permits.py +++ b/backend/tests/api/v1/test_admin_ekburg_permits.py @@ -4,7 +4,11 @@ - валидный запрос без year → scope all_years_2022_2026, task_id в ответе - валидный запрос с year=2026 → scope year_2026 - year < 2022 или > 2030 → 422 -- отсутствие X-Admin-Token → 401/503 + +Авторизация здесь НЕ проверяется и никогда не проверялась: строка «отсутствие +X-Admin-Token → 401/503» стояла в этом докстринге, но соответствующего теста в +файле нет. Гейт `/api/v1/admin/*` — middleware `rbac_guard` (app/main.py), +покрыт tests/test_rbac.py. """ from __future__ import annotations @@ -16,8 +20,6 @@ from fastapi.testclient import TestClient from app.main import app -ADMIN_TOKEN = "test-admin-token" -ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN} ENDPOINT = "/api/v1/admin/scrape/ekburg-permits" @@ -27,7 +29,6 @@ def _mock_task(task_id: str = "fake-task-id-123") -> MagicMock: return result -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_trigger_refresh_all_returns_task_id() -> None: """POST без year → refresh_all queued, scope=all_years_2022_2026.""" mock_result = _mock_task("task-all-001") @@ -38,7 +39,7 @@ def test_trigger_refresh_all_returns_task_id() -> None: ): mock_refresh_all.apply_async.return_value = mock_result client = TestClient(app) - response = client.post(ENDPOINT, json={}, headers=ADMIN_HEADERS) + response = client.post(ENDPOINT, json={}) assert response.status_code == 200, response.text body = response.json() @@ -47,7 +48,6 @@ def test_trigger_refresh_all_returns_task_id() -> None: assert "queued_at" in body -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_trigger_refresh_year_returns_task_id() -> None: """POST year=2026 → refresh_year queued, scope=year_2026.""" mock_result = _mock_task("task-year-002") @@ -58,7 +58,7 @@ def test_trigger_refresh_year_returns_task_id() -> None: ): mock_refresh_year.apply_async.return_value = mock_result client = TestClient(app) - response = client.post(ENDPOINT, json={"year": 2026}, headers=ADMIN_HEADERS) + response = client.post(ENDPOINT, json={"year": 2026}) assert response.status_code == 200, response.text body = response.json() @@ -67,9 +67,8 @@ def test_trigger_refresh_year_returns_task_id() -> None: @pytest.mark.parametrize("bad_year", [2021, 2031, 1999, 9999]) -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_trigger_invalid_year_returns_422(bad_year: int) -> None: """year вне диапазона [2022, 2030] → 422 Unprocessable Entity.""" client = TestClient(app) - response = client.post(ENDPOINT, json={"year": bad_year}, headers=ADMIN_HEADERS) + response = client.post(ENDPOINT, json={"year": bad_year}) assert response.status_code == 422, f"year={bad_year} должен возвращать 422" diff --git a/backend/tests/test_admin_weight_profiles.py b/backend/tests/test_admin_weight_profiles.py index 13316e56..5d3bd7fc 100644 --- a/backend/tests/test_admin_weight_profiles.py +++ b/backend/tests/test_admin_weight_profiles.py @@ -6,9 +6,15 @@ - GET /{id} → 200 / 404 - PUT /{id} → 200 / 404 - DELETE /{id} → 204 / 404 -- 401 при отсутствии X-Admin-Token - 422 при невалидных weights (неизвестная категория, вес вне диапазона) +Авторизация здесь НЕ проверяется и никогда не проверялась: строка «401 при +отсутствии X-Admin-Token» стояла в этом докстринге, но соответствующего теста в +файле нет — заголовок просто отправлялся во все запросы и никем не читался +(app-level токен снят в PR #437). Гейт `/api/v1/admin/*` живёт в middleware +`rbac_guard` (app/main.py, `_ADMIN_API_RE` → 403 для role != admin) и покрыт +tests/test_rbac.py. + Mock-based: get_db переопределяется через dependency override. """ @@ -24,9 +30,6 @@ from app.core.db import get_db from app.main import app from app.services.site_finder.weight_profiles import WeightProfile -_ADMIN_TOKEN = "test-admin-token" -_HEADERS = {"X-Admin-Token": _ADMIN_TOKEN} - _NOW = datetime.now(UTC) @@ -50,9 +53,7 @@ def _make_profile( @pytest.fixture() -def client_with_token(monkeypatch: pytest.MonkeyPatch) -> TestClient: - """TestClient с переопределённым SCRAPE_ADMIN_TOKEN.""" - monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN) +def client() -> TestClient: return TestClient(app) @@ -76,7 +77,7 @@ def _clear_overrides(): # ── GET list ─────────────────────────────────────────────────────────────────── -def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_list_empty(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET ?user_id= → 200 + пустой список.""" mock = MagicMock() _override_db(mock) @@ -85,10 +86,9 @@ def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPat "app.api.v1.admin_weight_profiles.list_profiles", lambda db, user_id: [], ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-x"}, - headers=_HEADERS, ) assert r.status_code == 200 assert r.json() == [] @@ -96,9 +96,7 @@ def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPat _clear_overrides() -def test_list_returns_profiles( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_list_returns_profiles(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET ?user_id= → 200 + список профилей.""" profiles = [_make_profile(1, is_default=True), _make_profile(2, profile_name="B")] mock = MagicMock() @@ -108,10 +106,9 @@ def test_list_returns_profiles( "app.api.v1.admin_weight_profiles.list_profiles", lambda db, user_id: profiles, ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 200 body = r.json() @@ -124,7 +121,7 @@ def test_list_returns_profiles( # ── POST create ──────────────────────────────────────────────────────────────── -def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_create_then_get(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """POST создаёт профиль, возвращает его со статусом 201.""" created = _make_profile(42, profile_name="Семейный", weights={"school": 2.0, "park": 1.5}) mock = MagicMock() @@ -134,7 +131,7 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk "app.api.v1.admin_weight_profiles.create_profile", lambda db, payload: created, ) - r = client_with_token.post( + r = client.post( "/api/v1/admin/site-finder/weight-profiles", json={ "user_id": "user-1", @@ -142,7 +139,6 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk "weights": {"school": 2.0, "park": 1.5}, "is_default": False, }, - headers=_HEADERS, ) assert r.status_code == 201 body = r.json() @@ -153,30 +149,28 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk _clear_overrides() -def test_create_validation_unknown_category(client_with_token: TestClient) -> None: +def test_create_validation_unknown_category(client: TestClient) -> None: """POST с неизвестной POI-категорией → 422 (Pydantic validation).""" - r = client_with_token.post( + r = client.post( "/api/v1/admin/site-finder/weight-profiles", json={ "user_id": "user-1", "profile_name": "Bad", "weights": {"supermarket": 1.0}, # не в ALLOWED_CATEGORIES }, - headers=_HEADERS, ) assert r.status_code == 422 -def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) -> None: +def test_create_validation_weight_out_of_bounds(client: TestClient) -> None: """POST с весом вне [-2, 3] → 422.""" - r = client_with_token.post( + r = client.post( "/api/v1/admin/site-finder/weight-profiles", json={ "user_id": "user-1", "profile_name": "Bad", "weights": {"school": 99.0}, }, - headers=_HEADERS, ) assert r.status_code == 422 @@ -184,7 +178,7 @@ def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) - # ── GET one ──────────────────────────────────────────────────────────────────── -def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_profile_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET /{id}?user_id= → 200.""" profile = _make_profile(7) mock = MagicMock() @@ -194,10 +188,9 @@ def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.Mo "app.api.v1.admin_weight_profiles.get_profile", lambda db, user_id, profile_id: profile, ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles/7", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 200 assert r.json()["id"] == 7 @@ -205,9 +198,7 @@ def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.Mo _clear_overrides() -def test_get_profile_not_found( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_get_profile_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET /{id} несуществующего профиля → 404.""" mock = MagicMock() _override_db(mock) @@ -216,10 +207,9 @@ def test_get_profile_not_found( "app.api.v1.admin_weight_profiles.get_profile", lambda db, user_id, profile_id: None, ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles/999", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 404 finally: @@ -229,7 +219,7 @@ def test_get_profile_not_found( # ── PUT update ───────────────────────────────────────────────────────────────── -def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_update_profile(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """PUT /{id} → 200 + обновлённый профиль.""" updated = _make_profile(3, profile_name="Обновлённый") mock = MagicMock() @@ -239,11 +229,10 @@ def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.Monke "app.api.v1.admin_weight_profiles.update_profile", lambda db, user_id, profile_id, payload: updated, ) - r = client_with_token.put( + r = client.put( "/api/v1/admin/site-finder/weight-profiles/3", params={"user_id": "user-1"}, json={"profile_name": "Обновлённый"}, - headers=_HEADERS, ) assert r.status_code == 200 assert r.json()["profile_name"] == "Обновлённый" @@ -251,9 +240,7 @@ def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.Monke _clear_overrides() -def test_update_profile_not_found( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_update_profile_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """PUT /{id} несуществующего → 404.""" mock = MagicMock() _override_db(mock) @@ -262,11 +249,10 @@ def test_update_profile_not_found( "app.api.v1.admin_weight_profiles.update_profile", lambda db, user_id, profile_id, payload: None, ) - r = client_with_token.put( + r = client.put( "/api/v1/admin/site-finder/weight-profiles/999", params={"user_id": "user-1"}, json={"profile_name": "X"}, - headers=_HEADERS, ) assert r.status_code == 404 finally: @@ -276,7 +262,7 @@ def test_update_profile_not_found( # ── DELETE ───────────────────────────────────────────────────────────────────── -def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_delete_success(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """DELETE /{id} → 204.""" mock = MagicMock() _override_db(mock) @@ -285,17 +271,16 @@ def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.Monke "app.api.v1.admin_weight_profiles.delete_profile", lambda db, user_id, profile_id: True, ) - r = client_with_token.delete( + r = client.delete( "/api/v1/admin/site-finder/weight-profiles/5", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 204 finally: _clear_overrides() -def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_delete_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """DELETE /{id} несуществующего → 404.""" mock = MagicMock() _override_db(mock) @@ -304,10 +289,9 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon "app.api.v1.admin_weight_profiles.delete_profile", lambda db, user_id, profile_id: False, ) - r = client_with_token.delete( + r = client.delete( "/api/v1/admin/site-finder/weight-profiles/999", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 404 finally: @@ -318,7 +302,7 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon def test_list_include_system_calls_with_system( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch + client: TestClient, monkeypatch: pytest.MonkeyPatch ) -> None: """GET ?include_system=true вызывает list_profiles_with_system, возвращает presets.""" system_profile = _make_profile( @@ -332,10 +316,9 @@ def test_list_include_system_calls_with_system( "app.api.v1.admin_weight_profiles.list_profiles_with_system", lambda db, user_id: [user_profile, system_profile], ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-1", "include_system": "true"}, - headers=_HEADERS, ) assert r.status_code == 200 body = r.json() @@ -348,7 +331,7 @@ def test_list_include_system_calls_with_system( def test_list_without_include_system_does_not_call_with_system( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch + client: TestClient, monkeypatch: pytest.MonkeyPatch ) -> None: """GET без include_system → list_profiles (только пользовательские профили).""" user_profile = _make_profile(1, user_id="user-1") @@ -364,10 +347,9 @@ def test_list_without_include_system_does_not_call_with_system( "app.api.v1.admin_weight_profiles.list_profiles_with_system", lambda db, user_id: called_with_system.append(True) or [], ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 200 assert len(r.json()) == 1 diff --git a/docs/Secrets_Rotation_Policy.md b/docs/Secrets_Rotation_Policy.md index 61180727..96dcfe6c 100644 --- a/docs/Secrets_Rotation_Policy.md +++ b/docs/Secrets_Rotation_Policy.md @@ -69,7 +69,7 @@ | `GLITCHTIP_SECRET` | `.env` | Django `SECRET_KEY` GlitchTip | **F** (app secret) | | `OBJECTIVE_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета на VPS | **D** | | `OPENAI_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета (только если non-empty) | **D** | -| `SCRAPE_ADMIN_TOKEN` | `backend/.env` | **DEPRECATED** (PR #436): app-level admin auth удалён, заменён Caddy basic_auth. Поле оставлено в `core/deps.py` для быстрого rollback | **F** (legacy, см. §3) | +| ~~`SCRAPE_ADMIN_TOKEN`~~ | — | **УДАЛЁН** (#2775): не секрет и не credential. См. §3 | — | ### 1.3 Прод runtime — tradein стек (`/opt/gendesign/tradein-mvp/backend/.env.runtime`) @@ -210,18 +210,28 @@ Bootstrap-роли (`tradein_fdw_reader`, `gendesign_reader`): --- -## 3. Особый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance) +## 3. Закрытый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance) -Issue #78 просит «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime». -**Статус токена: DEPRECATED** — app-level admin-auth был удалён в PR #436 -(`backend/.env.example:30`), доступ к админ-эндпоинтам теперь закрыт Caddy basic_auth. -Поле оставлено в `core/deps.py` только для быстрого rollback. +Issue #78 просил «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime». +**Ротировать нечего: переменной больше нет.** -**Вывод:** активной ротации не требуется — токен ни на что не влияет, пока -`AdminTokenAuth` dep не реинстейтнут. Если/когда его вернут, он попадает в класс **F** -(процедура: `sed` в `backend/.env.runtime` → `up -d --force-recreate --no-deps backend beat`, -downtime отсутствует). Фактический прод-прогон ротации — операционное действие -(Anton), не выполняется в рамках этого PR. +История. App-level admin-auth сняли в PR #437 (заголовок `X-Admin-Token` убран +со всех эндпоинтов), UI ввода токена — в PR #442. Поле `scrape_admin_token` и +dep `AdminTokenAuth` оставили «для быстрого rollback» — и они пролежали так до +#2775, полтора месяца, не имея ни одного вызывающего: разбор AST по всему +репозиторию нашёл `verify_admin_token` и `AdminTokenAuth` ровно в одном месте — +в объявлении в `core/deps.py`. Rollback, ради которого поле держали, всё равно +означал бы правку всех `admin_*.py` (dep-то нигде не проставлен), то есть +хранение поля не экономило ничего. Удалено вместе с `core/deps.py`, +`SCRAPE_ADMIN_TOKEN` из `.env.example` и остатками в тестах. + +Что закрывает `/api/v1/admin/*` сейчас — **два слоя, оба живые**: +1. `rbac_guard` в `backend/app/main.py`: `_ADMIN_API_RE` → 403 `admin only` + для роли ≠ admin. Покрыт `backend/tests/test_rbac.py` (24 теста). +2. Caddy basic_auth на весь `gendsgn.ru` (PR #426). + +Строку `SCRAPE_ADMIN_TOKEN=` в прод-`.env` удалять не обязательно: +`Settings.model_config` — `extra="ignore"`, лишняя переменная безвредна. `JWT_SECRET` (упомянут в #78 «после B3-4») в кодовой базе **ещё отсутствует** — добавить в реестр (класс **F**) при внедрении JWT-аутентификации. From 90c3e7e4908ad91a76c7e541b8ce9922dc78667f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 7 Aug 2026 09:48:52 +0000 Subject: [PATCH 05/23] =?UTF-8?q?test(scrapers/krt):=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D1=83=D1=82=D1=8C=20=D0=B2=20=D0=BF=D1=80=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=D0=BD=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D1=83?= =?UTF-8?q?=20=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=D0=B1=D0=BB=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B?= =?UTF-8?q?=20(#2778)=20(#2781)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scrapers/test_ekb_krt_registry.py | 90 ++++++++++++++----- backend/tests/skip_allowlist.txt | 24 +++-- 2 files changed, 82 insertions(+), 32 deletions(-) diff --git a/backend/tests/services/scrapers/test_ekb_krt_registry.py b/backend/tests/services/scrapers/test_ekb_krt_registry.py index 2463fc8b..9972e217 100644 --- a/backend/tests/services/scrapers/test_ekb_krt_registry.py +++ b/backend/tests/services/scrapers/test_ekb_krt_registry.py @@ -197,32 +197,74 @@ class TestParseBabushkinaBlock: assert site.krt_kind == "жилой застройки" -class TestMultiSiteCountFromDump: - """Проверка количества площадок из реального дампа страницы.""" +# ── Многоблочная страница: два года, три площадки, повторяющийся номер ──────── +# +# Здесь до #2778 стоял TestMultiSiteCountFromDump, читавший +# `.playwright-mcp/ekb-docs/krt_page_raw.html`. Дампа нет ни в репозитории, ни у +# кого-либо на диске, ни на проде: `ekb_krt_sites.raw_block` хранит не HTML, а +# уже очищенный текст (`block_text[:4000]` в ekb_krt_registry.py:338), поэтому +# восстановить вход парсера из БД нельзя. Оба теста не исполнялись НИГДЕ. +# +# Свойство, ради которого они писались, от дампа не зависит и выражается +# страницей из блоков, которые уже лежат в этом файле: +# • несколько

-секций с годами разбираются в одном проходе; +# • нумерация площадок на реальной странице НАЧИНАЕТСЯ ЗАНОВО В КАЖДОМ ГОДУ, +# поэтому «2.» встречается дважды — и site_name всё равно обязан быть +# уникальным (номер сам по себе ключом не является); +# • блоки лежат внутри контейнера
— это ветка `_extract_krt_section` +# через `find_parent`, которой одиночные блоки выше не касаются вовсе. +# +# ЧЕГО ЭТА ЗАМЕНА НЕ ПРОВЕРЯЕТ (осознанная потеря, зафиксирована в #2778): +# «на живой странице ЕКБ ≥14 площадок». Это утверждение о полноте разбора +# реального документа, синтетической страницей оно не заменяется. На проде +# ekb_krt_sites = 15 строк, то есть по факту сейчас всё разбирается, но +# автоматической проверки этого больше нет — она вернётся, когда в репозитории +# появится образец страницы. +_MULTISITE_PAGE = ( + "
" + + _BRUSNIKA_BLOCK + + _BABUSHKINA_BLOCK + + textwrap.dedent( + """\ +

 2. КРТ нежилой застройки в районе ул. Краснофлотцев:

+

Площадь территории: 3,65 га;
\ +Градостроительный потенциал: 117 640 кв. м;
\ +Всего многоквартирных домов: 13.

+ """ + ) + + "
" +) + + +class TestMultiSitePage: + """Разбор страницы из нескольких блоков — без локального дампа.""" @pytest.fixture(scope="class") - def dump_html(self): - """Загружает локальный дамп страницы. + def sites(self): + return parse_krt_page(_MULTISITE_PAGE) - Тест пропускается если файл отсутствует (CI без дампа). - """ - import os + def test_all_three_sites_parsed(self, sites): + """Три блока в двух годовых секциях → три площадки.""" + assert len(sites) == 3, f"Ожидалось 3 площадки, получено {len(sites)}: {sites}" - dump_path = os.path.join( - os.path.dirname(__file__), - "../../../../.playwright-mcp/ekb-docs/krt_page_raw.html", - ) - if not os.path.exists(dump_path): - pytest.skip("krt_page_raw.html dump not found — skip real-dump test") - with open(dump_path, encoding="utf-8") as fh: - return fh.read() - - def test_site_count_from_dump(self, dump_html): - """Из дампа должно распарситься ≥ 14 площадок (реальных на странице 15).""" - sites = parse_krt_page(dump_html) - assert len(sites) >= 14, f"Expected ≥ 14 sites, got {len(sites)}" - - def test_no_duplicate_site_names(self, dump_html): - sites = parse_krt_page(dump_html) + def test_no_duplicate_site_names(self, sites): + """Номер «2.» встречается дважды (2022 и 2024) — имена всё равно уникальны.""" names = [s.site_name for s in sites] - assert len(names) == len(set(names)), "Дубликаты site_name в результате" + assert sum(n.startswith("2.") for n in names) == 2, names + assert len(names) == len(set(names)), f"Дубликаты site_name: {names}" + + def test_blocks_do_not_leak_into_each_other(self, sites): + """Границы блоков соблюдены: договор и застройщик остались у своей площадки. + + Регресс, который этим ловится: если block_end съезжает, «Брусника» и + договор 1-2/21.7-14 из первого блока прилипают ко второму и третьему. + """ + with_developer = [s for s in sites if s.developer_name] + assert len(with_developer) == 1, [s.developer_name for s in sites] + assert "Брусника" in with_developer[0].developer_name + assert [s.contract_number for s in sites].count("1-2/21.7-14") == 1 + + def test_kinds_are_read_per_block(self, sites): + """Третий блок — «нежилой застройки», не должен унаследовать вид соседа.""" + kinds = sorted(s.krt_kind for s in sites if s.krt_kind) + assert kinds == ["жилой застройки", "жилой застройки", "нежилой застройки"], kinds diff --git a/backend/tests/skip_allowlist.txt b/backend/tests/skip_allowlist.txt index 6fda8d13..02ca21bc 100644 --- a/backend/tests/skip_allowlist.txt +++ b/backend/tests/skip_allowlist.txt @@ -73,14 +73,22 @@ tests/scrapers/test_nspd_bulk_client.py::test_search_by_quarter_real_ekb tests/scrapers/test_nspd_bulk_client.py::test_wms_feature_info_real tests/scrapers/test_nspd_grid_walk.py::test_live_nspd_grid_walk_skipped -# ── Образцы, которых нет в git ──────────────────────────────────────────────── -# ДОЛГ, а не норма: парсеры проверяются на реальных дампах, которые лежат только -# в untracked `.playwright-mcp/`. Пока файлы не в репозитории, эти 8 проверок не -# исполняются нигде. Правильная починка — закоммитить образцы в tests/fixtures/ -# (как сделано в tradein-mvp/backend/tests/fixtures/*.html), после чего строки -# отсюда убрать. -tests/services/scrapers/test_ekb_krt_registry.py::TestMultiSiteCountFromDump::test_no_duplicate_site_names -tests/services/scrapers/test_ekb_krt_registry.py::TestMultiSiteCountFromDump::test_site_count_from_dump +# ── Образец, которого нет в git ─────────────────────────────────────────────── +# ДОЛГ, а не норма. Было 8 записей; 2 из них (test_ekb_krt_registry.py:: +# TestMultiSiteCountFromDump) сняты в #2778: дампа `krt_page_raw.html` не +# оказалось НИГДЕ — ни в репозитории, ни на дисках, ни на проде (в БД лежит +# `raw_block` = уже очищенный текст, вход парсера из него не восстановить), — +# зато свойство, которое они проверяли (несколько блоков на странице, уникальные +# site_name), выразимо без дампа и теперь проверяется на многоблочной странице, +# собранной из блоков, уже лежащих в том же тест-файле. +# +# Оставшиеся 6 так не лечатся: они проверяют извлечение таблиц из НАСТОЯЩЕГО PDF +# через pdfplumber, соседний класс TestParsePptTep уже мокает pdfplumber целиком. +# Нужен файл `ppt2018_22823_poyasnit_PPT.pdf` (или рабочий URL на ГИСОГД ЕКБ) — +# запрошен у Anton в #2778, там же список того, что оживёт. Причин не коммитить +# нет: официальный градостроительный документ, без персональных данных. +# После получения — положить в tests/fixtures/, поправить `_REAL_PDF_PATH`, +# убрать эти шесть строк. tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_area tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_rows tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_raw_tables_not_empty From 9de690ad4fbdd8f492c7bc3d2dbb54939b43a8d5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 7 Aug 2026 10:00:48 +0000 Subject: [PATCH 06/23] =?UTF-8?q?chore(tradein/frontend):=20=D1=83=D0=B4?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D1=82=D1=8C=20=D0=BE=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20=D1=81=D0=BB=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D1=82=D1=83=D1=80=20(#2755)=20(#2785)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis/[cad]/ptica/ptica.module.css | 29 +- tradein-mvp/frontend/src/app/v2/page.tsx | 2 +- .../src/components/trade-in/v2/CacheView.tsx | 2 +- .../src/components/trade-in/v2/Footer.tsx | 4 +- .../components/trade-in/v2/ParamsPanel.tsx | 6 +- .../components/trade-in/v2/SectionOverlay.tsx | 4 +- .../components/trade-in/v2/SourcesView.tsx | 30 +- .../src/components/trade-in/v2/TopNav.tsx | 4 +- .../v2/__tests__/SourcesView.test.tsx | 94 +++ .../src/components/trade-in/v2/fixtures.ts | 671 ------------------ .../src/components/trade-in/v2/mappers.ts | 53 +- .../src/components/trade-in/v2/types.ts | 19 +- .../src/components/trade-in/v2/ui-config.ts | 62 ++ tradein-mvp/frontend/src/lib/city-registry.ts | 2 +- 14 files changed, 221 insertions(+), 761 deletions(-) create mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/__tests__/SourcesView.test.tsx delete mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts create mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/ui-config.ts diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css index e4c70c5f..31117249 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css @@ -10,8 +10,8 @@ /* ===================== SCOPED TOKENS (dark) ===================== */ .pticaRoot[data-theme="dark"] { --font-ui: - var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI", - system-ui, sans-serif; + var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI", system-ui, + sans-serif; --font-mono: var(--font-plex-mono), "IBM Plex Mono", "Roboto Mono", ui-monospace, monospace; @@ -907,31 +907,6 @@ letter-spacing: 0.08em; } -/* ===================== PLACEHOLDER PANEL ===================== */ -.placeholderPanel { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 10px; - text-align: center; - min-height: 240px; - border-style: dashed; -} -.placeholderPanel .soon { - font-size: 22px; - letter-spacing: 0.12em; - color: var(--text-soft); - font-weight: 700; - text-transform: uppercase; -} -.placeholderPanel p { - font-size: 10px; - color: var(--text-soft); - margin: 0; - max-width: 280px; -} - /* ===================== STATE SCREENS ===================== */ .stateScreen { display: grid; diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 1476a7f7..e2122ac5 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -652,7 +652,7 @@ export default function TradeInV2Page() { // TopNav «Мои отчёты» badge: per-user estimate count. quota.used is the // authoritative monthly counter; fall back to the loaded history length, then - // to the fixture default (undefined) for unwired/pre-load states. + // to undefined (TopNav renders 0) for pre-load states. const reportsCount = quota.data?.used ?? history.data?.length; // Real user for the TopNav menu. undefined until /me resolves → TopNav shows diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx index 1c699b66..106e7d7a 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx @@ -160,7 +160,7 @@ export function CacheView({ data, onSelectRow }: CacheViewProps) { // #2420 — clicking/activating a row navigates to the full report // for that historical estimate (SectionOverlay -> page.tsx). Rows // stay a passive role="row"/role="cell" ARIA-grid когда onSelectRow - // не задан (unwired/storybook usage), меняя только tabIndex/handlers + // не задан (SectionOverlay без обработчика), меняя только tabIndex/handlers // — the grid structure itself is unchanged. const clickable = Boolean(onSelectRow); function activate() { diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx index 01ab9d22..9dab743a 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx @@ -4,7 +4,7 @@ // МЕРА v2.0.6 wordmark on the right. Static markup, id/date/validUntil via `data`. import { tokens } from "./tokens"; -import { version } from "./fixtures"; +import { version } from "./ui-config"; import type { Report } from "./types"; interface FooterProps { @@ -13,7 +13,7 @@ interface FooterProps { // error, not a silent fallback to a fabricated report id/date. data: Report; // M4: hide the «ДЕЙСТВИТЕЛЕН ДО» validity line when there is no real estimate - // (mirrors HeroBar). Optional → unwired/fixture usage keeps showing it. + // (mirrors HeroBar). Optional: omitted → the line stays visible. hasEstimate?: boolean; } diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx index 91b8dc93..9d6b75a0 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx @@ -27,7 +27,7 @@ import { type SetStateAction, } from "react"; import { tokens } from "./tokens"; -import { dropdownOptions } from "./fixtures"; +import { dropdownOptions } from "./ui-config"; import { HOUSE_TYPE_FROM_RU, HOUSE_TYPE_RU, @@ -541,8 +541,8 @@ const radiusPanel: CSSProperties = { }; interface ParamsPanelProps { - /** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional so - * the still-unwired v2 page renders without props (tsc-safe). */ + /** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional + * dates back to the pre-wiring page; v2/page.tsx always supplies it. */ onSubmit?: (input: TradeInEstimateInput) => void; /** Disables the button + shows a pending label while the estimate is running. */ isPending?: boolean; diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx index b4ecdc72..c00225ab 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx @@ -9,7 +9,7 @@ import { useEffect, useRef } from "react"; import { tokens } from "./tokens"; -import { overlayNums, overlayTitles } from "./fixtures"; +import { overlayNums, overlayTitles } from "./ui-config"; import HistoryView from "./HistoryView"; import SourcesView from "./SourcesView"; import AnalyticsView from "./AnalyticsView"; @@ -37,7 +37,7 @@ interface SectionOverlayProps { estimate?: AggregatedEstimate | null; // #2420 — «ПРЕДЫДУЩИЕ ОЦЕНКИ» row click: navigate to the full report for // that historical estimate. Optional (threaded straight to CacheView's own - // optional onSelectRow — unwired usage leaves rows inert). + // optional onSelectRow — without a handler rows stay inert). onSelectEstimate?: (id: string) => void; } diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx index fb9ceffc..8ee85362 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx @@ -139,8 +139,9 @@ const monoMuted2: CSSProperties = { color: tokens.muted2, }; -// Shared "open original ↗" cell style (anchor + fixture-fallback span render -// identically; textDecoration:none keeps the anchor visually a button). +// "Открыть оригинал ↗" cell style; textDecoration:none keeps the anchor +// visually a button. Only a real ever gets it — a row without a link +// renders a plain dash, not a button-shaped span you cannot click. const linkBtn: CSSProperties = { fontSize: "9.5px", color: tokens.accent, @@ -210,7 +211,7 @@ const AD_COLS: Col[] = [ head: ИСТОЧНИК, cell: (r) => ( - {r.source ?? "Avito"} + {r.source} ), empty: () => false, @@ -263,7 +264,7 @@ const AD_COLS: Col[] = [ head: ДИСТ., cell: (r) => ( - {r.dist ?? "—"} + {r.dist} ), empty: (r) => isDash(r.dist), @@ -276,11 +277,10 @@ const AD_COLS: Col[] = [ // role="cell" sits on a display:contents wrapper so the stays the grid // item (identical layout) AND keeps its link role inside the cell. cell: (r) => { - // safeUrl gates href against javascript:/data: schemes (XSS). url is - // undefined for the design fixtures (→ "Росреестр ↗" fallback, keeps the - // storybook markup pixel-identical), null for a real row with no link - // (→ "—"), and a string when a real listing URL is available. - const href = typeof r.url === "string" ? safeUrl(r.url) : null; + // safeUrl gates href against javascript:/data: schemes (XSS). url is a + // string when the listing has a link and null when it has none (→ "—"). + // There is no third case: mapSources always sets the field. + const href = r.url === null ? null : safeUrl(r.url); return ( {href ? ( @@ -293,12 +293,8 @@ const AD_COLS: Col[] = [ > Источник ↗ - ) : r.url === null ? ( - ) : ( - - Росреестр ↗ - + )} ); @@ -336,9 +332,9 @@ const DEAL_COLS: Col[] = [ head: ИСТОЧНИК, cell: (r) => ( - {r.source ?? "Росреестр"}{" "} + {r.source}{" "} - {r.tier ?? "по улице"} + {r.tier} ), @@ -372,7 +368,7 @@ const DEAL_COLS: Col[] = [ head: ДАТА, cell: (r) => ( - {r.date ?? "—"} + {r.date} ), empty: (r) => isDash(r.date), diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx index 241af7d8..9bf5a19d 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx @@ -4,7 +4,7 @@ // Faithful markup port of the design header (МЕРА Оценка.dc.html, lines 42-90): // inline SVG logo + version + 5 nav tabs (active underline/triangle) + user menu. // Tabs change only local UI state via onNavigate; the user dropdown owns its -// own useState. No data fetching — labels/version come from fixtures, the user +// own useState. No data fetching — labels/version come from ./ui-config, the user // identity is fed in from the page (real useMe), colours from tokens. import { useState } from "react"; @@ -13,7 +13,7 @@ import type { CSSProperties } from "react"; import { API_BASE_URL } from "@/lib/api"; import { tokens } from "./tokens"; -import { navLabels, version } from "./fixtures"; +import { navLabels, version } from "./ui-config"; import { useSupportChat } from "./SupportChatContext"; // Real logged-in user identity, derived by the page from useMe() diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/SourcesView.test.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/SourcesView.test.tsx new file mode 100644 index 00000000..3ab3a05d --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/SourcesView.test.tsx @@ -0,0 +1,94 @@ +// #2755 — колонка ссылки в таблице аналогов «05 РЫНОК». +// +// Здесь до этого жила третья ветка: когда `url` приходил `undefined`, рисовался +// НЕкликабельный , стилизованный под кнопку-ссылку «Росреестр ↗». Ветка +// была недостижима (mapSources всегда ставит `string | null`), но выглядела как +// рабочий элемент интерфейса. Ветку убрали, поле сделали обязательным — этот +// тест держит обе половины: ссылка есть только там, где есть URL, а строка без +// URL получает прочерк, а не кнопку-обманку. + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import SourcesView from "../SourcesView"; +import { mapSources } from "../mappers"; +import { FIXTURE_ESTIMATE } from "@/app/ui-preview/estimate/fixture"; +import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in"; + +function lot(over: Partial): AnalogLot { + return { + address: "ул. Репина, 73", + area_m2: 54, + rooms: 2, + floor: 7, + total_floors: 16, + price_rub: 9_700_000, + price_per_m2: 179_629, + listing_date: "2026-05-12", + days_on_market: 18, + photo_url: null, + source: "avito", + source_url: null, + distance_m: 120, + tier: null, + lat: 56.8401, + lon: 60.5702, + ...over, + }; +} + +// Оффлайн-фикстура оценки репозитория с подменёнными аналогами: остальные поля +// (sources_used, коридоры, KPI) настоящие, меняем ровно то, что проверяем. +function estimateWith(analogs: AnalogLot[]): AggregatedEstimate { + return { + ...FIXTURE_ESTIMATE, + analogs, + n_analogs: analogs.length, + actual_deals: [], + }; +} + +describe("SourcesView — колонка ссылки на оригинал", () => { + it("есть URL — кликабельная ссылка на него", () => { + const estimate = estimateWith([ + lot({ + address: "ул. Репина, 73", + source_url: "https://www.avito.ru/ekaterinburg/kvartiry/1", + }), + ]); + + render(); + + const link = screen.getByRole("link", { name: /Источник/ }); + expect(link).toHaveAttribute( + "href", + "https://www.avito.ru/ekaterinburg/kvartiry/1", + ); + }); + + it("URL нет — прочерк, а не кнопка-обманка «Росреестр ↗»", () => { + const estimate = estimateWith([ + lot({ address: "ул. Викулова, 33", source_url: null }), + ]); + + render(); + + expect(screen.queryByText(/Росреестр ↗/)).toBeNull(); + expect(screen.queryByRole("link", { name: /Источник/ })).toBeNull(); + // Прочерк — единственное содержимое ячейки ссылки в строке аналога. + const row = screen.getByText("ул. Викулова, 33").closest("div"); + expect(row?.textContent).toContain("—"); + }); + + it("javascript:-схема отбрасывается safeUrl — прочерк, а не ссылка", () => { + const estimate = estimateWith([ + lot({ address: "ул. Кирова, 28", source_url: "javascript:alert(1)" }), + ]); + + render(); + + expect(screen.queryByRole("link", { name: /Источник/ })).toBeNull(); + const row = screen.getByText("ул. Кирова, 28").closest("div"); + expect(row?.textContent).toContain("—"); + }); +}); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts b/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts deleted file mode 100644 index 7c66e2ba..00000000 --- a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts +++ /dev/null @@ -1,671 +0,0 @@ -// Sample/markup data for the /trade-in/v2 "МЕРА Оценка" design port. -// Extracted verbatim from the pixel design (МЕРА Оценка.dc.html). This is the -// data source for the markup — components must NOT fetch; they read from here. -// Colour fields reference tokens so the palette stays the single source of truth. - -import { tokens } from "./tokens"; -import type { - Analytics, - CacheKpi, - CacheRow, - DkpRow, - AdRow, - DealRow, - DropdownOptions, - History, - MarketAds, - MarketDeals, - ObjectInfo, - Ranges, - Report, - ResultCard, - ResultMeta, - ScatterMini, - SourceCard, - Summary, - User, -} from "./types"; - -export const report: Report = { - id: "9b9e2737", - date: "20.06.2026", - validUntil: "21.06.2026", -}; - -export const object: ObjectInfo = { - address: "ул. Малышева, 30", - city: "Екатеринбург, Россия", - area: "54", - rooms: "2", - floor: "5", - totalFloors: "17", - year: "1985", - houseType: "Панельный", - repair: "Хороший", - balcony: true, - locationIndexLabel: "+8%", - locationIndexOk: true, - // Approximate центр Екатеринбурга near ул. Малышева, 30 — illustrative - // fixture coordinate for the HeroBar locator mini-map (unwired usage only). - lat: 56.8384, - lon: 60.6057, -}; - -// ---- 02 RESULT ------------------------------------------------------------ - -export const resultCards: ResultCard[] = [ - { - title: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"], - value: "11,29", - unit: "млн ₽", - range: "7,72 — 14,46 млн ₽", - ppm: "225 801 ₽/м² · по объявлениям", - bars: [30, 55, 80, 100, 70, 45, 25, 35], - nav: 2, - }, - { - title: ["ОЖИДАЕМАЯ СДЕЛКА", "(ПОЛУЧИТЕ)"], - value: "10,00", - unit: "млн ₽", - range: "6,83 — 12,81 млн ₽", - ppm: "199 973 ₽/м²", - delta: "−11%", - deltaLabel: "К ОБЪЯВЛ.", - nav: 2, - }, - { - title: ["ДКП · РОСРЕЕСТР", "(ФАКТИЧЕСКИЕ СДЕЛКИ)"], - value: "9,43", - unit: "млн ₽", - range: "6,10 — 12,72 млн ₽", - ppm: "188 672 ₽/м² · 44 сделки", - bars: [25, 45, 70, 100, 85, 55, 30, 20], - nav: 1, - }, -]; - -export const resultMeta: ResultMeta = { - sources: "3 / 7", - confidence: "СРЕДНЯЯ", - cv: "59.7%", -}; - -export const ranges: Ranges = { - ads: { - label: ["ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", "(БЕЗ УЧЁТА РЕМОНТА)"], - median: "11,29 млн ₽", - lo: "7,72 млн", - hi: "14,46 млн", - marker: { fillLeft: "8%", fillRight: "18%", dotLeft: "58%" }, - }, - deals: { - label: ["ДИАПАЗОН ЦЕН ПО", "ФАКТИЧЕСКИМ СДЕЛКАМ"], - median: "6,80 млн ₽", - lo: "4,30 млн", - hi: "19,70 млн", - marker: { fillLeft: "4%", fillRight: "55%", dotLeft: "33%" }, - }, -}; - -// Mini "ЦЕНА × СРОК ПРОДАЖИ" scatter inside the ranges card (viewBox 168×140). -export const scatterMini: ScatterMini = { - deals: [ - { x: 52, y: 96, r: 2.4 }, - { x: 64, y: 88, r: 2.4 }, - { x: 58, y: 101, r: 2.4 }, - { x: 74, y: 85, r: 2.4 }, - { x: 47, y: 103, r: 2.4 }, - { x: 88, y: 79, r: 2.4 }, - { x: 70, y: 93, r: 2.4 }, - { x: 55, y: 90, r: 2.4 }, - { x: 82, y: 99, r: 2.4 }, - { x: 44, y: 97, r: 2.4 }, - ], - analogs: [ - { x: 118, y: 48, r: 3 }, - { x: 100, y: 60, r: 3 }, - { x: 132, y: 40, r: 3 }, - { x: 92, y: 66, r: 3 }, - { x: 112, y: 54, r: 3 }, - { x: 142, y: 34, r: 3 }, - ], - subject: { x: 96, y: 70, r: 3.6 }, - yTicks: [ - { label: "15М", y: 13 }, - { label: "10М", y: 77 }, - { label: "4М", y: 112 }, - ], - xTicks: [ - { label: "0", x: 34 }, - { label: "90", x: 96 }, - { label: "180", x: 158 }, - ], -}; - -export const sources: SourceCard[] = [ - { name: "ЦИАН", count: "7", label: "лотов", active: true }, - { name: "Я.НЕДВИЖИМОСТЬ", count: "1", label: "лот", active: true }, - { name: "РОСРЕЕСТР (ВНУТР.)", count: "10", label: "лотов", active: true }, - { name: "АВИТО ОЦЕНКА", count: "—", label: "нет данных", active: false }, - { name: "ДОМКЛИК ПРАЙС", count: "—", label: "нет данных", active: false }, - { name: "RESTATE", count: "—", label: "нет данных", active: false }, -]; - -// ---- 03 SUMMARY ----------------------------------------------------------- - -export const summary: Summary = { - rows: [ - { label: "Продажи в доме", value: "2 · 32 дн", dot: "accent", nav: 1 }, - { label: "Аналоги", value: "23 · 6,13 млн", dot: "accent", nav: 2 }, - { label: "Сделки", value: "10 · 5,95 млн", dot: "muted", nav: 2 }, - { label: "Аналитика", value: "67 дн · −3%", dot: "accent", nav: 3 }, - ], - quality: { sources: "3 / 7", confidence: "средняя", cv: "59.7%" }, -}; - -// ---- OVERLAY 04: ПРОДАЖИ В ДОМЕ ------------------------------------------- - -export const dkpRows: DkpRow[] = [ - { - area: "33.9 м²", - price: "5 950 000 ₽", - ppm: "175 516", - ask: "5 272 800 ₽ за 3 дн до", - delta: "+12.8%", - deltaColor: tokens.danger, - }, - { - area: "35.4 м²", - price: "4 900 000 ₽", - ppm: "138 418", - ask: "5 186 700 ₽ за 3 дн до", - delta: "−5.5%", - deltaColor: tokens.success, - }, - { - area: "36.2 м²", - price: "4 200 000 ₽", - ppm: "116 022", - ask: "5 186 700 ₽ за 3 дн до", - delta: "−19.0%", - deltaColor: tokens.success, - }, - { - area: "37.0 м²", - price: "7 503 227 ₽", - ppm: "202 790", - ask: "5 272 800 ₽ за 3 дн до", - delta: "+42.3%", - deltaColor: tokens.danger, - }, - { - area: "36.8 м²", - price: "7 140 243 ₽", - ppm: "194 028", - ask: "5 272 800 ₽ за 3 дн до", - delta: "+35.4%", - deltaColor: tokens.danger, - }, - { - area: "42.1 м²", - price: "6 535 050 ₽", - ppm: "155 227", - ask: "5 272 800 ₽ за 3 дн до", - delta: "+23.9%", - deltaColor: tokens.danger, - }, - { - area: "42.0 м²", - price: "6 650 000 ₽", - ppm: "158 333", - ask: "5 781 600 ₽ за 3 дн до", - delta: "+15.0%", - deltaColor: tokens.danger, - }, - { - area: "32.5 м²", - price: "5 362 650 ₽", - ppm: "165 005", - ask: "5 325 600 ₽ за 3 дн до", - delta: "+0.7%", - deltaColor: tokens.danger, - }, - { - area: "38.8 м²", - price: "4 380 000 ₽", - ppm: "112 887", - ask: "5 325 600 ₽ за 3 дн до", - delta: "−17.8%", - deltaColor: tokens.success, - }, - { - area: "34.9 м²", - price: "5 000 000 ₽", - ppm: "143 266", - ask: "5 254 500 ₽ за 3 дн до", - delta: "−4.8%", - deltaColor: tokens.success, - }, -]; - -export const history: History = { - houseSales: [ - { - lot: "2-к. квартира, 50 м², 6/16 эт.", - priceFrom: "6,25", - priceTo: "5,98 млн ₽", - date: "11.07.2025", - exposure: "32 дн.", - }, - { - lot: "2-к. квартира, 60 м², 6/16 эт.", - priceFrom: "7,29", - priceTo: "7,09 млн ₽", - date: "11.07.2025", - exposure: "31 дн.", - }, - ], - dkpHeader: { - title: "ДКП-сделки на улице Академика Ландау", - period: "24 мес", - note: "78% сделок имеют историческую цену в объявлении · медианный торг −36.0%. Данные по улице, не по дому.", - }, - dkpKpi: { count: "95", median: "140 066", range: "3,1 – 7,5" }, -}; - -// ---- OVERLAY 05: АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ ----------------------------------- - -export const adRows: AdRow[] = [ - { - addr: "ул. Академика Ландау, 39", - meta: "32.5 м² · 1-к · этаж 12/15", - ppm: "170 769", - price: "5 550 000", - }, - { - addr: "ул. Академика Ландау, 35А/2", - meta: "33.5 м² · 1-к · этаж 13/25", - ppm: "174 626", - price: "5 850 000", - }, - { - addr: "ул. Академика Ландау, 38", - meta: "32.7 м² · 1-к · этаж 10/16", - ppm: "171 253", - price: "5 600 000", - }, - { - addr: "ул. Академика Ландау, 35", - meta: "32.7 м² · 1-к · этаж 6/12", - ppm: "178 999", - price: "5 853 300", - }, - { - addr: "ул. Академика Ландау, 35", - meta: "37.4 м² · 1-к · этаж 8/12", - ppm: "149 732", - price: "5 600 000", - }, - { - addr: "ул. Академика Парина, 29", - meta: "40.5 м² · 1-к · этаж 22/23", - ppm: "160 493", - price: "6 500 000", - }, - { - addr: "ул. Академика Ландау, 39", - meta: "32.5 м² · 1-к · этаж 15/15", - ppm: "146 153", - price: "4 750 000", - }, - { - addr: "ул. Академика Ландау, 39", - meta: "34.9 м² · 1-к · этаж 9/15", - ppm: "143 266", - price: "5 000 000", - }, - { - addr: "ул. Академика Парина, 33", - meta: "37.1 м² · 1-к · этаж 6/14", - ppm: "160 916", - price: "5 970 000", - }, - { - addr: "ул. Академика Ландау, 34", - meta: "41.7 м² · 1-к · этаж 22/23", - ppm: "167 865", - price: "6 999 999", - }, -]; - -export const dealRows: DealRow[] = [ - { - addr: "Екатеринбург, Академика Парина", - meta: "38.1 м² · 1-к", - ppm: "161 417", - price: "6 150 000", - }, - { - addr: "Екатеринбург, Академика Ландау", - meta: "33.9 м² · 1-к", - ppm: "175 516", - price: "5 950 000", - }, - { - addr: "Екатеринбург, Академика Ландау", - meta: "39.1 м² · 1-к", - ppm: "148 338", - price: "5 800 000", - }, - { - addr: "Екатеринбург, Академика Парина", - meta: "33.8 м² · 1-к", - ppm: "147 929", - price: "5 000 000", - }, - { - addr: "Екатеринбург, Академика Сахарова", - meta: "37.1 м² · 1-к", - ppm: "167 116", - price: "6 200 000", - }, - { - addr: "Екатеринбург, Академика Вавилова", - meta: "38.8 м² · 1-к", - ppm: "165 437", - price: "6 418 972", - }, - { - addr: "Екатеринбург, Академика Ландау", - meta: "42.0 м² · 1-к", - ppm: "158 333", - price: "6 650 000", - }, - { - addr: "Екатеринбург, Академика Ландау", - meta: "34.9 м² · 1-к", - ppm: "143 266", - price: "5 000 000", - }, - { - addr: "Екатеринбург, Академика Парина", - meta: "32.9 м² · 1-к", - ppm: "159 574", - price: "5 250 000", - }, - { - addr: "Екатеринбург, Академика Парина", - meta: "41.2 м² · 1-к", - ppm: "101 942", - price: "4 200 000", - }, -]; - -export const marketAds: MarketAds = { - kpi: { - count: "23", - median: "6,13", - ppm: "161 351", - range: "4,7–7,5", - cv: "45.9%", - }, - filters: ["Расстояние ≤ 2 км", "Планировка 1-к", "Площадь 32.5 – 41.7 м²"], -}; - -export const marketDeals: MarketDeals = { - kpi: { - count: "10", - median: "5,95", - delta: "−3% к рынку", - range: "4,2 – 6,7", - }, -}; - -// ---- OVERLAY 06: АНАЛИТИКА ДОМА ------------------------------------------- - -export const analytics: Analytics = { - header: { ads: "615 объявлений", radius: "в радиусе 300 м" }, - kpis: [ - { - label: "СРЕДНЯЯ ЭКСПОЗИЦИЯ", - value: "67", - unit: "дн.", - sub: "по архивным объявлениям", - }, - { - label: "СРЕДНИЙ ТОРГ", - value: "−3.0%", - color: tokens.success, - sub: "от начальной до итоговой цены", - }, - { - label: "ДОЛЯ СНЯТЫХ", - value: "69%", - sub: "421 из 615", - }, - ], - sellTimeNote: "Медиана экспозиции по архивным лотам · эталон 177 394 ₽/м²", - sellTime: [ - { - tier: "−5% от рынка", - days: "~63 дн.", - range: "обычно 18–180 дн.", - count: "69 аналогов", - variant: "success", - }, - { - tier: "По медиане", - days: "~65 дн.", - range: "обычно 18–182 дн.", - count: "79 аналогов", - variant: "accent", - }, - { - tier: "+5%", - days: "~78 дн.", - range: "обычно 16–212 дн.", - count: "61 аналог", - variant: "gold", - }, - { - tier: "+10%", - days: "~67 дн.", - range: "обычно 26–260 дн.", - count: "57 аналогов", - variant: "danger", - }, - ], - priceHistory: { - note: "Медиана ₽/м² по годам · только этот дом · Avito + Яндекс (596 лотов)", - years: ["2022", "2023", "2024", "2025", "2026"], - avito: "58,75 260,82 470,69 680,69 885,73", - yandex: "58,75 260,82 470,51 680,42 885,24", - yTicks: [ - { label: "200k", y: 20 }, - { label: "150k", y: 64 }, - { label: "100k", y: 108 }, - { label: "50k", y: 151 }, - { label: "0k", y: 195 }, - ], - }, - scatterDetail: { - note: "16 сделок и 6 аналогов в радиусе 300 м", - deals: [ - { x: 150, y: 210, r: 4.5 }, - { x: 200, y: 196, r: 4.5 }, - { x: 175, y: 222, r: 4.5 }, - { x: 250, y: 188, r: 4.5 }, - { x: 220, y: 204, r: 4.5 }, - { x: 300, y: 176, r: 4.5 }, - { x: 270, y: 200, r: 4.5 }, - { x: 330, y: 166, r: 4.5 }, - { x: 360, y: 182, r: 4.5 }, - { x: 195, y: 214, r: 4.5 }, - { x: 285, y: 190, r: 4.5 }, - { x: 160, y: 200, r: 4.5 }, - { x: 345, y: 198, r: 4.5 }, - { x: 240, y: 172, r: 4.5 }, - { x: 315, y: 186, r: 4.5 }, - { x: 180, y: 232, r: 4.5 }, - ], - analogs: [ - { x: 600, y: 96, r: 5.5 }, - { x: 540, y: 124, r: 5.5 }, - { x: 670, y: 84, r: 5.5 }, - { x: 500, y: 142, r: 5.5 }, - { x: 640, y: 104, r: 5.5 }, - { x: 730, y: 70, r: 5.5 }, - ], - subject: { x: 445, y: 150, r: 6 }, - yTicks: [ - { label: "15М", y: 24 }, - { label: "12М", y: 81 }, - { label: "9М", y: 138 }, - { label: "6М", y: 195 }, - { label: "4М", y: 252 }, - ], - xTicks: [ - { label: "0", x: 60 }, - { label: "45", x: 265 }, - { label: "90", x: 470 }, - { label: "135", x: 675 }, - { label: "180", x: 880 }, - ], - }, -}; - -// ---- OVERLAY 07: ПРЕДЫДУЩИЕ ОЦЕНКИ ---------------------------------------- - -export const cacheRows: CacheRow[] = [ - { - id: "fixture-1", - addr: "ул. Малышева, 30", - time: "12:45:02", - src: "3 / 7", - status: "свежий", - statusColor: tokens.success, - params: "2 комн · 54 м² · 5/9 эт.", - }, - { - id: "fixture-2", - addr: "ул. Куйбышева, 48", - time: "12:31:50", - src: "4 / 7", - status: "свежий", - statusColor: tokens.success, - params: "3 комн · 72 м² · 12/16 эт.", - }, - { - id: "fixture-3", - addr: "ул. Академика Ландау, 35", - time: "11:58:14", - src: "2 / 7", - status: "свежий", - statusColor: tokens.success, - params: "1 комн · 38 м²", - }, - { - id: "fixture-4", - addr: "ул. 8 Марта, 120", - time: "09:12:40", - src: "3 / 7", - status: "устаревает", - statusColor: tokens.warn, - params: "студия · 24 м² · 3/5 эт.", - }, - { - id: "fixture-5", - addr: "пр. Ленина, 5", - time: "вчера 18:04", - src: "5 / 7", - status: "устарел", - statusColor: tokens.danger, - params: "2 комн · 61,5 м²", - }, - { - id: "fixture-6", - addr: "ул. Белинского, 86", - time: "вчера 14:22", - src: "2 / 7", - status: "устарел", - statusColor: tokens.danger, - params: "4 комн · 95 м² · 7/10 эт.", - }, -]; - -export const cacheKpi: CacheKpi[] = [ - { label: "ВСЕГО ОЦЕНОК", value: "128", sub: "за последние 30 дней" }, - { - label: "СРЕДНЯЯ ЦЕНА ОЦЕНОК", - value: "9,6", - unit: "млн ₽", - sub: "по проведённым оценкам", - }, - { label: "ПОВТОРНЫЕ АДРЕСА", value: "18", unit: "%", sub: "доля переоценок" }, -]; - -// ---- INPUTS / DROPDOWNS --------------------------------------------------- - -export const dropdownOptions: DropdownOptions = { - rooms: ["1", "2", "3", "4", "5+"], - houseType: [ - "Не указано", - "Панельный", - "Кирпичный", - "Монолитный", - "Блочный", - "Монолитно-кирпичный", - ], - repair: [ - "Не указано", - "Без отделки", - "Требует ремонта", - "Удовлетворительный", - "Хороший", - "Отличный", - ], - radius: ["300 м", "500 м", "1000 м", "2000 м"], - crm: [ - "Не заполнять", - "Создать сделку", - "Привязать к контакту", - "Экспорт в CRM", - ], -}; - -// Note: the pre-wiring `locationFactors` fixture (fabricated 0.87 coefficient -// + base/coef/result formula + positives/negatives) lived here — removed -// together with the location-index rewrite. LocationDrawer now reads -// `LocationData` produced by `mapLocation` (./mappers.ts) from the real -// GET /trade-in/location-index response; its own default/unwired fixture is -// LOCATION_FIXTURE in ./LocationDrawer.tsx. - -// ---- NAV / CHROME --------------------------------------------------------- - -export const navLabels: string[] = [ - "ОЦЕНКА", - "ПРОДАЖИ В ДОМЕ", - "АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ", - "АНАЛИТИКА", - "ПРЕДЫДУЩИЕ ОЦЕНКИ", -]; - -export const overlayTitles: string[] = [ - "", - "ПРОДАЖИ В ДОМЕ", - "АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ", - "АНАЛИТИКА ДОМА", - "ПРЕДЫДУЩИЕ ОЦЕНКИ", -]; - -export const overlayNums: string[] = ["", "04", "05", "06", "07"]; - -export const user: User = { - name: "Андрей Петров", - org: "Брусника", - email: "a.petrov@mera.ru", - initials: "АП", - reports: 128, -}; - -export const version = "v2.0.6"; diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index 81e9ff94..a2bde80b 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -1,11 +1,13 @@ // Pure API -> presentation mappers for the /trade-in/v2 "МЕРА Оценка" HUD port. // // These functions take the real trade-in API objects (AggregatedEstimate, -// StreetDealsResponse, HouseAnalyticsResponse) and produce the EXACT fixture -// shapes the v2 components already consume (see ./types + ./fixtures). The Wire -// phase swaps each component's hardcoded fixture for a `data` prop fed by one of -// these mappers; the fixtures stay as the prop DEFAULT so unwired usage still -// renders. ALL money/format/geometry lives here — never copy fixture literals. +// StreetDealsResponse, HouseAnalyticsResponse) and produce the display shapes +// the v2 components consume (see ./types). Every component's `data` prop is +// REQUIRED and fed by one of these mappers — since #2747 there is no fixture +// default to fall back on, and since #2755 there are no data fixtures at all +// (./ui-config keeps only labels/options/version). ALL money/format/geometry +// lives here; a value the API did not supply is rendered as a dash, never +// substituted with a plausible-looking constant. // // Reconciliations / known FE-approximations (look for "TODO BE-N"): // BE-1 #2043 shipped 2026-07-02: AggregatedEstimate now carries real @@ -1294,9 +1296,9 @@ export function mapSummary( // // Three pure mappers feeding the SectionOverlay views. Each returns a composite // presentation shape whose fields keep the exact ./types contract the views -// already render, so the Wire phase only swaps each view's fixture import for a -// `data` prop (fixture stays the DEFAULT). All number/format/geometry recomputed -// here from the real API objects — never copied from fixtures. Graceful nulls +// render. The `data` prop is required — the views had fixture imports until the +// Wire phase and fixture DEFAULTS until #2747; both are gone. All number/format/ +// geometry is recomputed here from the real API objects. Graceful nulls // degrade to "—" / empty so a pending/errored sub-hook never blanks an overlay. // // NB on table number formats (faithful to the pixel design, NOT app-wide fmtPpm/ @@ -1966,26 +1968,31 @@ export function mapAnalytics( // ── 05 РЫНОК · АНАЛОГИ И СДЕЛКИ (SourcesView) ─────────────────────────────── // Real per-row source/distance/url + tier/date carried alongside the existing -// display fields. Optional so the design fixtures (AdRow[]/DealRow[]) stay valid -// as the component DEFAULT; the mapper always populates them. +// display fields. REQUIRED: mapSources is the only producer of these rows and +// always populates every field. They used to be optional so the deleted design +// fixtures (AdRow[]/DealRow[]) would typecheck as the component DEFAULT — and +// that optionality is what let SourcesView keep unreachable `?? "Avito"` / +// `?? "Росреестр"` / "Росреестр ↗" fallbacks that looked like real data (#2755). +// Keep them required: an absent value must be a TS error here, not an invented +// brand name on screen. export interface AdRowData extends AdRow { - /** Source brand label for the badge (was hardcoded "Avito"). */ - source?: string; - /** Distance to the subject (was hardcoded "—"). */ - dist?: string; - /** Original listing URL (was a hardcoded "Росреестр ↗" link). */ - url?: string | null; + /** Source brand label for the badge. */ + source: string; + /** Distance to the subject; "—" when unknown. */ + dist: string; + /** Original listing URL; null when the row has no link. */ + url: string | null; /** Fix #8 — ₽/м² > OUTLIER_MULT× the pool median: excluded from spread/CV, * flagged with a "возможный выброс" badge instead of silently distorting it. */ - outlier?: boolean; + outlier: boolean; } export interface DealRowData extends DealRow { - /** Source brand label for the badge (was hardcoded "Росреестр"). */ - source?: string; - /** Tier caption next to the badge (was hardcoded "по улице"). */ - tier?: string; - /** Deal/listing date (was hardcoded "01.01.26"). */ - date?: string; + /** Source brand label for the badge. */ + source: string; + /** Tier caption next to the badge. */ + tier: string; + /** Deal/listing date; "—" when unknown. */ + date: string; } export interface SourcesData { diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts index 81ca7422..eb682c4c 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts @@ -1,5 +1,7 @@ -// TypeScript shapes for the /trade-in/v2 design fixtures. -// All values are sample/markup data (see fixtures.ts) — no API types here. +// Display shapes rendered by the /trade-in/v2 components. Not API types — the +// wire contract lives in @/types/trade-in and is converted here by ./mappers. +// Originally these described the pixel-design fixtures; the fixtures are gone +// (#2755), the shapes stayed because the mappers produce them. export interface Report { id: string; @@ -314,12 +316,7 @@ export interface DropdownOptions { // (see ./mappers.ts), which reflects the real backend contract, not the old // fabricated formula. -// ---- USER / CHROME -------------------------------------------------------- - -export interface User { - name: string; - org: string; - email: string; - initials: string; - reports: number; -} +// Note: the `User` shape («Андрей Петров» / «Брусника» / reports: 128) lived +// here for the fixture of the same name — both deleted in #2755. The real +// logged-in identity is `TopNavUser` in ./TopNav.tsx, fed from useMe(); a +// missing user renders the neutral «Гость», never a fabricated person. diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ui-config.ts b/tradein-mvp/frontend/src/components/trade-in/v2/ui-config.ts new file mode 100644 index 00000000..7e955710 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ui-config.ts @@ -0,0 +1,62 @@ +// Static UI chrome for the /trade-in/v2 "МЕРА Оценка" port: nav/overlay labels, +// select options, build version. Constants only — no numbers that could be +// mistaken for measurements. +// +// This file used to be `fixtures.ts` and carried ~600 lines of made-up prices, +// deal rows and analytics lifted from the pixel design. Those were the prop +// DEFAULTS until #2747 made every data prop required; #2755 deleted them. All +// displayed data now comes from the API through ./mappers — if a value is +// missing the UI shows an honest dash, never a plausible-looking number. + +import type { DropdownOptions } from "./types"; + +// ---- INPUTS / DROPDOWNS --------------------------------------------------- + +export const dropdownOptions: DropdownOptions = { + rooms: ["1", "2", "3", "4", "5+"], + houseType: [ + "Не указано", + "Панельный", + "Кирпичный", + "Монолитный", + "Блочный", + "Монолитно-кирпичный", + ], + repair: [ + "Не указано", + "Без отделки", + "Требует ремонта", + "Удовлетворительный", + "Хороший", + "Отличный", + ], + radius: ["300 м", "500 м", "1000 м", "2000 м"], + crm: [ + "Не заполнять", + "Создать сделку", + "Привязать к контакту", + "Экспорт в CRM", + ], +}; + +// ---- NAV / CHROME --------------------------------------------------------- + +export const navLabels: string[] = [ + "ОЦЕНКА", + "ПРОДАЖИ В ДОМЕ", + "АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ", + "АНАЛИТИКА", + "ПРЕДЫДУЩИЕ ОЦЕНКИ", +]; + +export const overlayTitles: string[] = [ + "", + "ПРОДАЖИ В ДОМЕ", + "АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ", + "АНАЛИТИКА ДОМА", + "ПРЕДЫДУЩИЕ ОЦЕНКИ", +]; + +export const overlayNums: string[] = ["", "04", "05", "06", "07"]; + +export const version = "v2.0.6"; diff --git a/tradein-mvp/frontend/src/lib/city-registry.ts b/tradein-mvp/frontend/src/lib/city-registry.ts index 16178d6c..bc07b8ab 100644 --- a/tradein-mvp/frontend/src/lib/city-registry.ts +++ b/tradein-mvp/frontend/src/lib/city-registry.ts @@ -79,7 +79,7 @@ export const DEFAULT_CITY: OblastCity = OBLAST_CITIES[0]; */ export const UNCONFIRMED_CITY_LABEL = "Автоопределение"; -/** RU-лейблы для дропдауна — тот же формат (string[]), что dropdownOptions.* в v2/fixtures.ts. */ +/** RU-лейблы для дропдауна — тот же формат (string[]), что dropdownOptions.* в v2/ui-config.ts. */ export const CITY_LABELS: string[] = OBLAST_CITIES.map((c) => c.label); /** From c3a35a31d45556ae12453499ef05b729f24b1faf Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 7 Aug 2026 10:11:36 +0000 Subject: [PATCH 07/23] =?UTF-8?q?fix(ptica):=20=D1=81=D0=BA=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=20=D0=9F=D0=94=D0=BD=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=91=D1=82=20=D1=83=D1=82=D0=B5=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D1=82=D0=BE,=20=D1=87=D1=82=D0=BE=20=D0=B7=D0=B0?= =?UTF-8?q?=D1=89=D0=B8=D1=89=D0=B0=D0=B5=D1=82=20+=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=B4=D0=BA=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20=D0=BF=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC=20(#2753)=20(#2787)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/main.py | 6 + backend/app/observability/sentry_scrub.py | 20 ++- backend/app/workers/celery_app.py | 4 + backend/tests/_sentry_wiring_probe.py | 146 ++++++++++++++++++ backend/tests/test_sentry_init.py | 124 ++++++++++++--- .../concept/ConceptVariantsResult.tsx | 13 +- 6 files changed, 284 insertions(+), 29 deletions(-) create mode 100644 backend/tests/_sentry_wiring_probe.py diff --git a/backend/app/main.py b/backend/app/main.py index 7162bfa0..e0ac46cb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -87,6 +87,12 @@ if settings.glitchtip_dsn: traces_sample_rate=settings.glitchtip_traces_sample_rate, profiles_sample_rate=0.0, send_default_pii=False, + # Локальные переменные кадров стека НЕ уходят в мониторинг (#2753). + # Дефолт SDK — True: при любом исключении кадр несёт значения аргументов + # (телефон заявки, адрес, токен) под ПРОИЗВОЛЬНЫМИ именами, а scrub_event + # сверяет ИМЕНА ключей — такое он не ловит по построению. То есть это не + # дополнительная мера, а условие, без которого скраб не полон. + include_local_variables=False, before_send=scrub_event, before_send_transaction=scrub_event, integrations=[ diff --git a/backend/app/observability/sentry_scrub.py b/backend/app/observability/sentry_scrub.py index d40911ae..66f9c2cc 100644 --- a/backend/app/observability/sentry_scrub.py +++ b/backend/app/observability/sentry_scrub.py @@ -36,10 +36,19 @@ import logging import re from typing import Any +from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.types import Event logger = logging.getLogger(__name__) +# Собственный сбой скраба НЕ должен становиться событием мониторинга (#2753). +# LoggingIntegration (event_level=ERROR) превратила бы строку журнала об отказе +# в новое событие, которое снова пойдёт через этот же обработчик; при +# детерминированном сбое это рекурсия — защиты от неё в SDK нет (проверено: +# 1000+ вложенных трассировок за минуту, процесс не завершается). Диагностика +# остаётся в stdout контейнера: текст трассировки значений переменных не несёт. +ignore_logger(__name__) + _REDACTED = "[REDACTED]" # Ключи consumer-PII (нижний регистр; сверка case-insensitive). Набор МЕРЫ # (client_name/client_phone/client_email/phone/email/name, #396) + company/ @@ -143,6 +152,13 @@ def scrub_event(event: Event, hint: dict[str, Any]) -> Event | None: try: scrub_pii_event(event, hint) scrub_sensitive_query(event, hint) - except Exception: - logger.exception("sentry_scrub.scrub_event: handler failed, sending event as-is") + except Exception as exc: + # Ни трассировки, ни str(exc): и то и другое способно нести значения из + # ЕЩЁ НЕ ОЧИЩЕННОГО event — то есть страховка утекла бы ровно то, что + # защищает (#2753). Имя класса исключения данных не несёт. Событием + # мониторинга эта строка не станет — см. ignore_logger выше. + logger.error( + "sentry_scrub.scrub_event: handler failed (%s), sending event as-is", + type(exc).__name__, + ) return event diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index e52b8f5b..bfba175d 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -35,6 +35,10 @@ if settings.glitchtip_dsn: traces_sample_rate=settings.glitchtip_traces_sample_rate, profiles_sample_rate=0.0, send_default_pii=False, + # Локальные переменные кадров стека НЕ уходят в мониторинг (#2753) — см. + # app/main.py: скраб сверяет ИМЕНА ключей, а имя переменной произвольно. + # В воркере вектор шире: задачи держат в кадрах сырые ответы источников. + include_local_variables=False, before_send=scrub_event, before_send_transaction=scrub_event, integrations=[ diff --git a/backend/tests/_sentry_wiring_probe.py b/backend/tests/_sentry_wiring_probe.py new file mode 100644 index 00000000..a2c72bf4 --- /dev/null +++ b/backend/tests/_sentry_wiring_probe.py @@ -0,0 +1,146 @@ +"""Проба проводки GlitchTip: запускается ОТДЕЛЬНЫМ процессом из test_sentry_init.py. + +Зачем подпроцесс. `app/main.py` и `app/workers/celery_app.py` зовут +`sentry_sdk.init()` на импорте модуля и только при непустом `GLITCHTIP_DSN`. В +процессе pytest этот путь недостижим (модуль уже в `sys.modules`, DSN пуст), а +если бы и был достижим — глобальный клиент SDK остался бы живым для всех +последующих тестов. Отдельный процесс даёт настоящую инициализацию и умирает +вместе с ней. + +Наружу не уходит ничего: `capture_envelope` подменяется ДО первого события, а +DSN в тесте указывает на несуществующий хост. Значения-маркеры генерируются +случайно на каждый запуск — кадр стека несёт не только переменные, но и строки +исходника, поэтому литерал в коде пробы сделал бы проверку вечно красной. + +stdout — одна строка JSON: counts / scrub_handler_entries / markers / payloads +(тело каждого канала отдельно — см. `main`). +""" + +from __future__ import annotations + +import importlib +import io +import itertools +import json +import sys +import uuid +from typing import Any + +import sentry_sdk + +_FAILURE_CAP = 3 + + +def _fresh(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4().hex}" + + +def _leaking_event(markers: dict[str, str]) -> dict[str, Any]: + """Событие с ПДн в трёх местах, которые закрывает scrub_event.""" + return { + "message": "sentry-wiring-probe", + "level": "error", + "request": { + "data": {"phone": markers["phone"], "message": markers["free_text"]}, + "url": f"https://example.invalid/probe?api_key={markers['url_secret']}", + }, + } + + +def main(module: str) -> int: + importlib.import_module(module) # ← здесь отрабатывает sentry_sdk.init() + + client = sentry_sdk.get_client() + sent: list[str] = [] + + def _record(envelope: Any) -> None: + buf = io.BytesIO() + envelope.serialize_into(buf) + sent.append(buf.getvalue().decode("utf-8", "replace")) + + client.transport.capture_envelope = _record # type: ignore[union-attr,method-assign] + + markers = { + "phone": _fresh("probe-phone"), + "free_text": _fresh("probe-free-text"), + "url_secret": _fresh("probe-url-secret"), + "local_var": _fresh("probe-local-var"), + } + + # 1. Канал ошибок (before_send). + sentry_sdk.capture_event(_leaking_event(markers)) + after_error = len(sent) + + # 2. Канал транзакций (before_send_transaction) — Starlette кладёт + # request.data на transaction-scope так же, как на error-scope. + transaction = _leaking_event(markers) + transaction["type"] = "transaction" + transaction["transaction"] = "sentry-wiring-probe-tx" + transaction["contexts"] = {"trace": {"trace_id": "0" * 32, "span_id": "0" * 16}} + transaction["start_timestamp"] = "2026-01-01T00:00:00.000000Z" + transaction["timestamp"] = "2026-01-01T00:00:01.000000Z" + transaction["spans"] = [] + sentry_sdk.capture_event(transaction) + after_transaction = len(sent) + + # 3. Локальные переменные кадра стека (include_local_variables). Имя + # переменной произвольное — ключевой скраб такое не ловит по построению. + def _raise_with_local() -> None: + applicant_note = markers["local_var"] # noqa: F841 — ради кадра стека + raise RuntimeError("sentry-wiring-probe boom") + + try: + _raise_with_local() + except RuntimeError: + sentry_sdk.capture_exception() + after_exception = len(sent) + + # 4. Сбой самого скраба не должен порождать ВТОРОЕ событие: иначе строка + # журнала об отказе уходит в мониторинг через LoggingIntegration, снова + # попадает в скраб, снова падает — рекурсия (#2753; на коде до фикса + # проверено: не завершается, 1000+ вложенных трассировок за минуту). + # Считаем ВХОДЫ в обработчик; после _FAILURE_CAP перестаём падать, иначе + # проба на сломанном коде висела бы вместо того, чтобы честно покраснеть. + from app.observability import sentry_scrub + + original = sentry_scrub.scrub_pii_event + entries: list[int] = [] + + def _boom(*_a: Any, **_kw: Any) -> Any: + entries.append(1) + if len(entries) > _FAILURE_CAP: + return None + raise RuntimeError("sentry-wiring-probe scrubber failure") + + sentry_scrub.scrub_pii_event = _boom # type: ignore[assignment] + try: + # Без маркеров: это событие по замыслу уходит НЕОЧИЩЕННЫМ ("as-is"). + sentry_sdk.capture_event({"message": "sentry-wiring-probe-failure", "level": "error"}) + finally: + sentry_scrub.scrub_pii_event = original # type: ignore[assignment] + after_scrub_failure = len(sent) + + # Тело каждого канала — отдельно: иначе утечка из одного (напр. локальные + # переменные шага 3 несут те же маркеры, что тело запроса шага 1) красит + # чужую проверку и мешает понять, что именно сломано. + bounds = [0, after_error, after_transaction, after_exception, after_scrub_failure] + names = ["error", "transaction", "exception", "scrub_failure"] + spans = dict(zip(names, itertools.pairwise(bounds), strict=True)) + + print( + json.dumps( + { + "counts": {name: end - start for name, (start, end) in spans.items()}, + "scrub_handler_entries": len(entries), + "markers": markers, + "payloads": { + name: "\n".join(sent[start:end]) for name, (start, end) in spans.items() + }, + } + ) + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1])) diff --git a/backend/tests/test_sentry_init.py b/backend/tests/test_sentry_init.py index 73dd80c4..19ff11d1 100644 --- a/backend/tests/test_sentry_init.py +++ b/backend/tests/test_sentry_init.py @@ -4,15 +4,21 @@ только при непустом GLITCHTIP_DSN, что release-fallback работает корректно, что scrub_sensitive_query redact-ит api keys из URL spans, что scrub_pii_event redact-ит consumer-PII (client_name/client_phone/client_email/phone/email/name/ -company/message) из request.data/extra/contexts, и что composed-хендлер -scrub_event реально повешен на ОБА канала (before_send И -before_send_transaction) в main.py/celery_app.py (#2457-review). +company/message) из request.data/extra/contexts (#2457-review), и — в конце +файла — что до транспорта не доезжают ни ПДн тела запроса, ни значения +локальных переменных кадра стека, ни второе событие о сбое самого скраба +(#2753, поведение через подставной транспорт вместо поиска подстроки). """ +import json import os import pathlib +import subprocess +import sys +from functools import lru_cache from unittest.mock import patch +import pytest import sentry_sdk _BACKEND_ROOT = pathlib.Path(__file__).resolve().parents[1] @@ -392,26 +398,102 @@ def test_scrub_event_survives_scrub_sensitive_query_exception() -> None: assert result is event -# ── wiring: before_send/before_send_transaction реально используют scrub_event ── +# ── wiring: ПДн не доходят до транспорта (поведение, а не текст исходника) ───── # -# Source-grep вместо мока sentry_sdk.init: main.py/celery_app.py вызывают -# sentry_sdk.init() на module-level import, поэтому мок пришлось бы ставить ДО -# импорта app.main — фрагильно и не переиспользуемо между тестами (модуль уже -# закэширован в sys.modules к моменту первого теста). Прямая проверка исходника -# — детерминированный, дешёвый и точный регрессионный гейт на саму строку, -# которую правил review (#2457). +# До #2753 проводка проверялась поиском подстроки `before_send=scrub_event` в +# файле. Такой гейт зелен и на разорванной проводке: обе точки входа несут +# многострочные комментарии, где те же подстроки встречаются, — достаточно +# удалить сам аргумент, оставив комментарий. Хуже того, подстрока ничего не +# говорит о том, ДОШЛИ ли ПДн до транспорта: их можно выпустить и при живом +# before_send (локальные переменные кадра стека уходят мимо ключевого скраба). +# +# Поэтому проверяем поведение: поднимаем настоящую инициализацию в подпроцессе +# (`tests/_sentry_wiring_probe.py`), подменяем транспорт и смотрим, что до него +# доехало. Наружу не уходит ничего — DSN указывает на несуществующий хост, а +# `capture_envelope` подменён до первого события. -def test_main_wires_scrub_event_to_both_channels() -> None: - """app/main.py: before_send И before_send_transaction ОБА на scrub_event.""" - text = (_BACKEND_ROOT / "app" / "main.py").read_text(encoding="utf-8") - assert "before_send=scrub_event" in text - assert "before_send_transaction=scrub_event" in text +@lru_cache(maxsize=2) +def _probe(module: str) -> str: + """Прогнать пробу проводки для точки входа `module`; вернуть JSON-строку.""" + env = { + **os.environ, + "TESTING": "1", + # Синтаксически валидный DSN на несуществующий хост: init отработает, + # сети не будет даже если транспорт когда-нибудь перестанут подменять. + "GLITCHTIP_DSN": "https://probe@localhost.invalid/1", + # Явно: у запуска скрипта в sys.path[0] попадает КАТАЛОГ СКРИПТА (tests/), + # и без этого `import app` уехал бы в editable-установку пакета — то есть + # проба мерила бы чужое дерево, а не то, что рядом с ней лежит. + "PYTHONPATH": os.pathsep.join([str(_BACKEND_ROOT), os.environ.get("PYTHONPATH", "")]), + } + proc = subprocess.run( + [sys.executable, str(_BACKEND_ROOT / "tests" / "_sentry_wiring_probe.py"), module], + cwd=_BACKEND_ROOT, + env=env, + capture_output=True, + text=True, + timeout=300, + check=False, + ) + assert proc.returncode == 0, f"проба упала: {proc.stderr[-3000:]}" + return proc.stdout.strip().splitlines()[-1] -def test_celery_app_wires_scrub_event_to_both_channels() -> None: - """app/workers/celery_app.py: before_send И before_send_transaction ОБА на - scrub_event (раньше before_send не было вообще).""" - text = (_BACKEND_ROOT / "app" / "workers" / "celery_app.py").read_text(encoding="utf-8") - assert "before_send=scrub_event" in text - assert "before_send_transaction=scrub_event" in text +@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"]) +def test_pii_never_reaches_transport(module: str) -> None: + """Оба канала (error И transaction) отдают транспорту событие без ПДн. + + Красный, если из `sentry_sdk.init()` убрать `before_send` ИЛИ + `before_send_transaction` — комментарий с теми же словами не спасает. + """ + probe = json.loads(_probe(module)) + markers = probe["markers"] + + for channel in ("error", "transaction"): + payload = probe["payloads"][channel] + # Контроль «событие вообще доехало»: без него проверка была бы зелёной + # и на пробе, которая молча ничего не отправила. + assert probe["counts"][channel] == 1, f"{module}/{channel}: событие не доехало" + assert "[REDACTED]" in payload, f"{module}/{channel}: скраб не отработал" + + leaked = [key for key in ("phone", "free_text", "url_secret") if markers[key] in payload] + assert leaked == [], f"{module}/{channel}: до транспорта дошли ПДн — {leaked}" + + +@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"]) +def test_local_variables_never_reach_transport(module: str) -> None: + """`include_local_variables=False`: значения локальных переменных кадра стека + не уходят в мониторинг (#2753). + + Ключевой скраб такое не ловит по построению — имя переменной произвольно, + а сверка идёт по именам. Красный, если флаг убрать из `sentry_sdk.init()` + (в sentry-sdk он по умолчанию `True`). + """ + probe = json.loads(_probe(module)) + payload = probe["payloads"]["exception"] + + assert probe["counts"]["exception"] == 1 + assert "sentry-wiring-probe boom" in payload, "событие с исключением не доехало" + + assert ( + probe["markers"]["local_var"] not in payload + ), f"{module}: значение локальной переменной ушло в мониторинг" + + +@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"]) +def test_scrub_failure_does_not_spawn_second_event(module: str) -> None: + """Сбой самого скраба не порождает ВТОРОГО события (#2753). + + `logger` этого модуля внесён в `ignore_logger`, иначе строка журнала об + отказе ушла бы в мониторинг через LoggingIntegration (event_level=ERROR), + снова попала бы в скраб, снова упала — рекурсия, защиты от которой в SDK + нет (проверено на коде до фикса: не завершается). Красный, если + `ignore_logger` убрать: обработчик войдёт повторно. + """ + probe = json.loads(_probe(module)) + + assert ( + probe["scrub_handler_entries"] == 1 + ), "сбой скраба вернулся вторым событием: строка журнала уходит в мониторинг" + assert probe["counts"]["scrub_failure"] == 1 diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx index 14448afb..8e9d02f9 100644 --- a/frontend/src/components/concept/ConceptVariantsResult.tsx +++ b/frontend/src/components/concept/ConceptVariantsResult.tsx @@ -526,14 +526,15 @@ function VariantPanel({ ? "IRR помечен как оценочный: денежный поток вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. " : ""} НДС: жильё и услуги застройщика по ДДУ освобождены (ст. 149 НК - РФ); НДС начисляется только на паркинг (нежилые машиноместа). - Входной НДС по строительству уже учтён в себестоимости. Налог на - прибыль — 25% (с 2025 года). Цена продажи жилья —{" "} + РФ); НДС начисляется на нежилое — паркинг (машиноместа) и + коммерцию 1-го этажа. Входной НДС по строительству уже учтён в + себестоимости. Налог на прибыль — 25% (с 2025 года). Цена продажи + жилья —{" "} {financial.price_is_calibrated ? `калибрована по рынку (${priceSourceCaption(financial)})` : "норматив класса (нет рыночных данных по участку)"} - ; себестоимость СМР и цена паркинга — нормативные ориентиры. - Коммерческие и офисные площади не учитываются. + ; себестоимость СМР, цена паркинга и цена нежилого — нормативные + ориентиры. {financial.financing_enabled && financial.financing_is_simplified ? " Финансирование упрощено: весь кассовый разрыв покрыт " + "кредитом по ставке-нормативу, проценты капитализируются, " + @@ -656,7 +657,7 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) { value={formatMoneyCompact(financial.gross_margin_rub)} /> Date: Fri, 7 Aug 2026 10:22:55 +0000 Subject: [PATCH 08/23] =?UTF-8?q?fix(site-finder):=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D1=83=D1=82=D1=8C=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF?= =?UTF-8?q?=20=D0=BA=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8F=D0=BC=20?= =?UTF-8?q?=D0=B2=D0=B5=D1=81=D0=BE=D0=B2=20=E2=80=94=20=D0=B2=D0=BB=D0=B0?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B5=D1=86=20=D0=B8=D0=B7=20=D1=81=D0=B5?= =?UTF-8?q?=D1=81=D1=81=D0=B8=D0=B8=20(#2782)=20(#2788)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/app/legacy/site-finder/page.tsx | 78 +++------ .../site-finder/WeightProfilePanel.tsx | 54 +++---- .../WeightProfilePanel.identity.test.tsx | 151 ++++++++++++++++++ frontend/src/hooks/useSiteAnalysis.ts | 34 +++- frontend/src/lib/api/weightProfiles.ts | 36 ++--- 5 files changed, 238 insertions(+), 115 deletions(-) create mode 100644 frontend/src/components/site-finder/__tests__/WeightProfilePanel.identity.test.tsx diff --git a/frontend/src/app/legacy/site-finder/page.tsx b/frontend/src/app/legacy/site-finder/page.tsx index bd7144c7..61e580a8 100644 --- a/frontend/src/app/legacy/site-finder/page.tsx +++ b/frontend/src/app/legacy/site-finder/page.tsx @@ -14,7 +14,7 @@ import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab"; import { LandTab } from "@/components/site-finder/LandTab"; import { MarketTab } from "@/components/site-finder/MarketTab"; import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel"; -import { useSiteAnalysis } from "@/hooks/useSiteAnalysis"; +import { buildAnalyzeOptions, useSiteAnalysis } from "@/hooks/useSiteAnalysis"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { useConnectionPoints } from "@/hooks/useConnectionPoints"; import { useCustomPois } from "@/hooks/useCustomPois"; @@ -22,6 +22,7 @@ import { POI_DEFAULT_WEIGHTS, type PoiCategoryKey, } from "@/lib/api/weightProfiles"; +import { useMe } from "@/lib/useMe"; // SiteMap imports Leaflet which requires browser APIs — load without SSR const SiteMap = dynamic( @@ -129,16 +130,16 @@ function SiteFinderContent() { // Ref to skip the initial mount effect (we only re-analyze on actual changes). const weightsChangeInitializedRef = useRef(false); - const [profileUserId, setProfileUserId] = useState(() => - typeof window === "undefined" - ? "" - : (localStorage.getItem("admin_user_id") ?? ""), - ); - const [adminToken] = useState(() => - typeof window === "undefined" - ? "" - : (localStorage.getItem("admin_token") ?? ""), - ); + // Владелец weight-профилей — вошедший пользователь (#2782). Было: два значения + // из localStorage (`admin_user_id` из удалённого в #442 инпута и `admin_token`, + // который сервер не читает с #437) — оба недостижимы без DevTools. + // + // 🔴 profileUserId нужен НЕ только панели: analyze с одним `profile_id` без + // `profile_user_id` бэкенд резолвит в СИСТЕМНЫЕ веса, отдавая при этом + // `weights_profile.source = "profile"` (проверено на проде: profile_id=1 без + // user_id → tram_stop −0.5 вместо −0.4 из профиля). То есть выбранный профиль + // молча не применялся бы, а UI показывал бы его ползунки. + const profileUserId = useMe().data?.username ?? ""; // Lazy init: считаем initialTab один раз на mount (useState всё равно // игнорирует initializer после первого render — не тратим CPU). const [tab, setTabState] = useState(() => { @@ -192,12 +193,7 @@ function SiteFinderContent() { setIsochrones(undefined); mutate({ cad: currentData.cad_num, - options: - profileId != null - ? { profileId } - : currentProfileUserId - ? { profileUserId: currentProfileUserId, weights } - : { weights }, + options: buildAnalyzeOptions(weights, profileId, currentProfileUserId), }); // mutate is stable from useMutation — safe to omit from deps. // data?.cad_num — dep, чтобы при завершении ПЕРВИЧНОГО analyze (cad_num @@ -216,12 +212,11 @@ function SiteFinderContent() { // slider values are always respected even without a saved profile (#201). mutate({ cad: cadNum, - options: - activeProfileId != null - ? { profileId: activeProfileId } - : profileUserId - ? { profileUserId, weights: currentWeights } - : { weights: currentWeights }, + options: buildAnalyzeOptions( + currentWeights, + activeProfileId, + profileUserId, + ), }); } @@ -329,46 +324,9 @@ function SiteFinderContent() { {/* Weight profile panel — collapsible, below header */}
- {/* Optional user-id field for profile CRUD (shown only when adminToken present) */} - {!!adminToken && ( -
- - { - setProfileUserId(e.target.value); - if (typeof window !== "undefined") { - localStorage.setItem("admin_user_id", e.target.value); - } - }} - /> -
- )} {/* Recalculation indicator — shown while re-analyze is in-flight after weights change (data already loaded, pendingWeightsChange set). */} diff --git a/frontend/src/components/site-finder/WeightProfilePanel.tsx b/frontend/src/components/site-finder/WeightProfilePanel.tsx index 887cd1d8..60fcbe5b 100644 --- a/frontend/src/components/site-finder/WeightProfilePanel.tsx +++ b/frontend/src/components/site-finder/WeightProfilePanel.tsx @@ -3,6 +3,7 @@ import { useCallback, useState } from "react"; import { SectionLabel } from "@/components/ui/SectionLabel"; +import { useMe } from "@/lib/useMe"; import { POI_CATEGORIES, POI_DEFAULT_WEIGHTS, @@ -29,13 +30,6 @@ interface Props { weights: Record, profileId: number | null, ) => void; - /** - * If provided, enables save/load from DB. - * Must be non-empty for CRUD functionality. - */ - userId?: string; - /** Admin token for CRUD API calls. */ - adminToken?: string; } // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -66,12 +60,7 @@ function weightsEqual( // ── Component ───────────────────────────────────────────────────────────────── -export function WeightProfilePanel({ - currentWeights, - onWeightsChange, - userId, - adminToken, -}: Props) { +export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) { const [open, setOpen] = useState(false); // Local draft weights — editable before "Применить" @@ -89,11 +78,19 @@ export function WeightProfilePanel({ const [saveName, setSaveName] = useState(""); const [saveDefault, setSaveDefault] = useState(false); - // Profiles query (only when userId + adminToken provided) - const canUseCrud = !!userId && !!adminToken; - const profilesQuery = useWeightProfiles(userId ?? "", adminToken ?? ""); + // Владелец профилей — вошедший пользователь (#2782). Раньше user_id вводили + // руками, а CRUD был заперт на `adminToken` из localStorage, которого негде было + // взять: поле ввода удалили в #442, а сервер перестал читать X-Admin-Token ещё в + // #437. Профили и так per-user, так что личность берём оттуда же, откуда её берут + // RouteGuard и Topbar — из /api/v1/me (тот же queryKey, запрос не дублируется). + // В проде username всегда есть: до страницы не пустит Caddy basic_auth. Пусто + // бывает только в dev без Caddy (/me → 401) — тогда CRUD выключен. + const { data: me } = useMe(); + const userId = me?.username ?? ""; + const canUseCrud = !!userId; + const profilesQuery = useWeightProfiles(userId); - const createMutation = useCreateProfile(adminToken ?? ""); + const createMutation = useCreateProfile(); // ── Handlers ──────────────────────────────────────────────────────────────── @@ -117,7 +114,7 @@ export function WeightProfilePanel({ } const handleSaveProfile = useCallback(async () => { - if (!canUseCrud || !userId || !adminToken) { + if (!canUseCrud || !userId) { setShowSaveDialog(false); return; } @@ -136,15 +133,7 @@ export function WeightProfilePanel({ } catch { // Error visible through createMutation.error } - }, [ - canUseCrud, - userId, - adminToken, - saveName, - draft, - saveDefault, - createMutation, - ]); + }, [canUseCrud, userId, saveName, draft, saveDefault, createMutation]); // ── Derived ───────────────────────────────────────────────────────────────── @@ -242,6 +231,7 @@ export function WeightProfilePanel({
@@ -278,10 +268,13 @@ export function WeightProfilePanel({
)} - {/* Hint when no crud */} + {/* Hint when no crud. В проде недостижимо (без входа страницу не отдаёт + Caddy basic_auth) — остаётся для dev-запуска без прокси, где + /api/v1/me отвечает 401. Текст называет причину, а не действие: + вводить тут больше нечего. */} {!canUseCrud && (

- Укажите User ID и Admin Token для сохранения профилей. + Пользователь не определён — сохранение профилей недоступно.

)} @@ -362,6 +355,9 @@ export function WeightProfilePanel({ }} > Новый профиль +

+ Сохранится для пользователя {userId} — другие его не увидят. +

(); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +const PROFILE = { + id: 1, + user_id: "admin", + profile_name: "Мой профиль", + weights: { ...POI_DEFAULT_WEIGHTS, park: 2.5 }, + is_default: true, + description: null, + created_at: "2026-05-15T05:30:51Z", + updated_at: "2026-05-15T05:30:51Z", +}; + +function renderPanel() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + {}} + /> + , + ); +} + +/** Раскрыть свёрнутую панель (в закрытом виде тело не рендерится). */ +async function openPanel() { + const { default: userEvent } = await import("@testing-library/user-event"); + await userEvent.setup().click(screen.getByText("POI Веса")); +} + +beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockImplementation(async (input) => { + const url = typeof input === "string" ? input : String(input); + if (url.includes("/api/v1/me")) { + return jsonResponse({ + username: "admin", + role: "admin", + allowed_paths: ["/**"], + deny_paths: [], + }); + } + if (url.includes("/weight-profiles")) { + return jsonResponse([PROFILE]); + } + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("WeightProfilePanel — владелец профилей из сессии (#2782)", () => { + it("включает CRUD без пропсов: показывает профили вошедшего пользователя", async () => { + renderPanel(); + await openPanel(); + + // Профили грузятся по user_id из /me, без ручного ввода и без токена. + await waitFor(() => + expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(), + ); + expect(screen.getByText("Профиль:")).toBeTruthy(); + }); + + it("не обещает поля, которого нет: старой подсказки про Admin Token больше нет", async () => { + renderPanel(); + await openPanel(); + + await waitFor(() => + expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(), + ); + expect(screen.queryByText(/Admin Token/i)).toBeNull(); + expect(screen.queryByText(/Укажите User ID/i)).toBeNull(); + }); + + it("не шлёт X-Admin-Token — сервер его не читает с #437", async () => { + renderPanel(); + await openPanel(); + + await waitFor(() => { + const crudCall = fetchMock.mock.calls.find(([input]) => + String(input).includes("/weight-profiles"), + ); + expect(crudCall).toBeTruthy(); + const headers = new Headers( + (crudCall?.[1] as RequestInit | undefined)?.headers, + ); + expect(headers.has("X-Admin-Token")).toBe(false); + }); + }); +}); + +describe("buildAnalyzeOptions — profileId не ходит без владельца (#2782)", () => { + const weights = { ...POI_DEFAULT_WEIGHTS }; + + it("выбранный профиль уходит вместе с profileUserId", () => { + expect(buildAnalyzeOptions(weights, 7, "admin")).toEqual({ + profileId: 7, + profileUserId: "admin", + }); + }); + + it("без пользователя profileId не отправляется — иначе бэкенд тихо возьмёт системные веса", () => { + const options = buildAnalyzeOptions(weights, 7, ""); + expect(options.profileId).toBeUndefined(); + expect(options.weights).toEqual(weights); + }); + + it("без профиля уходят inline-веса и владелец для default-профиля", () => { + expect(buildAnalyzeOptions(weights, null, "admin")).toEqual({ + profileUserId: "admin", + weights, + }); + }); +}); diff --git a/frontend/src/hooks/useSiteAnalysis.ts b/frontend/src/hooks/useSiteAnalysis.ts index 1ffd3a7e..745ccdb1 100644 --- a/frontend/src/hooks/useSiteAnalysis.ts +++ b/frontend/src/hooks/useSiteAnalysis.ts @@ -12,11 +12,7 @@ import type { ParcelAnalysis } from "@/types/site-finder"; // /fetch-status every 2s, re-triggers analyze когда status=ready. export type FetchStatus = - | "ready" - | "fetching" - | "failed" - | "not_in_nspd" - | "invalid_format"; + "ready" | "fetching" | "failed" | "not_in_nspd" | "invalid_format"; export interface FetchStatusResponse { status: FetchStatus; @@ -52,6 +48,34 @@ export interface AnalyzeOptions { weights?: Record | null; } +/** + * Собрать options для POST /analyze из состояния панели весов POI. + * + * 🔴 `profileId` уходит на бэкенд ТОЛЬКО в паре с `profileUserId`. Причина не + * стилистическая: `resolve_weights()` ищет профиль как `get_profile(db, user_id, + * profile_id)` — при `user_id=None` условие `profile_id is not None and user_id is + * not None` не выполняется, и веса тихо падают на системные. Ответ при этом + * рапортует `weights_profile.source = "profile"`, то есть врёт (проверено на проде + * #2782: `profile_id=1` без `profile_user_id` → `tram_stop −0.5` вместо `−0.4` из + * профиля). Юзер бы видел ползунки профиля и score, посчитанный по другим весам. + * + * Когда пользователь неизвестен (dev без Caddy: /api/v1/me → 401) — шлём inline + * `weights`. Они всегда равны ползункам панели, так что расхождения нет. + */ +export function buildAnalyzeOptions( + weights: Record, + profileId: number | null, + profileUserId: string, +): AnalyzeOptions { + if (profileId != null && profileUserId) { + return { profileId, profileUserId }; + } + if (profileUserId) { + return { profileUserId, weights }; + } + return { weights }; +} + /** * Custom hook для analyze flow с graceful on-demand fetch fallback. * diff --git a/frontend/src/lib/api/weightProfiles.ts b/frontend/src/lib/api/weightProfiles.ts index f2619dfa..4aa28bd7 100644 --- a/frontend/src/lib/api/weightProfiles.ts +++ b/frontend/src/lib/api/weightProfiles.ts @@ -91,35 +91,37 @@ export const POI_WEIGHT_MAX = 3; const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles"; -function profilesHeaders(adminToken: string): HeadersInit { - return { "X-Admin-Token": adminToken }; -} +// Никакого X-Admin-Token: сервер перестал его читать в #437, а последний +// `verify_admin_token` удалён в #2775. Заголовок отправлялся ещё год после этого и +// ничего не решал — проверено живым запросом на проде (#2782): один и тот же 200 +// с корректным токеном, с мусорным и без заголовка вовсе. +// +// Реальная защита `/api/v1/admin/*` — два живых слоя, оба прод-проверены: +// 1) Caddy basic_auth на gendsgn.ru → без валидных кред 401 ещё на периметре +// (подставленный клиентом X-Authenticated-User туда же не проходит); +// 2) app/main.py rbac_guard → `role != "admin"` даёт 403 "admin only". // ── Hooks ───────────────────────────────────────────────────────────────────── /** List all weight profiles for a given user_id. */ -export function useWeightProfiles(userId: string, adminToken: string) { +export function useWeightProfiles(userId: string) { return useQuery({ queryKey: ["weight-profiles", userId], queryFn: () => apiFetch( `${BASE_PATH}?user_id=${encodeURIComponent(userId)}`, - { - headers: profilesHeaders(adminToken), - }, ), - enabled: !!userId && !!adminToken, + enabled: !!userId, }); } /** Create a new weight profile. Invalidates the list query on success. */ -export function useCreateProfile(adminToken: string) { +export function useCreateProfile() { const qc = useQueryClient(); return useMutation({ mutationFn: (payload) => apiFetch(BASE_PATH, { method: "POST", - headers: profilesHeaders(adminToken), body: JSON.stringify(payload), }), onSuccess: (_, variables) => { @@ -131,11 +133,7 @@ export function useCreateProfile(adminToken: string) { } /** Update an existing weight profile by id. */ -export function useUpdateProfile( - userId: string, - profileId: number, - adminToken: string, -) { +export function useUpdateProfile(userId: string, profileId: number) { const qc = useQueryClient(); return useMutation({ mutationFn: (payload) => @@ -143,7 +141,6 @@ export function useUpdateProfile( `${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`, { method: "PUT", - headers: profilesHeaders(adminToken), body: JSON.stringify(payload), }, ), @@ -154,16 +151,13 @@ export function useUpdateProfile( } /** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */ -export function useDeleteProfile(userId: string, adminToken: string) { +export function useDeleteProfile(userId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (profileId) => apiFetch( `${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`, - { - method: "DELETE", - headers: profilesHeaders(adminToken), - }, + { method: "DELETE" }, ), onSuccess: () => { void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] }); From e4680082ea2da3e8c4c43f2d0c416bb22b63aa79 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 7 Aug 2026 10:35:15 +0000 Subject: [PATCH 09/23] =?UTF-8?q?test(ci):=20=D1=80=D0=B0=D0=B7=D0=B2?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B8=20=C2=AB=D0=BD=D0=B0=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B7=D0=BA=D1=83=20=D0=BD=D0=B5=20=D1=81=D0=BE=D0=B7=D0=B4?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=C2=BB=20=D0=B8=20=C2=AB=D0=B7=D0=B0=D1=89?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=20=D1=81=D0=BB=D0=BE=D0=BC=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=C2=BB=20=D0=B2=20=D0=B4=D0=B2=D1=83=D1=85=20=D1=84=D0=BB=D0=B0?= =?UTF-8?q?=D0=BF=D0=B0=D1=8E=D1=89=D0=B8=D1=85=20=D0=B3=D0=B5=D0=B9=D1=82?= =?UTF-8?q?=D0=B0=D1=85=20(#2783)=20(#2789)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/services/test_weather_cache.py | 66 +++++++++++++++++--- tradein-mvp/backend/tests/test_auth_api.py | 30 +++++++-- 2 files changed, 80 insertions(+), 16 deletions(-) diff --git a/backend/tests/services/test_weather_cache.py b/backend/tests/services/test_weather_cache.py index f04c2322..c5cab6f1 100644 --- a/backend/tests/services/test_weather_cache.py +++ b/backend/tests/services/test_weather_cache.py @@ -10,8 +10,10 @@ DNS-fail повторяет timeout на каждый analyze. 3. ИЗОЛЯЦИЯ ДВУХ КЭШЕЙ: forecast-вызов не отравляет climate-кэш и наоборот (две раздельные таблицы внутри модуля). - 4. SINGLE-FLIGHT под конкурентностью: 16 потоков на ОДИН ключ при cold-start → - ровно ОДИН реальный httpx-вызов (lock + check-then-fetch-then-store). + 4. ШТОРМ НА COLD-START: 16 потоков на ОДИН ключ → сеть зовётся не больше раза на + поток, все получают одно и то же значение, и шторм заканчивается сложившимся + кэшем. Не «ровно один вызов»: single-flight'а тут нет и он снят сознательно + (#1370, см. сам тест). 5. ИСТЕЧЕНИЕ TTL: подменяем `weather_cache._now`, проталкиваем время за expires_at → следующий вызов идёт по сети заново (а не из устаревшего кэша). @@ -23,6 +25,7 @@ from __future__ import annotations import os import threading +import time from collections.abc import Iterator from typing import Any from unittest.mock import MagicMock, patch @@ -242,9 +245,37 @@ class TestSeparateCachesForForecastAndClimate: class TestConcurrencySafe: - def test_single_flight_cold_start_one_network_call(self) -> None: - """16 потоков на ОДИН ключ при cold-start → ровно один реальный httpx-вызов.""" - # GET имитирует медленный ответ, чтобы потоки реально гонялись за один lock. + def test_cold_start_storm_bounded_and_cache_converges(self) -> None: + """16 потоков на ОДИН ключ при cold-start: сеть зовут не больше раза на поток, + все получают одно и то же значение, и после шторма кэш отвечает без сети. + + ЗДЕСЬ СТОЯЛО `get_call_count == 1` («single-flight под lock'ом»), и это + было требование, которого код НЕ выполняет и выполнять не собирается: + сетевой вызов вынесен ЗА lock сознательно (#1370 — иначе все analyze + сериализуются на время httpx-вызова даже для разных координат), а рядом с + ним написано, что cold-start на один ключ «может породить несколько + параллельных запросов… приемлемо». Тест зеленел не потому, что защита + работает, а потому что при GIL первый поток обычно успевал сложить + результат раньше остальных. + + Замер 2026-08-07, 200 штормов подряд: при дефолтном + `sys.getswitchinterval()` 199 раз вышел 1 вызов и один раз 2 — те самые + ~0.5%, которыми гейт красил ЧУЖИЕ PR-ы (#2781: «ожидался 1 сетевой вызов, + было 2» в диффе про парсер КРТ). При `setswitchinterval(1e-6)`, когда + потоки реально чередуются, больше одного вызова дали 197 штормов из 200, + и в 173 из них вызовов было все 16. То есть утверждение ложно почти + всегда, когда гонка вообще случается, — чинить надо было тест. + + Менять КОД (per-key lock ради настоящего single-flight) сознательно НЕ + стали: поведение объявлено приемлемым в #1370 с обоснованием, лишние + запросы бывают только на cold-start одного ключа и они идемпотентны. + Понадобится — это отдельная задача с отдельным обоснованием, а не + побочный эффект правки теста. + + `time.sleep` в ответе делает гонку НЕслучайной: все 16 успевают пройти + промах кэша до первой записи. Так тест мерит худший случай той самой + уступки, а не везение планировщика. + """ start_barrier = threading.Barrier(16) get_call_count = 0 get_lock = threading.Lock() @@ -253,8 +284,7 @@ class TestConcurrencySafe: nonlocal get_call_count with get_lock: get_call_count += 1 - # Микро-задержка — окно для других потоков добраться до lock'а. - # Не делаем sleep большим, чтобы тест не висел. + time.sleep(0.05) # окно, в котором остальные потоки видят промах return _make_httpx_response(_make_forecast_response()) client_ctx = MagicMock() @@ -276,11 +306,27 @@ class TestConcurrencySafe: t.start() for t in threads: t.join() + storm_calls = get_call_count + # Шторм закончился — кэш обязан отвечать сам. Патч ещё активен, так что + # поход в сеть был бы виден счётчиком, а не отказом коннекта. + after_storm = weather_cache.get_weather_cached(56.84, 60.59) assert len(results) == 16 - assert all(r is not None for r in results) - # Single-flight под lock'ом + check-then-fetch — РОВНО один реальный вызов. - assert get_call_count == 1, f"ожидался 1 сетевой вызов, было {get_call_count}" + assert results[0] is not None + assert all(r == results[0] for r in results), "потоки увидели РАЗНЫЕ значения" + # Потолок — число участников: в сеть идут только промахнувшиеся, по разу + # каждый. Больше — значит кто-то фетчит повторно (retry-петля, потерянная + # запись в кэш); меньше единицы невозможно, кэш был пуст. + assert 1 <= storm_calls <= 16, f"сетевых вызовов {storm_calls} при 16 участниках" + # Ключ ОДИН на всех (last-write wins), и цена шторма платится один раз: + # следующий вызов идёт из кэша. Это и есть то, что #1370 обещает взамен + # снятого single-flight — без этого уступка превращается в дыру. + assert list(weather_cache._FORECAST_CACHE) == [weather_cache._round_key(56.84, 60.59)] + assert after_storm == results[0] + assert get_call_count == storm_calls, ( + f"после шторма кэш обязан отвечать без сети, а вызовов стало " + f"{get_call_count} против {storm_calls}" + ) # ────────────────────────────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_auth_api.py b/tradein-mvp/backend/tests/test_auth_api.py index 17b0930e..9cd7b5cd 100644 --- a/tradein-mvp/backend/tests/test_auth_api.py +++ b/tradein-mvp/backend/tests/test_auth_api.py @@ -839,13 +839,31 @@ async def test_login_flood_capped_by_rate_while_api_stays_responsive( len(probe_latencies) >= 10 ), f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят" - # 2. ТЕМП ограничен. Флуд предлагал тысячи попыток в секунду — до bcrypt их - # доехало не больше потолка (запас ×1.5 на планировщик). - assert len(codes) > connections, ( - "флуд не состоялся: на каждое соединение вышло не больше одного ответа — " - "мерить потолок не на чем" + # 2. ТЕМП ограничен. Флуд предлагал больше попыток в секунду, чем разрешает + # потолок — до bcrypt их доехало не больше него (запас ×1.5 на планировщик). + max_attempts_per_s = ceiling_per_s * 1.5 + offered_per_s = len(codes) / elapsed + + # ПРЕДУСЛОВИЕ, и оно отделено от вердикта намеренно: «нагрузку создать не + # удалось» и «потолок не работает» — разные новости, и красный обязан их + # различать. Порог здесь ТОТ ЖЕ, с которым сверяется вердикт ниже, и это не + # совпадение: пока флуд предлагает меньше, следующее утверждение зелено даже + # на системе вовсе без потолка, то есть измерения нет. + # + # Раньше условием было `len(codes) > connections` — «каждое соединение + # успело сходить хотя бы дважды за секунду». Это мерило скорости РАННЕРА, а + # не нагрузки: на занятом первый круг из ста запросов сам съедал всю секунду, + # и сторож краснел неотличимо от настоящей поломки потолка (замер 2026-08-07: + # свободная машина — 0 падений из 20, ~800 ответов/с; под load average ~185 — + # 10 падений из 15, и в каждом флуд всё равно предлагал 55-80 запросов/с + # против порога 30/с, то есть мерить было на чем). + assert offered_per_s > max_attempts_per_s, ( + f"нагрузку создать не удалось: флуд предложил {offered_per_s:.0f} запросов/с " + f"({len(codes)} за {elapsed:.2f}с) — не больше порога следующей проверки " + f"({max_attempts_per_s:.0f}/с), она прошла бы и без потолка. Это «раннер не " + f"потянул», НЕ «потолок сломан»" ) - assert attempts_per_s <= ceiling_per_s * 1.5, ( + assert attempts_per_s <= max_attempts_per_s, ( f"{attempts_per_s:.0f} сверок/с при потолке {ceiling_per_s:.0f}/с " f"({len(attempts)} за {elapsed:.2f}с) — потолок темпа не работает" ) From 29f10002289324bba1147e3a9a5c5614d42232ca Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 7 Aug 2026 11:01:31 +0000 Subject: [PATCH 10/23] =?UTF-8?q?revert(tradein/db):=20=D1=81=D0=BD=D1=8F?= =?UTF-8?q?=D1=82=D1=8C=20250=20=D1=81=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE?= =?UTF-8?q?=D1=8F=20=E2=80=94=20=D0=BE=D1=87=D0=B5=D1=80=D0=B5=D0=B4=D1=8C?= =?UTF-8?q?=20=D0=B7=D0=B0=20=D0=BB=D0=BE=D0=BA=D0=BE=D0=BC=20=D0=B1=D0=BB?= =?UTF-8?q?=D0=BE=D0=BA=D0=B8=D1=80=D1=83=D0=B5=D1=82=20=D0=BF=D0=B0=D0=B9?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D0=B9=D0=BD=20(#2792)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../250_drop_duplicate_expires_at_index.sql | 98 ------------------- 1 file changed, 98 deletions(-) delete mode 100644 tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql diff --git a/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql b/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql deleted file mode 100644 index b0866aaa..00000000 --- a/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql +++ /dev/null @@ -1,98 +0,0 @@ --- 250_drop_duplicate_expires_at_index.sql --- Issue #2752 — снос дубля индекса на trade_in_estimates(expires_at). --- --- WHY: --- 229_trade_in_estimates_consent_proof.sql (применена 2026-08-06 17:09) --- создала trade_in_estimates_expires_at_idx. Это ПОБАЙТОВЫЙ дубль --- trade_in_estimates_expires_idx из 001_trade_in_estimates.sql. --- --- Дословное сравнение на проде 2026-08-07 (pg_index, а не по имени): --- name indkey indclass indoption indcollation pred am --- trade_in_estimates_expires_idx 22 3127 0 0 — btree --- trade_in_estimates_expires_at_idx 22 3127 0 0 — btree --- Совпадает всё: колонка, класс операторов, направление сортировки, --- NULLS-порядок (indoption=0 → ASC/NULLS LAST у обоих), коллация, --- отсутствие частичного предиката, метод доступа. Ни один не привязан к --- ограничению (pg_constraint.conindid пуст для обоих), в pg_depend на них --- никто не ссылается — снос ничего не роняет по цепочке и НЕ требует --- CASCADE (важно: в этом продукте DROP ... CASCADE уже терял гранты --- FDW-пользователю). Гранты живут на таблице, не на индексе. --- --- ── Почему у «нулевого» дубля появились сканы ──────────────────────────────── --- В теле #2752 значилось «у нового 0 сканов». Через сутки у него 15, а у --- старого счётчик ЗАМОРОЖЕН на 234 (два замера, 09:14 и 09:18 UTC: старый --- +0, новый +4). То есть планировщик перевёл на новый ВЕСЬ живой трафик. --- --- Причина не семантическая, а физическая: индексы идентичны, но новый --- собран вчера с нуля и плотнее упакован — relpages 5 против 6 у старого, --- разъеденного месяцем UPDATE/DELETE. genericcostestimate() считает спуск --- по дереву от числа страниц, 5 < 6 → новый дешевле на доли единицы cost, --- и при прочих равных выигрывает. Никакого нового запроса не появилось: --- отношение idx_tup_read/idx_scan у обоих одного порядка (1.88 у старого, --- 0.93 у нового) — это один и тот же класс точечных lookup'ов, просто --- переехавший на более свежий индекс. Со временем новый забронзовеет так же --- и они поменялись бы местами обратно. --- --- ── ОПРОВЕРГНУТО: обоснование индекса в самой 229 ──────────────────────────── --- 229 завела индекс осознанно, с мотивировкой «обслуживает retention-задачу --- purge_expired_trade_in_data (migration 231) — без индекса batched-DELETE --- делал бы full scan». На проде это НЕ так. Фактический план боевого --- запроса из app/tasks/purge_expired_trade_in_data.py (EXPLAIN, прод --- 2026-08-07): --- Limit → Sort (Sort Key: expires_at) --- → Bitmap Heap Scan Filter: (expires_at < now()) --- → Bitmap Index Scan on idx_trade_in_estimates_created_by_created_at --- Index Cond: (created_by IS NULL) --- Задача purge ограничена `AND created_by IS NULL` (129 строк из 1061), и --- планировщик берёт именно этот, более селективный индекс, а expires_at --- остаётся Filter'ом. Ни один из двух expires-индексов в этом плане не --- участвует. Так что аргумента «оставить именно индекс из 229, он заведён --- под конкретный запрос» не существует — запрос его не использует. --- Поэтому оставлен индекс из 001: он объявлен в миграции, создающей саму --- таблицу, и на свежей БД (001..N по порядку) переживший индекс совпадёт с --- прод-состоянием, без «001 создаёт — 250 сносит» на каждой новой БД. --- --- ── Планы ДО и ПОСЛЕ ───────────────────────────────────────────────────────── --- Индексы побайтово идентичны, поэтому смена узла невозможна в принципе: --- меняется только имя индекса в строке плана и cost на одну страницу спуска. --- Проверено на чистом PostgreSQL 16.4 (та же минорная версия, что на проде) --- с воспроизведённым перекосом плотности: --- ДО: Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..31.84) --- ПОСЛЕ: Index Scan using trade_in_estimates_expires_idx (cost=0.28..38.30) --- Форма плана, Index Cond и Filter идентичны; отличается только имя. --- На проде разрыв плотности меньше (5 против 6 страниц, а не 5 против 8), --- то есть и дельта cost будет меньше синтетической. --- --- ── Стоимость блокировки ───────────────────────────────────────────────────── --- Обычный DROP INDEX берёт ACCESS EXCLUSIVE на таблицу. Здесь это дёшево: --- trade_in_estimates — 1061 строка, heap 1856 kB (231 страница), сам --- сносимый индекс 40 kB. DROP INDEX ничего не переписывает — это удаление --- строк каталога плюс unlink файла, единицы миллисекунд. CONCURRENTLY не --- нужен и был бы хуже: он не может выполняться внутри блока транзакции, а --- значит файл пришлось бы оставить без BEGIN/COMMIT (см. разбор механики --- раннера в 225_listing_source_snapshots_run_id_idx.sql). --- --- IDEMPOTENCY / SAFETY: --- - DROP INDEX IF EXISTS — безопасный re-run; без CASCADE. --- - Одна DDL-операция внутри BEGIN/COMMIT: либо применилась, либо нет. --- - COMMENT ON INDEX переносит знание из 229 на переживший индекс, чтобы --- дубль не завели заново (в т.ч. фиксирует, что purge его НЕ использует). --- --- Dependencies: 001_trade_in_estimates.sql (создаёт переживший индекс), --- 229_trade_in_estimates_consent_proof.sql (создала сносимый дубль). --- Deploy order: standalone. Ничего не ждёт и никого не блокирует. --- --- Критерий приёмки (записан ДО применения): --- после деплоя pg_stat_user_indexes.idx_scan у trade_in_estimates_expires_idx --- должен СДВИНУТЬСЯ с 234 в течение часа. Если он останется 234, значит --- трафик ушёл не на переживший индекс, а в Seq Scan — это опровергло бы --- разбор выше и требовало бы отката. - -BEGIN; - -DROP INDEX IF EXISTS trade_in_estimates_expires_at_idx; - -COMMENT ON INDEX trade_in_estimates_expires_idx IS - 'Единственный индекс на trade_in_estimates(expires_at) (001). НЕ заводить второй: 229 создала побайтовый дубль trade_in_estimates_expires_at_idx, снят миграцией 250 (#2752). Мотивировка 229 («под batched-DELETE в purge_expired_trade_in_data») на проде не подтвердилась: тот запрос сужен по created_by IS NULL и идёт через idx_trade_in_estimates_created_by_created_at, expires_at остаётся Filter''ом.'; - -COMMIT; From 482deb48646ec013fe6920d32a208f00b6196936 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 7 Aug 2026 11:21:28 +0000 Subject: [PATCH 11/23] =?UTF-8?q?fix(migrations):=20=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D1=80=D0=B5=D0=BF=D0=B8=D1=82=D1=8C=20lock=5Ftimeout=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D1=83=D1=8E?= =?UTF-8?q?=D1=89=D0=B5=D0=B3=D0=BE=20DDL=20=D0=B8=20=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BD=D0=B5=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D1=8B?= =?UTF-8?q?=20(#2791)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/sql.md | 32 ++++ .forgejo/workflows/ci.yml | 9 + .forgejo/workflows/deploy-tradein.yml | 29 +++ .forgejo/workflows/deploy.yml | 25 +++ scripts/check-migration-lock-timeout.py | 242 ++++++++++++++++++++++++ 5 files changed, 337 insertions(+) create mode 100644 scripts/check-migration-lock-timeout.py diff --git a/.claude/rules/sql.md b/.claude/rules/sql.md index f221b4ef..136bc01a 100644 --- a/.claude/rules/sql.md +++ b/.claude/rules/sql.md @@ -16,11 +16,43 @@ paths: -- Контекст: что делает файл, зачем, порядок применения, dependencies. BEGIN; +SET LOCAL lock_timeout = '5s'; -- если ниже есть блокирующий DDL, см. § lock_timeout + -- DDL здесь (idempotent) COMMIT; ``` +## lock_timeout при блокирующем DDL (обязательно) + +Любой `ALTER TABLE` / `DROP INDEX` / `CREATE INDEX` (без `CONCURRENTLY`) / +`REFRESH MATERIALIZED VIEW` / `TRUNCATE` обязан нести `SET LOCAL lock_timeout = '5s';` +сразу после `BEGIN`. Гейт: `scripts/check-migration-lock-timeout.py` (бежит в `ci.yml` +на каждом PR) — проверяет и наличие, и место (внутри транзакции, ДО первого DDL). + +**Почему.** Дорого не удержание лока, а ожидание его выдачи. 2026-08-07 `DROP INDEX` +на таблице в 1061 строку ждал ACCESS EXCLUSIVE 29 минут за чужой аналитической +psql-сессией. Ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами → за +ним начинают ждать обычные SELECT приложения. `lock_timeout` ограничивает только +ожидание, на работу под локом не влияет. Срабатывание = красный деплой (честный +отказ, повторить позже) вместо тихой очереди перед приложением. + +**Значение 5 s:** снизу ограничено `deadlock_timeout` (1 s на проде) — автоотмена +мешающего autovacuum срабатывает только после того, как ждущий отстоял эту секунду, +поэтому 1-2 s гонялись бы с рутинным autovacuum. Сверху — столько максимум простоит +очередь запросов приложения. + +**`CONCURRENTLY`-формы — НАОБОРОТ, без lock_timeout** (и гейт их не требует): +`CREATE INDEX CONCURRENTLY` ждёт завершения параллельных транзакций через +VirtualXactLock, это ожидание тоже под `lock_timeout`, и таймаут обрывает построение, +оставляя невалидный индекс. По той же причине НЕ задавать `lock_timeout` глобально +в раннере. И только `SET LOCAL`, не голый `SET`: голый доживёт до конца сессии и +обрежет `CONCURRENTLY` ниже по файлу. + +Невалидные индексы (след оборванного CIC) ловит проверка после цикла миграций в +`deploy.yml` / `deploy-tradein.yml`: re-run миграции их НЕ чинит — `CREATE INDEX +CONCURRENTLY IF NOT EXISTS` тихо пропускает битый индекс как существующий. + ## Idempotency (обязательно) - `CREATE TABLE IF NOT EXISTS` diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 0719e2a3..53e1d59b 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -65,6 +65,15 @@ jobs: python3 scripts/check-workflow-ports.py --selftest python3 scripts/check-workflow-ports.py + - name: "Guard: блокирующий DDL без lock_timeout (#2752)" + # Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR, + # включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow — + # фильтруется только job backend-tests). Это важно: миграции лежат в ДВУХ + # каталогах, и гейт, видимый лишь одному лэйну, пропускал бы половину. + run: | + python3 scripts/check-migration-lock-timeout.py --selftest + python3 scripts/check-migration-lock-timeout.py + - uses: dorny/paths-filter@v3 id: filter with: diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index b93af07c..ee062b18 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -452,6 +452,35 @@ jobs: done echo "All migrations applied." + # (3b) Невалидные индексы после цикла (#2752). Оборванный + # CREATE INDEX CONCURRENTLY оставляет индекс с indisvalid=false: + # планировщик им НЕ пользуется (проверено — Seq Scan), а поддержка + # на записи всё равно платится. Молчит это так (воспроизведено на + # PostgreSQL 16.4): CIC упал → деплой красный, миграция не помечена + # применённой → следующий деплой прогоняет её заново → `CREATE INDEX + # CONCURRENTLY IF NOT EXISTS` видит битый индекс, печатает + # «relation already exists, skipping», выходит с кодом 0 → миграция + # помечается применённой, а индекс остаётся невалидным навсегда. + # Поэтому проверка не в каждом файле DO-блоком, а одна здесь: она + # ловит и этот путь, и невалидные индексы любого другого + # происхождения (отменённый job, ручной CIC оператором). + # На 2026-08-07 на проде таких индексов 0 — это профилактика. + invalid_idx=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ + psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \ + "SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \ + | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \ + || { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; } + if [ -n "$invalid_idx" ]; then + echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx" + echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой" + echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит" + echo " (тихо пропускает как существующий). Новый app-код НЕ поднят." + echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем" + echo " пересоздать индекс и повторить деплой." + exit 1 + fi + echo "✓ невалидных индексов нет." + # Bootstrap gendesign_reader password from env (post-migration, #976). # SQL migration 101_gendesign_reader_role.sql creates role passwordless; # password lives only in /opt/gendesign/tradein-mvp/.env.runtime. diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 7303d4cf..f267444a 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -309,6 +309,31 @@ jobs: done echo "All migrations applied." + # Невалидные индексы после цикла (#2752). Оборванный CREATE INDEX + # CONCURRENTLY оставляет индекс с indisvalid=false: планировщик им НЕ + # пользуется, а re-run миграции его не чинит — `CREATE INDEX + # CONCURRENTLY IF NOT EXISTS` печатает «relation already exists, + # skipping» и выходит с кодом 0, после чего миграция помечается + # применённой, а индекс остаётся битым навсегда (воспроизведено на + # PostgreSQL 16.4). В data/sql 5 файлов с CREATE INDEX CONCURRENTLY. + # Одна проверка здесь вместо DO-блока в каждом файле; на 2026-08-07 + # на проде таких индексов 0 — это профилактика. + invalid_idx=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \ + "SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \ + | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \ + || { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; } + if [ -n "$invalid_idx" ]; then + echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx" + echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой" + echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит" + echo " (тихо пропускает как существующий). Новый app-код НЕ поднят." + echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем" + echo " пересоздать индекс и повторить деплой." + exit 1 + fi + echo "✓ невалидных индексов нет." + # Set tradein_fdw_reader password from env (post-migration bootstrap). # SQL migration 100_tradein_fdw_role.sql creates role passwordless; # password lives only in /opt/gendesign/backend/.env.runtime. diff --git a/scripts/check-migration-lock-timeout.py b/scripts/check-migration-lock-timeout.py new file mode 100644 index 00000000..92b1e3ff --- /dev/null +++ b/scripts/check-migration-lock-timeout.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Гейт: новая миграция с блокирующим DDL обязана нести `SET LOCAL lock_timeout` (#2752). + +ПОЧЕМУ. 2026-08-07 миграция 250 (`DROP INDEX` на таблице в 1061 строку) встала +на боевой БД: сам DROP берёт лок за миллисекунды, но ЖДАЛ его выдачи 29 минут за +чужой аналитической psql-сессией; вторая попытка деплоя — ещё 16 минут. Опасность +не в простое деплоя: ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми +запросами, поэтому обычный SELECT приложения по той же таблице начинает ждать за +ним. В тот раз обошлось, но `lock_timeout` не стоял НИ В ОДНОЙ миграции обоих +data/sql — то есть следующий блокирующий DDL повторил бы это. + +`SET LOCAL` ограничивает ТОЛЬКО ожидание лока, не работу под ним: длинный +CREATE INDEX он не оборвёт, а очередь — не соберёт. Срабатывание = красный деплой +(ON_ERROR_STOP=on) вместо тихой очереди перед приложением. + +ПОЧЕМУ НЕ ОДНИМ `lock_timeout` В РАННЕРЕ (проверено, а не предположено). Вариант +«задать один раз перед циклом миграций» отвергнут замером на PostgreSQL 16.4: +`PGOPTIONS="-c lock_timeout=5s"` действительно доезжает до сервера (`SHOW +lock_timeout` → 5s), но session-wide значение ОБРЫВАЕТ `CREATE INDEX +CONCURRENTLY` — тот ждёт завершения параллельных транзакций через VirtualXactLock, +и это ожидание тоже под lock_timeout. В замере CIC упал через 5 s, когда встречная +сессия просто держала открытую транзакцию (ACCESS SHARE — с CIC вообще не +конфликтует), и ОСТАВИЛ невалидный индекс. То есть runner-wide значение +изготавливало бы ровно ту аварию, от которой заведена проверка невалидных +индексов в deploy-workflow'ах. Блокирующий DDL и CONCURRENTLY хотят +противоположной политики, поэтому granularity — файл, а не раннер. + +ЧТО ТРЕБУЕТСЯ ОТ ФАЙЛА: `SET LOCAL` (не голый `SET`: голый доживёт до конца +сессии и обрежет CIC в том же файле), ПОСЛЕ `BEGIN` (вне транзакции `SET LOCAL` +молча ничего не делает, только WARNING) и ДО первого блокирующего стейтмента. + +ГРАНДФАЗЕРИНГ: миграции ниже порога уже применены на проде, а применённые файлы +задним числом не переписываются. Гейт смотрит только вперёд. + +Запуск: python3 scripts/check-migration-lock-timeout.py [--selftest] +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +# каталог миграций -> минимальный NN, с которого правило обязательно. +# data/sql: последняя на 2026-08-07 — 188_*; tradein: 250_* (та самая). +SQL_DIRS: dict[str, int] = { + "data/sql": 189, + "tradein-mvp/backend/data/sql": 250, +} + +# DDL, берущий лок, который конфликтует с трафиком приложения (ACCESS EXCLUSIVE, +# у CREATE INDEX / REFRESH MV — SHARE / ACCESS EXCLUSIVE). Всё это может встать +# в очередь и увести за собой запросы приложения. +BLOCKING = re.compile( + r"\b(?:" + r"ALTER\s+TABLE|ALTER\s+MATERIALIZED\s+VIEW|" + r"DROP\s+INDEX|CREATE\s+(?:UNIQUE\s+)?INDEX|REINDEX|" + r"DROP\s+(?:MATERIALIZED\s+)?VIEW|REFRESH\s+MATERIALIZED\s+VIEW|" + r"DROP\s+TABLE|TRUNCATE|CLUSTER|VACUUM\s+FULL" + r")\b", + re.IGNORECASE, +) +# CONCURRENTLY-форма НЕ требует lock_timeout и не терпит его (см. шапку). +# Исключение по-стейтментно, не по-файлово: файл с CIC И с ALTER TABLE +# по-прежнему обязан прикрыть свой ALTER. +CONCURRENTLY = re.compile(r"\bCONCURRENTLY\b", re.IGNORECASE) + +BEGIN_STMT = re.compile(r"^\s*(?:BEGIN|START\s+TRANSACTION)\b", re.IGNORECASE) +SET_LOCAL_LT = re.compile(r"^\s*SET\s+LOCAL\s+lock_timeout\b", re.IGNORECASE) +SET_BARE_LT = re.compile(r"^\s*SET\s+(?!LOCAL\b)(?:SESSION\s+)?lock_timeout\b", re.IGNORECASE) +NN_PREFIX = re.compile(r"^(\d+)") + + +def strip_noise(sql: str) -> str: + """Убирает `--` и `/* */` комментарии, а тела строковых литералов заменяет на + пробелы (сохраняя длину и переводы строк — номера строк не съезжают). + + Дословный текст литералов не нужен, а вреден: в COMMENT ON ... IS '...' + легко встречается слово ALTER TABLE, и без затирания гейт ловил бы прозу. + Тела $$...$$ (DO-блоки) НЕ затираются — там живёт исполняемый DDL. + """ + out: list[str] = [] + i, n = 0, len(sql) + while i < n: + ch = sql[i] + nxt = sql[i + 1] if i + 1 < n else "" + if ch == "-" and nxt == "-": + while i < n and sql[i] != "\n": + out.append(" ") + i += 1 + elif ch == "/" and nxt == "*": + depth = 1 # в PostgreSQL блочные комментарии вложенные + out.append(" ") + i += 2 + while i < n and depth: + if sql[i] == "/" and i + 1 < n and sql[i + 1] == "*": + depth += 1 + out.append(" ") + i += 2 + elif sql[i] == "*" and i + 1 < n and sql[i + 1] == "/": + depth -= 1 + out.append(" ") + i += 2 + else: + out.append("\n" if sql[i] == "\n" else " ") + i += 1 + elif ch == "'": + out.append("'") + i += 1 + while i < n: + if sql[i] == "'" and i + 1 < n and sql[i + 1] == "'": + out.append(" ") + i += 2 + continue + if sql[i] == "'": + break + out.append("\n" if sql[i] == "\n" else " ") + i += 1 + if i < n: + out.append("'") + i += 1 + else: + out.append(ch) + i += 1 + return "".join(out) + + +def scan(sql: str) -> list[str]: + """-> список претензий к файлу; пустой список = файл в порядке.""" + clean = strip_noise(sql) + statements = clean.split(";") + + first_blocking: int | None = None + blocking_text = "" + for idx, stmt in enumerate(statements): + if BLOCKING.search(stmt) and not CONCURRENTLY.search(stmt): + first_blocking = idx + blocking_text = " ".join(stmt.split())[:80] + break + if first_blocking is None: + return [] + + set_local = next((i for i, s in enumerate(statements) if SET_LOCAL_LT.search(s)), None) + if set_local is None: + if any(SET_BARE_LT.search(s) for s in statements): + return [ + f"`SET lock_timeout` без LOCAL при блокирующем DDL ({blocking_text}). " + "Голый SET живёт до конца сессии и обрежет CREATE INDEX CONCURRENTLY " + "в этом же файле. Нужен `SET LOCAL lock_timeout = '5s';` внутри BEGIN." + ] + return [ + f"блокирующий DDL без lock_timeout ({blocking_text}). Добавь первой " + "строкой после BEGIN: `SET LOCAL lock_timeout = '5s';` — иначе DDL встанет " + "в очередь за чужой сессией и уведёт за собой запросы приложения (#2752)." + ] + + problems: list[str] = [] + if not any(BEGIN_STMT.search(s) for s in statements[:set_local]): + problems.append( + "`SET LOCAL lock_timeout` стоит ВНЕ транзакции (нет BEGIN выше). " + "Вне блока транзакции SET LOCAL молча ничего не делает (только WARNING)." + ) + if set_local > first_blocking: + problems.append( + f"`SET LOCAL lock_timeout` стоит ПОСЛЕ блокирующего DDL ({blocking_text}) — " + "к моменту DDL он ещё не действует. Подними его сразу под BEGIN." + ) + return problems + + +def selftest() -> None: + ok = "BEGIN;\nSET LOCAL lock_timeout = '5s';\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n" + assert scan(ok) == [], scan(ok) + + # красное: ровно случай 250 до фикса + bad = "BEGIN;\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n" + assert len(scan(bad)) == 1 and "без lock_timeout" in scan(bad)[0] + assert scan("BEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n") + assert scan("BEGIN;\nALTER TABLE t ADD CONSTRAINT c CHECK (x > 0);\nCOMMIT;\n") + assert scan("BEGIN;\nALTER TABLE t DROP COLUMN IF EXISTS x;\nCOMMIT;\n") + assert scan("BEGIN;\nCREATE INDEX IF NOT EXISTS i ON t (c);\nCOMMIT;\n") + + # красное: правильная строка, но в местах, где она не действует + assert "ВНЕ транзакции" in scan("SET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\n")[0] + late = "BEGIN;\nALTER TABLE t ADD COLUMN x int;\nSET LOCAL lock_timeout='5s';\nCOMMIT;\n" + assert any("ПОСЛЕ блокирующего DDL" in p for p in scan(late)) + bare = "BEGIN;\nSET lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n" + assert "без LOCAL" in scan(bare)[0] + + # зелёное: CONCURRENTLY-формы, им lock_timeout вреден (обрывает CIC) + assert scan("CREATE INDEX CONCURRENTLY IF NOT EXISTS i ON t (c);\n") == [] + assert scan("DROP INDEX CONCURRENTLY IF EXISTS i;\n") == [] + assert scan("REFRESH MATERIALIZED VIEW CONCURRENTLY mv;\n") == [] + # ...но CONCURRENTLY в файле не прощает соседний блокирующий DDL + mixed = "CREATE INDEX CONCURRENTLY i ON t (c);\nBEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n" + assert scan(mixed), "CONCURRENTLY не должен амнистировать ALTER TABLE в том же файле" + mixed_ok = ( + "CREATE INDEX CONCURRENTLY i ON t (c);\n" + "BEGIN;\nSET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n" + ) + assert scan(mixed_ok) == [], scan(mixed_ok) + + # зелёное: DDL, которого нет — он в комментарии или в строковом литерале + assert scan("-- ALTER TABLE t ADD COLUMN x int;\nSELECT 1;\n") == [] + assert scan("/* DROP INDEX foo; */\nSELECT 1;\n") == [] + assert scan("/* /* вложенный */ ALTER TABLE t ADD COLUMN x int; */\nSELECT 1;\n") == [] + assert scan("COMMENT ON INDEX i IS 'не заводить второй: ALTER TABLE тут проза';\n") == [] + assert scan("COMMENT ON INDEX i IS 'кавычка внутри '' и DROP INDEX проза';\n") == [] + # зелёное: не-DDL миграции (backfill/seed) правила не касаются + assert scan("BEGIN;\nUPDATE t SET x = 1 WHERE x IS NULL;\nCOMMIT;\n") == [] + assert scan("BEGIN;\nINSERT INTO t (x) VALUES (1) ON CONFLICT DO NOTHING;\nCOMMIT;\n") == [] + print("selftest OK") + + +def main() -> int: + if "--selftest" in sys.argv: + selftest() + return 0 + + failed = False + checked = 0 + for dirname, min_nn in SQL_DIRS.items(): + sql_dir = Path(dirname) + if not sql_dir.is_dir(): + print(f"::error::{sql_dir} не найден — запускать из корня репозитория") + return 1 + for path in sorted(sql_dir.glob("*.sql")): + m = NN_PREFIX.match(path.name) + if not m or int(m.group(1)) < min_nn: + continue # применено на проде до внедрения гейта — не переписываем + checked += 1 + for problem in scan(path.read_text(encoding="utf-8")): + failed = True + print(f"::error file={path}::{problem}") + if failed: + return 1 + print(f"✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: {checked})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From e6591a450a829f94924bf85a3fa6c541ff10a9c8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 7 Aug 2026 15:31:01 +0300 Subject: [PATCH 12/23] =?UTF-8?q?fix(tradein/payments):=20=D0=BC=D0=B8?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20234=20=E2=86=92=20240=20?= =?UTF-8?q?=E2=80=94=20=D0=BD=D0=BE=D0=BC=D0=B5=D1=80=20=D1=81=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=20=D0=B7=D0=B0=D0=BD=D1=8F=D1=82=20=D0=BD=D0=B0=20?= =?UTF-8?q?main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main уехал вперёд за сутки: 234 занял 234_scrape_runs_ban_kind_unknown.sql (0de22f4b), максимум на main сейчас 239 (235-237 — дыры). max+1=240 безопаснее дыр; ни один открытый PR номер 235-240 не занимает (сверено по forgejo/main и всем открытым веткам). Переименован файл + обновлены все 7 упоминаний "migration 234" (_manifest_applied.txt, config.py, schemas/trade_in.py, purge_expired_trade_in_data.py, test_estimate_idor.py, content.ts, types/trade-in.ts) — правки текстовые, ни один тест не читает миграцию по имени файла. --- tradein-mvp/backend/app/core/config.py | 4 ++-- tradein-mvp/backend/app/schemas/trade_in.py | 2 +- .../backend/app/tasks/purge_expired_trade_in_data.py | 2 +- ...l.sql => 240_trade_in_estimates_retain_until.sql} | 12 ++++++++---- tradein-mvp/backend/data/sql/_manifest_applied.txt | 2 +- tradein-mvp/backend/tests/test_estimate_idor.py | 2 +- tradein-mvp/frontend/src/app/mera-public/content.ts | 2 +- tradein-mvp/frontend/src/types/trade-in.ts | 2 +- 8 files changed, 16 insertions(+), 12 deletions(-) rename tradein-mvp/backend/data/sql/{234_trade_in_estimates_retain_until.sql => 240_trade_in_estimates_retain_until.sql} (87%) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index eb70c787..b0f5b791 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -836,11 +836,11 @@ class Settings(BaseSettings): # срок — решение DPO/юриста, не инженера). ENV: TRADE_IN_LEAD_RETENTION_DAYS. trade_in_lead_retention_days: int = 180 - # ── Платный отчёт живёт год (retain_until, migration 234, PR #2754) ───── + # ── Платный отчёт живёт год (retain_until, migration 240, PR #2754) ───── # trade_in_estimates.retain_until TTL (дни ОТ ОПЛАТЫ) — срок жизни ССЫЛКИ/ # СТРОКИ для оплаченной оценки, независимый от expires_at (актуальность # расчёта, 24ч, глобальный для ВСЕХ строк). НЕ трогает expires_at — см. - # migration 234 докстринг. Отдельная колонка, а не подъём expires_at: + # migration 240 докстринг. Отдельная колонка, а не подъём expires_at: # expires_at печатается в PDF/UI как «актуальность расчёта» и одинаков # для всех строк, поднять его до года = соврать в документе клиента про # свежесть цифры + нарушить минимизацию ПДн для неоплаченных B2C-адресов. diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index c7620ece..4f3d4996 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -197,7 +197,7 @@ class AggregatedEstimate(BaseModel): actual_deals: list[AnalogLot] # реальные продажи last 12 mo expires_at: datetime # PR-D1: срок жизни ССЫЛКИ/СТРОКИ (оплаченный доступ), НЕ актуальности - # расчёта — тот остаётся expires_at (не путать, см. migration 234). + # расчёта — тот остаётся expires_at (не путать, см. migration 240). # NULL = неоплачено (весь текущий трафик, B2B pilots включительно). retain_until: datetime | None = None # ── Дополнительные метаданные ── diff --git a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py index c48e8dba..4dcda362 100644 --- a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py +++ b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py @@ -99,7 +99,7 @@ _DEFAULT_MAX_BATCHES = 20 # означало бы одновременно нарушить минимизацию ПДн по 152-ФЗ и соврать в # документе клиента про срок актуальности цифры: # 1. `retain_until IS NULL` — именно IS NULL, НЕ `< NOW()`. Оплаченная -# строка (retain_until IS NOT NULL, migration 234) не удаляется джобой +# строка (retain_until IS NOT NULL, migration 240) не удаляется джобой # В ПРИНЦИПЕ, пока не поднято ослабление отдельным PR не раньше чем # через год после первой продажи. `retain_until` ставится сервисным # кодом платёжного контура (ещё не существует в этом PR) на now() + diff --git a/tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql b/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql similarity index 87% rename from tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql rename to tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql index 912eb915..93693455 100644 --- a/tradein-mvp/backend/data/sql/234_trade_in_estimates_retain_until.sql +++ b/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql @@ -1,10 +1,14 @@ --- 234_trade_in_estimates_retain_until.sql +-- 240_trade_in_estimates_retain_until.sql -- Платёжный контур МЕРЫ, ретеншен (PR #2754): «оплаченное живёт год, purge -- его не трогает». Владелец продаёт отчёт физлицу за 150 ₽ — отчёт должен -- жить год на нашей стороне, а не 24ч (см. WHY ниже). --- Номер сверен и по `forgejo/main`, и по всем открытым PR-веткам на момент --- написания (последняя занятая — 233_payments.sql) — см. урок в шапке того --- же файла про то, как коллизия 228/229/231/232 обнаруживается поздно. +-- Номер сверен по `forgejo/main` и всем открытым PR-веткам ДВАЖДЫ: сначала +-- как 234 (последняя занятая на момент ветвления была 233_payments.sql), но +-- main уехал вперёд и 234 занял `234_scrape_runs_ban_kind_unknown.sql` +-- (коммит 0de22f4b) — переименовано в 240 (main max на момент повторной +-- сверки — 239, с дырами 235-237; max+1 безопаснее дыр). Урок пятый за +-- сутки: сверять номер нужно не только перед первым коммитом, а прямо перед +-- пушем/мержем — main не стоит на месте. -- -- ── WHY ────────────────────────────────────────────────────────────────────── -- purge_expired_trade_in_data (migration 231, seeded enabled=false) удаляет diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 27b06836..72981f2c 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -231,4 +231,4 @@ # поддержки, #2532/#2533) откладывались до подтверждения, что они осели на # проде в финальном виде. Они в _schema_migrations — условие выполнено. 233_payments.sql -234_trade_in_estimates_retain_until.sql +240_trade_in_estimates_retain_until.sql diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index 7cb0e735..a9ed8d31 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -54,7 +54,7 @@ def trade_in_app() -> FastAPI: def _make_estimate_row(created_by: str | None, retain_until: object = None) -> SimpleNamespace: """A trade_in_estimates row with the full column set the endpoints read. - retain_until defaults to None (PR-D1, migration 234) -- unpaid, matches every + retain_until defaults to None (PR-D1, migration 240) -- unpaid, matches every row that existed before that migration; explicit param lets retention-gate tests (see test_estimate_retention_gate.py) construct a paid row. """ diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts index 66772877..5e5c2937 100644 --- a/tradein-mvp/frontend/src/app/mera-public/content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/content.ts @@ -94,7 +94,7 @@ export const PRIVACY_PATH = "/mera-public/privacy"; /** * Сколько месяцев на нашей стороне хранится ссылка/строка оплаченного отчёта - * после оплаты (`trade_in_estimates.retain_until`, migration 234) — НЕ срок + * после оплаты (`trade_in_estimates.retain_until`, migration 240) — НЕ срок * действия самого расчёта (тот отдельный, `expires_at`, часы). * * ЕДИНСТВЕННОЕ место, где это число хардкодится на фронте — любой другой diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index ce3282bd..94f1dea2 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -172,7 +172,7 @@ export interface AggregatedEstimate { analogs: AnalogLot[]; // top 5-10 actual_deals: AnalogLot[]; // last 12 mo expires_at: string; // ISO datetime - // PR-D1: срок жизни ссылки/строки (оплаченный доступ, backend migration 234), + // PR-D1: срок жизни ссылки/строки (оплаченный доступ, backend migration 240), // НЕ путать с expires_at (актуальность расчёта). null = неоплачено (текущий // трафик целиком). retain_until?: string | null; // ISO datetime From 5ce95a28a864a2327df83d5f985636c09fbf365b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 7 Aug 2026 15:59:28 +0300 Subject: [PATCH 13/23] =?UTF-8?q?fix(tradein/payments):=20SET=20LOCAL=20lo?= =?UTF-8?q?ck=5Ftimeout=20=D0=B2=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20240=20(gate=20threshold=20=E2=80=94=20=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=B5=D1=84=D0=B0=D0=BA=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-migration-lock-timeout.py требует lock_timeout только для NN >= 250 в tradein — порог назначен по номеру аварийной миграции 250, которую затем сняли с деплоя (#2792). Фактический максимум применённого на main — 239, то есть весь диапазон 240-249 гейтом не проверяется вообще ("проверено новых миграций: 0" = не проверено ни одного файла, не "все чисты"). Функция scan() из самого гейта, прогнанная напрямую без порогового отсечения, помечает ALTER TABLE в этом файле как блокирующий DDL без lock_timeout. trade_in_estimates — самая горячая таблица стека (история, история сотрудников, каждое чтение/PDF оценки); на этой БД уже наблюдались открытые транзакции на 46 и 22 часа. Ждущая ACCESS EXCLUSIVE-блокировка встаёт в очередь перед новыми запросами приложения. DDL на 1058 строках мгновенный — риск не в исполнении, а в ожидании чужой блокировки. Добавлено SET LOCAL lock_timeout = '5s' сразу после BEGIN + объяснение в шапке файла, почему оно здесь при том что гейт формально не требует — чтобы не убрали как "лишнее". Порог гейта не трогаю — отдельный issue. --- .../240_trade_in_estimates_retain_until.sql | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql b/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql index 93693455..e9b0cb67 100644 --- a/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql +++ b/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql @@ -48,9 +48,29 @@ -- строки со строкой в payments опосредованно через predicate purge-джобы, -- сама таблица payments здесь не читается). -- Apply after: 233_payments.sql. +-- +-- ── lock_timeout — выставлен, хотя гейт (scripts/check-migration-lock-timeout.py) +-- этот файл не проверяет ──────────────────────────────────────────────────── +-- Порог гейта для tradein (NN >= 250) — артефакт: назначен по номеру аварийной +-- миграции 250, которую затем сняли с деплоя (#2792, 29f10002). Фактический +-- максимум применённого на main — 239, то есть НИ ОДНА миграция в диапазоне +-- 240-249 (этот файл включительно) гейтом не проверяется вообще — "проверено +-- новых миграций: 0" в логе означает "не проверено ни одного файла", а не +-- "все чисты". Сама функция scan() внутри гейта, если прогнать её без +-- порогового отсечения, помечает ALTER TABLE ниже как блокирующий DDL без +-- lock_timeout. `trade_in_estimates` — самая горячая таблица стека (история, +-- история сотрудников, каждое чтение/PDF оценки); на этой БД уже наблюдались +-- открытые транзакции на 46 и 22 часа. Ждущая ACCESS EXCLUSIVE-блокировка +-- встаёт в очередь ПЕРЕД новыми запросами — за ней начинают ждать обычные +-- SELECT приложения (см. `sql.md` § lock_timeout). На 1058 строках сам DDL +-- мгновенный — риск не в исполнении, а в ожидании чужой блокировки. Красный +-- деплой по таймауту — осознанно принятый в проекте размен (честный отказ +-- лучше тихой очереди перед приложением). НЕ убирать как "гейт же не просит". BEGIN; +SET LOCAL lock_timeout = '5s'; + ALTER TABLE trade_in_estimates ADD COLUMN IF NOT EXISTS retain_until timestamptz; From 45924021a7fd74cf27aae5ad66cf82aa562bf4b0 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 9 Aug 2026 17:10:18 +0000 Subject: [PATCH 14/23] =?UTF-8?q?chore(tradein/db):=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D1=83=D1=82=D1=8C=20=D1=81=D0=BD=D0=BE=D1=81=20=D0=B4?= =?UTF-8?q?=D1=83=D0=B1=D0=BB=D1=8F=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=D0=B0=20expires=5Fat=20=E2=80=94=20=D1=82=D0=B5=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D1=8C=20=D1=81=20lock=5Ftimeout=20(#2795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../250_drop_duplicate_expires_at_index.sql | 140 ++++++++++++++++++ .../backend/data/sql/_manifest_applied.txt | 1 + 2 files changed, 141 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql diff --git a/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql b/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql new file mode 100644 index 00000000..d725963d --- /dev/null +++ b/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql @@ -0,0 +1,140 @@ +-- 250_drop_duplicate_expires_at_index.sql +-- Issue #2752 — снос дубля индекса на trade_in_estimates(expires_at). +-- Возврат после #2792 (снятие с деплоя) — теперь с lock_timeout, см. #2793/#2791. +-- +-- WHY: +-- 229_trade_in_estimates_consent_proof.sql (применена 2026-08-06 17:09) +-- создала trade_in_estimates_expires_at_idx. Это ПОБАЙТОВЫЙ дубль +-- trade_in_estimates_expires_idx из 001_trade_in_estimates.sql. +-- +-- Дословное сравнение на проде 2026-08-07 (pg_index, а не по имени): +-- name indkey indclass indoption indcollation pred am +-- trade_in_estimates_expires_idx 22 3127 0 0 — btree +-- trade_in_estimates_expires_at_idx 22 3127 0 0 — btree +-- Совпадает всё: колонка, класс операторов, направление сортировки, +-- NULLS-порядок (indoption=0 → ASC/NULLS LAST у обоих), коллация, +-- отсутствие частичного предиката, метод доступа. Ни один не привязан к +-- ограничению (pg_constraint.conindid пуст для обоих), в pg_depend на них +-- никто не ссылается — снос ничего не роняет по цепочке и НЕ требует +-- CASCADE (важно: в этом продукте DROP ... CASCADE уже терял гранты +-- FDW-пользователю). Гранты живут на таблице, не на индексе. +-- +-- ── Почему у «нулевого» дубля появились сканы ──────────────────────────────── +-- В теле #2752 значилось «у нового 0 сканов». Через сутки у него 15, а у +-- старого счётчик ЗАМОРОЖЕН на 234 (два замера, 09:14 и 09:18 UTC: старый +-- +0, новый +4). Замер 2026-08-09 16:54 UTC подтверждает картину ещё через +-- двое суток: новый 21, старый ВСЁ ЕЩЁ 234. То есть планировщик перевёл на +-- новый весь живой трафик, и это устойчивое состояние, а не переходное. +-- +-- Причина не семантическая, а физическая: индексы идентичны, но новый +-- собран позже с нуля и плотнее упакован — relpages 5 против 6 у старого, +-- разъеденного месяцем UPDATE/DELETE. genericcostestimate() считает спуск +-- по дереву от числа страниц, 5 < 6 → новый дешевле на доли единицы cost, +-- и при прочих равных выигрывает. Никакого нового запроса не появилось: +-- отношение idx_tup_read/idx_scan у обоих одного порядка (1.88 у старого, +-- 0.95 у нового) — это один и тот же класс точечных lookup'ов, просто +-- переехавший на более свежий индекс. Со временем новый забронзовеет так же +-- и они поменялись бы местами обратно. +-- +-- ── ОПРОВЕРГНУТО: обоснование индекса в самой 229 ──────────────────────────── +-- 229 завела индекс осознанно, с мотивировкой «обслуживает retention-задачу +-- purge_expired_trade_in_data (migration 231) — без индекса batched-DELETE +-- делал бы full scan». На проде это НЕ так. Фактический план боевого +-- запроса из app/tasks/purge_expired_trade_in_data.py (EXPLAIN, прод +-- 2026-08-07, перепроверено 2026-08-09 — план тот же): +-- Limit → Sort (Sort Key: expires_at) +-- → Bitmap Heap Scan Filter: (expires_at < now()) +-- → Bitmap Index Scan on idx_trade_in_estimates_created_by_created_at +-- Index Cond: (created_by IS NULL) +-- Задача purge ограничена `AND created_by IS NULL` (134 строки из 1061), и +-- планировщик берёт именно этот, более селективный индекс, а expires_at +-- остаётся Filter'ом. Ни один из двух expires-индексов в этом плане не +-- участвует. Так что аргумента «оставить именно индекс из 229, он заведён +-- под конкретный запрос» не существует — запрос его не использует. +-- Поэтому оставлен индекс из 001: он объявлен в миграции, создающей саму +-- таблицу, и на свежей БД (001..N по порядку) переживший индекс совпадёт с +-- прод-состоянием, без «001 создаёт — 250 сносит» на каждой новой БД. +-- +-- ── Планы ДО и ПОСЛЕ ───────────────────────────────────────────────────────── +-- Индексы побайтово идентичны, поэтому смена узла невозможна в принципе: +-- меняется только имя индекса в строке плана и cost на одну страницу спуска. +-- ДО (прод, 2026-08-09 16:54 UTC): +-- Limit (cost=0.28..58.98 rows=100 width=24) +-- → Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..623.07) +-- Index Cond: (expires_at < now()) +-- ПОСЛЕ ожидается тот же узел с именем trade_in_estimates_expires_idx и +-- cost, отличающимся на спуск по одной лишней странице. Проверено на чистом +-- PostgreSQL 16.4 (та же минорная версия, что на проде) с воспроизведённым +-- перекосом плотности: +-- ДО: Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..31.84) +-- ПОСЛЕ: Index Scan using trade_in_estimates_expires_idx (cost=0.28..38.30) +-- Форма плана, Index Cond и Filter идентичны; отличается только имя. +-- +-- ── Стоимость блокировки и почему здесь SET LOCAL lock_timeout ────────────── +-- Обычный DROP INDEX берёт ACCESS EXCLUSIVE на таблицу. УДЕРЖАНИЕ здесь +-- дёшево: trade_in_estimates — 1061 строка, heap 1856 kB, сносимый индекс +-- 40 kB; DROP INDEX ничего не переписывает (удаление строк каталога плюс +-- unlink файла, единицы миллисекунд). +-- +-- Дорого — ОЖИДАНИЕ выдачи лока, и это уже случилось. 2026-08-07 первая +-- редакция этого файла (без строки ниже) ждала ACCESS EXCLUSIVE 29 минут за +-- чужой аналитической psql-сессией (`CREATE TEMP TABLE tmp_res AS ...`, +-- pid 83256), вторая попытка — ещё 16. Четыре прогона деплоя красные, +-- четыре смерженных PR не доехали до прода; ждущий ACCESS EXCLUSIVE встаёт +-- в очередь ПЕРЕД новыми запросами, поэтому за ним начали ждать и обычные +-- SELECT приложения. Файл сняли с деплоя (#2792), конвенцию закрепили +-- (#2791: гейт scripts/check-migration-lock-timeout.py + .claude/rules/sql.md). +-- +-- Значение 5 s: снизу ограничено deadlock_timeout (на проде 1 s — сверено +-- 2026-08-09) — автоотмена мешающего autovacuum срабатывает только после +-- того, как ждущий отстоял эту секунду, поэтому 1-2 s гонялись бы с рутинным +-- autovacuum. Сверху — потолок простоя очереди приложения; против +-- наблюдённых 1740 s это в 348 раз меньше. На работу ПОД локом значение не +-- влияет вообще. +-- +-- Срабатывание таймаута = красный деплой через 5 секунд с `canceling +-- statement due to lock timeout` вместо получасовой очереди. Это ожидаемое +-- поведение, а не авария: миграция не помечается применённой, повторить +-- позже. CONCURRENTLY здесь не нужен и был бы хуже: он не может выполняться +-- внутри блока транзакции, а значит файл пришлось бы оставить без +-- BEGIN/COMMIT (см. разбор механики раннера в +-- 225_listing_source_snapshots_run_id_idx.sql). +-- +-- IDEMPOTENCY / SAFETY: +-- - DROP INDEX IF EXISTS — безопасный re-run; без CASCADE. +-- - Одна DDL-операция внутри BEGIN/COMMIT: либо применилась, либо нет. +-- - COMMENT ON INDEX переносит знание из 229 на переживший индекс, чтобы +-- дубль не завели заново (в т.ч. фиксирует, что purge его НЕ использует). +-- +-- Dependencies: 001_trade_in_estimates.sql (создаёт переживший индекс), +-- 229_trade_in_estimates_consent_proof.sql (создала сносимый дубль). +-- Deploy order: standalone. Ничего не ждёт и никого не блокирует. +-- +-- Критерий «таблица тиха» (записан ДО, выполнен 2026-08-09 16:54 UTC): +-- SELECT count(*) FROM pg_locks l JOIN pg_class c ON c.oid = l.relation +-- WHERE c.relname='trade_in_estimates' AND l.pid <> pg_backend_pid(); → 0 +-- +-- Критерий приёмки (записан ДО применения): +-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»). +-- 2. EXPLAIN того же запроса показывает Index Scan using +-- trade_in_estimates_expires_idx — детерминированная проверка, доступна +-- сразу. +-- 3. pg_stat_user_indexes.idx_scan у trade_in_estimates_expires_idx уходит с +-- 234. NB: наблюдаемый темп ~7 сканов/сутки (21 скан за трое суток у +-- дубля), поэтому «в течение часа» — недостаточное окно; честный срок +-- подтверждения ~сутки. Если через сутки счётчик всё ещё 234, значит +-- трафик ушёл в Seq Scan — это опровергло бы разбор выше и требовало бы +-- отката (вернуть индекс: CREATE INDEX CONCURRENTLY). + +BEGIN; + +-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке +-- и в .claude/rules/sql.md § lock_timeout. +SET LOCAL lock_timeout = '5s'; + +DROP INDEX IF EXISTS trade_in_estimates_expires_at_idx; + +COMMENT ON INDEX trade_in_estimates_expires_idx IS + 'Единственный индекс на trade_in_estimates(expires_at) (001). НЕ заводить второй: 229 создала побайтовый дубль trade_in_estimates_expires_at_idx, снят миграцией 250 (#2752/#2793). Мотивировка 229 («под batched-DELETE в purge_expired_trade_in_data») на проде не подтвердилась: тот запрос сужен по created_by IS NULL и идёт через idx_trade_in_estimates_created_by_created_at, expires_at остаётся Filter''ом.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index b73de854..d011b41a 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -242,3 +242,4 @@ 233_payments.sql 234_scrape_runs_ban_kind_unknown.sql 240_trade_in_estimates_retain_until.sql +250_drop_duplicate_expires_at_index.sql From 7b36f86ea6f123165d1c406a42f8cfc9dc5ac102 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 9 Aug 2026 17:21:49 +0000 Subject: [PATCH 15/23] =?UTF-8?q?fix(tradein/domclick):=20=D1=81=D0=B2?= =?UTF-8?q?=D0=B8=D0=BF=20=D1=85=D0=BE=D0=B4=D0=B8=D1=82=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20=D0=BF=D1=80=D0=BE=D0=BA=D1=81=D0=B8-?= =?UTF-8?q?=D0=BF=D1=83=D0=BB,=20=D0=B0=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=20=D1=83=D0=B7=D0=BB=D0=B0=20=D0=BD=D0=B5=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D1=83=D0=BB=D1=8F=D0=B5=D1=82=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D0=BD=20(#2657)=20(#2796)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_2657_domclick_proxy_pool.py | 114 ++++++++++++++++++ .../test_2670_streak_and_partial_coverage.py | 2 +- .../backend/tests/test_domclick_sweep.py | 2 +- .../src/scraper_kit/orchestration/pipeline.py | 10 +- .../scraper_kit/orchestration/scheduler.py | 1 + .../scraper_kit/providers/domclick/serp.py | 50 +++++--- 6 files changed, 157 insertions(+), 22 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_2657_domclick_proxy_pool.py diff --git a/tradein-mvp/backend/tests/test_2657_domclick_proxy_pool.py b/tradein-mvp/backend/tests/test_2657_domclick_proxy_pool.py new file mode 100644 index 00000000..e2a3f43a --- /dev/null +++ b/tradein-mvp/backend/tests/test_2657_domclick_proxy_pool.py @@ -0,0 +1,114 @@ +"""#2657: домклик собирал 0 лотов, потому что ходил мимо прокси-пула. + +Прод 06-09.08.2026: `domclick_city_sweep` четыре прогона подряд `failed` с +`buckets=0/6, lots=0, errors=1`. Причина найдена не в QRATOR и не в куках, а в двух +сложившихся дефектах: + +1. **Домклик был единственным sweep-источником без `proxy_provider`** (#2160 P4 wiring + gap). `build_browser_fetcher(config, "domclick")` без провайдера → lease не берётся → + сайдкар идёт через статический `SCRAPER_PROXY_URL`. Этот адрес (прод + `scrape_proxies.id=1`, residential, `provider_affinity='domclick'`) с 06.08 отдаёт + `NS_ERROR_PROXY_BAD_GATEWAY` ИМЕННО на `bff-search-web.domclick.ru`. Живая проверка + 09.08 через сайдкар: тот же URL через id=1 → 500, через мобильный узел пула + (`id=10`) → 200 и `{"offersCount":704,"snippetsCount":678}` в бакете `st`. + Предпосылка миграции 173 («QRATOR банит все прокси кроме этого residential») + опровергнута: мобильный узел домкликовский BFF пускает. + +2. **Транспортная ошибка убивала весь свип на первом бакете.** `fetch_city` ловил + только `ValueError/TypeError` — ошибки РАЗБОРА. `httpx.HTTPStatusError` от сайдкара + пролетал наружу мимо цикла по `ROOM_BUCKETS`, и прогон заканчивался с `0/6`. Это же + не давало сработать ротации lease после N подряд провалов + (`browser_fetcher._LEASE_ROTATE_AFTER_FAILS`) — до неё не доживали. + +Фальсификация на старом коде: `test_scraper_leases_proxy_for_domclick` падает +`TypeError: unexpected keyword argument 'proxy_provider'`, +`test_transport_error_does_not_kill_the_whole_sweep` падает на пробросе +`HTTPStatusError` наружу. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration.scheduler import _job_domclick_city_sweep +from scraper_kit.providers.domclick.serp import ROOM_BUCKETS, DomClickScraper + + +def _config() -> SimpleNamespace: + return SimpleNamespace( + browser_http_endpoint="http://x:9000", + use_proxy_pool_browser=True, + environment="production", + ) + + +@pytest.fixture +def _no_browser() -> Any: + """Сайдкар не поднимается — нас интересует только то, ЧТО ему передали.""" + fetcher = MagicMock() + fetcher.__aenter__ = AsyncMock(return_value=fetcher) + fetcher.__aexit__ = AsyncMock(return_value=None) + with patch("scraper_kit.providers._base.BrowserFetcher", return_value=fetcher) as bf: + yield bf + + +async def test_scraper_leases_proxy_for_domclick(_no_browser: MagicMock) -> None: + """Узел берётся из пула под именем "domclick" — а не из env сайдкара.""" + provider = MagicMock() + scraper = DomClickScraper(_config(), proxy_provider=provider) + with patch.object(DomClickScraper, "_sweep_bucket", AsyncMock(return_value=None)): + await scraper.fetch_city(city_id=4) + + assert _no_browser.call_args.kwargs["proxy_provider"] is provider + assert _no_browser.call_args.kwargs["use_pool"] is True + + +async def test_transport_error_does_not_kill_the_whole_sweep(_no_browser: MagicMock) -> None: + """Отказ сайдкара на первом бакете не обнуляет прогон — остальные бакеты идут. + + Прод-форма отказа: 500 от tradein-browser с NS_ERROR_PROXY_BAD_GATEWAY в теле. + """ + scraper = DomClickScraper(_config()) + calls = {"n": 0} + + async def _sweep(self: DomClickScraper, **_: object) -> None: + calls["n"] += 1 + if calls["n"] == 1: + raise httpx.HTTPStatusError( + "Server error '500 Internal Server Error' | tradein-browser: " + '{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}', + request=MagicMock(), + response=MagicMock(), + ) + + with patch.object(DomClickScraper, "_sweep_bucket", _sweep): + await scraper.fetch_city(city_id=4) + + assert calls["n"] == len(ROOM_BUCKETS) + # Битый бакет НЕ засчитан в охват (#2670) — прогон честно неполон. + assert scraper.buckets_completed == len(ROOM_BUCKETS) - 1 + assert scraper.fetch_errors == 1 + + +async def test_scheduler_passes_pool_to_domclick_job() -> None: + """Планировщик отдаёт домклику ctx.proxy_provider — как всем остальным свипам.""" + provider = MagicMock() + ctx = SimpleNamespace( + config=_config(), + matcher=MagicMock(), + shutdown_requested=lambda: False, + proxy_provider=provider, + ) + sweep = AsyncMock(return_value=None) + with patch("scraper_kit.orchestration.scheduler.run_domclick_city_sweep", sweep): + await _job_domclick_city_sweep(MagicMock(), 1, {}, ctx) # type: ignore[arg-type] + + assert sweep.await_args.kwargs["proxy_provider"] is provider diff --git a/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py b/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py index ecc31dc9..9c5cfae6 100644 --- a/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py +++ b/tradein-mvp/backend/tests/test_2670_streak_and_partial_coverage.py @@ -191,7 +191,7 @@ class _FakeFetcher: def _no_browser(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( "scraper_kit.providers._base.build_browser_fetcher", - lambda config, source: _FakeFetcher(), + lambda config, source, **_kw: _FakeFetcher(), ) diff --git a/tradein-mvp/backend/tests/test_domclick_sweep.py b/tradein-mvp/backend/tests/test_domclick_sweep.py index e84f377e..39f869f3 100644 --- a/tradein-mvp/backend/tests/test_domclick_sweep.py +++ b/tradein-mvp/backend/tests/test_domclick_sweep.py @@ -120,7 +120,7 @@ async def test_fetch_city_reports_ban_on_qrator_block(monkeypatch: pytest.Monkey fake_fetcher = _FakeFetcher() - def _fake_build_browser_fetcher(config: object, source: str) -> _FakeFetcher: + def _fake_build_browser_fetcher(config: object, source: str, **_kw: object) -> _FakeFetcher: assert source == "domclick" return fake_fetcher diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index a900f996..eb6852b3 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -3742,6 +3742,7 @@ async def run_domclick_city_sweep( config: ScraperConfig, matcher: HouseMatcher, shutdown_requested: Callable[[], bool] = lambda: False, + proxy_provider: ProxyProvider | None = None, city_id: int = DOMCLICK_DEFAULT_CITY_ID, rooms: list[int] | None = None, pages: int = 100, @@ -3753,11 +3754,16 @@ async def run_domclick_city_sweep( Структурно зеркалит run_cian_city_sweep / run_yandex_city_sweep, но CITYWIDE: DomClick не поддерживает geo-radius (fetch_around → NotImplementedError), поэтому anchor-loop отсутствует. DomClickScraper.fetch_city перебирает все - ROOM_BUCKETS (st/1/2/3/4/5+) через BrowserFetcher → shared mobile proxy. + ROOM_BUCKETS (st/1/2/3/4/5+) через BrowserFetcher → прокси-пул. Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо прямых импортов app.* (см. scraper_kit.contracts). + proxy_provider (#2657): домклик был единственным sweep-источником без пула — ходил + через статический SCRAPER_PROXY_URL сайдкара, а тот адрес с 06.08 отдаёт + NS_ERROR_PROXY_BAD_GATEWAY именно на домкликовский BFF. Теперь узел берётся из пула, + как у avito/cian/yandex, и плохой узел ротируется/банится штатными механизмами. + ЧЕСТНЫЙ СТАТУС (#1968, ужесточён #2657): распознанный QRATOR-блок → mark_banned (независимо от числа собранных лотов — блок обрывает ОСТАВШИЕСЯ комнатные бакеты, прогон не доделал работу). Ноль лотов с fetch-ошибками, но без блока → mark_failed. @@ -3807,7 +3813,7 @@ async def run_domclick_city_sweep( async def _domclick_phase() -> None: """Единственная citywide-фаза: fetch_city + save.""" nonlocal lots - async with DomClickScraper(config) as _scraper: + async with DomClickScraper(config, proxy_provider=proxy_provider) as _scraper: _scraper_ref.append(_scraper) if request_delay_sec is not None: _scraper.request_delay_sec = _resolved_delay diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py index c670a298..0ddd275e 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py @@ -666,6 +666,7 @@ async def _job_domclick_city_sweep( config=ctx.config, matcher=ctx.matcher, shutdown_requested=ctx.shutdown_requested, + proxy_provider=ctx.proxy_provider, city_id=int(params.get("city_id", 4)), rooms=params.get("rooms"), pages=int(params.get("pages_per_anchor", 5)), diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py index 1a41094f..c9b99ba5 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py @@ -6,8 +6,9 @@ Strangler-копия (#2133) боевого `app.services.scrapers.domclick`, р (golden-parity — tests/test_scraper_kit_domclick_golden_parity.py). Стратегия: GET https://bff-search-web.domclick.ru/api/offers/v1?... через -BrowserFetcher(source="domclick") (generic provider → shared mobile proxy). -QRATOR банит прямые datacenter-запросы, но пропускает через mobile proxy. +BrowserFetcher(source="domclick") → узел прокси-пула (#2657; до этого — статический +SCRAPER_PROXY_URL сайдкара, см. fetch_city). QRATOR банит прямые datacenter-запросы, +но пропускает через mobile proxy — проверено вживую 09.08.2026. Ответ BrowserFetcher содержит JSON, обёрнутый в HTML (
 или bare body).
 Парсинг: _extract_json() вытаскивает первый {...} из ответа.
@@ -41,7 +42,7 @@ from scraper_kit.pricing import BisectionConfig, ProbeResult, walk_price_range
 from scraper_kit.repair_state_normalizer import infer_repair_state_from_text
 
 if TYPE_CHECKING:
-    from scraper_kit.contracts import ScraperConfig
+    from scraper_kit.contracts import ProxyProvider, ScraperConfig
 
 logger = logging.getLogger(__name__)
 
@@ -251,12 +252,17 @@ class DomClickScraper(BaseScraper):
         config: ScraperConfig,
         *,
         delay_provider: Callable[[str], float] | None = None,
+        proxy_provider: ProxyProvider | None = None,
     ) -> None:
         super().__init__()
         # Strangler-инжекция (#2133): config даёт browser_http_endpoint для
         # BrowserFetcher, delay_provider заменяет прямой импорт
         # app.services.scraper_settings.get_scraper_delay. Kit не знает про app / БД.
         self._config = config
+        # #2657: proxy_provider — как у cian/yandex/avito. До этого домклик был
+        # ЕДИНСТВЕННЫМ sweep-источником без пула (#2160 P4 wiring gap) и потому ходил
+        # через статический SCRAPER_PROXY_URL сайдкара.
+        self._proxy_provider = proxy_provider
         if delay_provider is not None:
             self.request_delay_sec = delay_provider(self.name)
         self.parse_failures: int = 0
@@ -317,15 +323,16 @@ class DomClickScraper(BaseScraper):
         out_lots: list[ScrapedLot] = []
         seen_ids: set[str] = set()
 
-        # build_browser_fetcher(config, source) без proxy_provider (дефолт None) —
-        # поведенчески идентично прежнему прямому BrowserFetcher(source=..., endpoint=...):
-        # use_pool теперь читает config.use_proxy_pool_browser (#2364 caveat, тот же
-        # класс что и avito) вместо хардкода False, но BrowserFetcher._pool_proxy
-        # вычисляет `use_pool and proxy_provider is not None` — при proxy_provider=None
-        # результат всегда False независимо от значения флага (см. _base.py
-        # build_browser_fetcher docstring: domclick пока не подключён к browser-proxy-
-        # пулу, #2160 P4 wiring gap). Поведение НЕ меняется.
-        async with build_browser_fetcher(self._config, "domclick") as fetcher:
+        # #2657: proxy_provider прокинут — до этого домклик единственный из sweep-
+        # источников ходил без пула (#2160 P4 wiring gap), т.е. через статический
+        # SCRAPER_PROXY_URL сайдкара. Прод 06-09.08: этот адрес отдаёт
+        # NS_ERROR_PROXY_BAD_GATEWAY именно на bff-search-web.domclick.ru (проверено
+        # вживую 09.08: через него 500, через мобильный узел пула — 200 и
+        # snippetsCount=678 в бакете 'st'), поэтому свип брал 0 лотов 4 дня подряд.
+        # proxy_provider=None (тесты/dev) по-прежнему валиден — env-fallback.
+        async with build_browser_fetcher(
+            self._config, "domclick", proxy_provider=self._proxy_provider
+        ) as fetcher:
             for bucket in ROOM_BUCKETS:
                 logger.info(
                     "domklik: BFF sweep rooms=%r city_id=%d pages_cap=%d",
@@ -350,15 +357,22 @@ class DomClickScraper(BaseScraper):
                         bucket,
                     )
                     # #2600 п.1: fetcher (lease) ещё жив — `async with` вокруг этого
-                    # цикла не закрылся, мы внутри его тела. no-op сегодня (домклик
-                    # SERP собирается БЕЗ proxy_provider, #2160 P4 wiring gap — см.
-                    # build_browser_fetcher выше), но провода готовы на будущее.
+                    # цикла не закрылся, мы внутри его тела. С #2657 (proxy_provider
+                    # прокинут выше) это уже не no-op: узел уходит в
+                    # scrape_proxy_source_bans и следующий acquire("domclick") его не
+                    # выдаст.
                     fetcher.report_ban(f"domklik QRATOR block during rooms={bucket!r}")
                     break
-                except (ValueError, TypeError) as exc:
+                except Exception as exc:
                     # Defensive: bucket-level ошибка не должна убивать весь sweep.
-                    # _count/_paginate уже глотают эти ошибки per-fetch (fetch_errors++),
-                    # но если что-то всё же всплыло — переходим к следующему бакету.
+                    # _count/_paginate глотают ошибки РАЗБОРА per-fetch (fetch_errors++),
+                    # но транспортные (httpx.HTTPStatusError от сайдкара, таймаут узла)
+                    # летели мимо и убивали прогон на ПЕРВОМ же бакете — прод 06-09.08:
+                    # buckets=0/6, lots=0, четыре дня подряд. Это же лишало смысла
+                    # ротацию lease после N подряд провалов (browser_fetcher
+                    # _LEASE_ROTATE_AFTER_FAILS): до неё просто не доживали. Ловим
+                    # Exception, а не BaseException — CancelledError (SIGTERM-drain,
+                    # watchdog asyncio.wait_for) обязан пройти насквозь.
                     self.fetch_errors += 1
                     logger.warning(
                         "domklik: bucket rooms=%r failed (%s) — skipping to next bucket",

From f1f2bca2e93007bd02ac09f8af891bac94982a3e Mon Sep 17 00:00:00 2001
From: bot-backend 
Date: Sun, 9 Aug 2026 17:26:18 +0000
Subject: [PATCH 16/23] =?UTF-8?q?fix(tradein/deactivate):=20TTL=20=D0=BD?=
 =?UTF-8?q?=D0=B5=20=D1=81=D0=BD=D0=B8=D0=BC=D0=B0=D0=B5=D1=82=20=D0=BE?=
 =?UTF-8?q?=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF?=
 =?UTF-8?q?=D0=BE=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B3=D1=83=20=D0=BD=D0=B8?=
 =?UTF-8?q?=D0=B6=D0=B5=20=D1=81=D0=BE=D0=B1=D1=81=D1=82=D0=B2=D0=B5=D0=BD?=
 =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=86=D0=B8=D0=BA=D0=BB=D0=B0=20?=
 =?UTF-8?q?=D0=BE=D0=B1=D1=85=D0=BE=D0=B4=D0=B0=20(#2797)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../backend/app/services/product_handlers.py  |   8 +
 .../app/tasks/deactivate_stale_avito.py       | 156 +++++++-
 .../test_deactivate_stale_revisit_floor.py    | 334 ++++++++++++++++++
 3 files changed, 494 insertions(+), 4 deletions(-)
 create mode 100644 tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py

diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py
index 29b6aaab..64ba11e2 100644
--- a/tradein-mvp/backend/app/services/product_handlers.py
+++ b/tradein-mvp/backend/app/services/product_handlers.py
@@ -218,6 +218,7 @@ async def _job_deactivate_stale(
     from app.core.config import settings as _settings
     from app.tasks.deactivate_stale_avito import (
         DEFAULT_MIN_CONFIRMATIONS,
+        DEFAULT_REVISIT_FLOOR_QUANTILE,
         deactivate_stale_listings,
     )
 
@@ -229,6 +230,12 @@ async def _job_deactivate_stale(
     # получает страховочный порог, а не «деактивируй вслепую». Посчитанные по
     # источнику пороги приходят из default_params (миграция 219).
     min_confirmations: int = params.get("min_confirmations", DEFAULT_MIN_CONFIRMATIONS)
+    # Пол TTL по измеренному циклу переобхода (#2659) — тоже включён по умолчанию:
+    # незасеянное расписание не должно снимать объявления по порогу ниже собственного
+    # хвоста обхода. Снять ручку вручную: revisit_floor_quantile = 0.
+    revisit_floor_quantile: float = params.get(
+        "revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE
+    )
 
     loop = asyncio.get_event_loop()
     await loop.run_in_executor(
@@ -241,6 +248,7 @@ async def _job_deactivate_stale(
             segments=segments,
             staleness_column=staleness_column,
             min_confirmations=min_confirmations,
+            revisit_floor_quantile=revisit_floor_quantile,
         ),
     )
 
diff --git a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py
index 1f800160..cdab053b 100644
--- a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py
+++ b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py
@@ -24,6 +24,7 @@ TTL для avito берётся из settings.avito_stale_ttl_days (env AVITO_ST
 from __future__ import annotations
 
 import logging
+from math import ceil
 from typing import Any
 
 from sqlalchemy import text
@@ -127,6 +128,107 @@ DEFAULT_MIN_CONFIRMATIONS = 500
 _CONFIRMATIONS_SEGMENT_FILTER = "\n          AND listing_segment = ANY(CAST(:segments AS text[]))"
 
 
+# ── Пол TTL по измеренному циклу переобхода (#2659) ───────────────────────────
+# Гейт выше отвечает на вопрос «источник вообще собирается?». Он НЕ отвечает на
+# вопрос, из-за которого заведён #2659: «а достаточно ли ttl_days, чтобы молчание
+# означало снятие?». Пока свип возвращается к строке реже, чем раз в ttl_days,
+# TTL меряет НАШУ выборку, а не жизнь объявления, — и источник при этом полностью
+# здоров, так что гейт молчит.
+#
+# ЗАМЕР НА ПРОДЕ 2026-08-09, из-за которого этот пол существует.
+# С момента деплоя гейта (06.08) TTL снял 1 028 строк; 127 из них (12.4%) УЖЕ снова
+# активны — свип нашёл их живыми через 1-3 суток и вернул сам (upsert в
+# scraper_kit/base.py ставит is_active = true). В единственном городе с настоящим
+# покрытием доля ложных снятий 100%:
+#   cian   Екатеринбург  103 снято → 103 снова активны
+#   yandex Екатеринбург   24 снято →  24 снова активны
+#   cian/yandex без города 901 снято → 0 вернулись (их свип не обходит вовсе)
+# Возраст на момент снятия у всех 127: 29.9..30.3 суток при TTL=30 — то есть TTL
+# срабатывал ровно на границе, а свип возвращался к строке на 31-34-е сутки.
+#
+# ПОЧЕМУ ЭТО НЕ ЛЕЧИТСЯ НОВОЙ КОНСТАНТОЙ. Разрывы переобхода, суток
+# (listing_source_snapshots, 40 суток, посчитано по срезу TTL-джобы):
+#   источник/сегмент      p90    p99   TTL сейчас   TTL/p99
+#   domklik  vtorichka     1.9    3.1      14         4.5   ← сплошное суточное покрытие
+#   cian     vtorichka    10.9   26.6      30         1.1
+#   yandex   vtorichka     5.7   43.0      30         0.7
+#   avito    vtorichka    29.1   42.1      10         0.24  ← отсюда 9 033 строки
+# Домклик — контрольная группа: при почти полном суточном обходе TTL=14 лежит в
+# 4.5 раза выше хвоста, и снятие у него действительно означает снятие. У остальных
+# трёх порог ниже собственного хвоста обхода — руками подобранное число и есть
+# корень #2659, поэтому чинить его вторым руками подобранным числом бессмысленно.
+#
+# ЧТО МЕРЯЕМ ВМЕСТО КОНСТАНТЫ: факт, а не оценку. «Какой самый большой возраст, при
+# котором свип за последнее окно ДОКАЗАЛ, что объявление живо» — то есть насколько
+# старую строку он только что нашёл на площадке. Если свип буквально вчера вернул к
+# жизни строку, молчавшую 40 суток, то 30 суток молчания не доказывают ничего.
+# Пол = квантиль этого распределения, эффективный TTL = max(ttl_days, пол).
+#
+# Считается по ТОМУ ЖЕ срезу (source + segments) и по ТОЙ ЖЕ колонке свежести, что
+# и UPDATE. Предыдущее наблюдение берётся из listing_source_snapshots — единственной
+# истории свежести, что у нас есть; расхождение listings. и
+# listing_sources.last_seen_at замерено на проде и не превышает 0.5 суток в среднем
+# (максимум 0), что на шкале 30-70 суток шум.
+#
+# КВАНТИЛЬ — калибровочная ручка, не догма. 0.99 подобран по требованию «пол обязан
+# накрыть 127 доказанных ложных снятий», у которых возраст был 29.9..30.3: замер
+# того же запроса на проде даёт 34.0 для cian/vtorichka и 74.3 для yandex/vtorichka.
+# Ниже 0.99 опускать нельзя без нового замера. Ручка живёт в default_params
+# расписания (revisit_floor_quantile), 0 -> пол выключен.
+#
+# ПОБОЧНЫЙ ЭФФЕКТ, КОТОРЫЙ ЗДЕСЬ НАМЕРЕННЫЙ: после провала сбора хвост разрывов
+# распухает (свип разгребает завал и находит очень старые строки), пол поднимается,
+# и деактивация замирает сама — без отдельного детектора банов. Когда завал разобран,
+# хвост схлопывается и пол опускается обратно. Это ровно то поведение, которого
+# issue просил от «гейта по банам», но выраженное через результат, а не через причину.
+#
+# ПОТОЛОК: пол не может превысить глубину истории снимков. Если снимок за нужную
+# дату не писался (дыры на проде есть — 30.07, 01.08), берётся ближайший более
+# ранний; при полном отсутствии снимков пол не считается и TTL остаётся как задан.
+DEFAULT_REVISIT_FLOOR_QUANTILE = 0.99
+
+_REVISIT_FLOOR_SEGMENT_FILTER = "\n          AND l.listing_segment = ANY(CAST(:segments AS text[]))"
+
+
+def _build_revisit_floor_sql(staleness_column: str, *, with_segments: bool) -> Any:
+    """Квантиль возраста, при котором свип за окно ДОКАЗАЛ, что строка жива.
+
+    Пара «предыдущее наблюдение (снимок) → текущее наблюдение (listings)» даёт
+    разрыв переобхода в сутках; берём его квантиль по срезу source+segments.
+    Только строки, у которых свежесть реально сдвинулась, — то есть выжившие,
+    а не «мы к ним не приходили».
+
+    staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings.
+    Значения — param-binding, psycopg v3 safe (CAST(... AS ...), никаких :param::type).
+    """
+    segment_filter = _REVISIT_FLOOR_SEGMENT_FILTER if with_segments else ""
+    return text(
+        f"""
+        SELECT percentile_disc(CAST(:revisit_quantile AS double precision))
+               WITHIN GROUP (
+                   ORDER BY EXTRACT(epoch FROM (l.{staleness_column} - prev.last_seen_at))
+                            / 86400.0
+               )
+        FROM listings l
+        JOIN listing_sources ls
+          ON ls.listing_id = l.id
+         AND ls.ext_source = l.source
+        JOIN listing_source_snapshots prev
+          ON prev.listing_source_id = ls.id
+         AND prev.snapshot_date = (
+                 SELECT max(snapshot_date)
+                 FROM listing_source_snapshots
+                 WHERE snapshot_date
+                       <= CURRENT_DATE - CAST(:health_window_days AS integer)
+             )
+        WHERE l.source = :listing_source
+          AND l.{staleness_column}
+              > NOW() - CAST(:health_window_days || ' days' AS interval)
+          AND l.{staleness_column} > prev.last_seen_at{segment_filter}
+        """
+    )
+
+
 def _build_confirmations_sql(staleness_column: str, *, with_segments: bool) -> Any:
     """SELECT count(*) подтверждённых за окно строк — тот же срез, что и у UPDATE.
 
@@ -209,6 +311,7 @@ def deactivate_stale_listings(
     staleness_column: str = "last_seen_at",
     min_confirmations: int = 0,
     health_window_days: int = _HEALTH_WINDOW_DAYS,
+    revisit_floor_quantile: float = 0.0,
 ) -> dict[str, int]:
     """Пометить is_active=false объявления, чья свежесть старше ttl_days дней.
 
@@ -229,6 +332,11 @@ def deactivate_stale_listings(
             вызывают старые тесты и совместимая обёртка); реальные значения приходят
             из default_params расписания, см. миграцию 219 и комментарий выше.
         health_window_days: окно подтверждений для гейта, суток. Дефолт 3.
+        revisit_floor_quantile: пол TTL по измеренному циклу переобхода (#2659).
+            Квантиль возраста, при котором свип за окно ДОКАЗАЛ строку живой;
+            эффективный TTL = max(ttl_days, этот пол). 0 -> пол выключен (так
+            вызывают старые тесты и совместимая обёртка), рабочее значение —
+            DEFAULT_REVISIT_FLOOR_QUANTILE, см. комментарий выше.
 
     Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
     Один statement в транзакции: UPDATE флага + снимок 'stale' в listings_snapshots
@@ -236,7 +344,8 @@ def deactivate_stale_listings(
 
     Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками).
     Если гейт не пропустил прогон: {"deactivated": 0, "confirmations": N,
-    "skipped_unhealthy": 1} и НИ ОДНА строка не тронута.
+    "skipped_unhealthy": 1} и НИ ОДНА строка не тронута. Если пол переобхода поднял
+    TTL: дополнительно {"revisit_floor_days": N, "ttl_days_effective": N}.
 
     Raises:
         ValueError: если staleness_column не входит в whitelist (проверка ДО SQL,
@@ -292,6 +401,44 @@ def deactivate_stale_listings(
                 )
                 return counters
 
+        # Пол TTL по измеренному циклу переобхода (#2659) — тоже ДО UPDATE и по тому же
+        # срезу. Поднимает порог, никогда не опускает: max(), а не замена.
+        effective_ttl_days = ttl_days
+        if revisit_floor_quantile > 0:
+            floor_params: dict[str, Any] = {
+                "listing_source": listing_source,
+                "health_window_days": health_window_days,
+                "revisit_quantile": revisit_floor_quantile,
+            }
+            if segments is not None:
+                floor_params["segments"] = segments
+            floor_days = db.execute(
+                _build_revisit_floor_sql(staleness_column, with_segments=segments is not None),
+                floor_params,
+            ).scalar()
+            # NULL = истории снимков за окно нет вовсе (свежая БД, дыра в снимках).
+            # Тогда пола нет и TTL остаётся как задан: выдумывать пол не из чего.
+            if floor_days is not None:
+                counters["revisit_floor_days"] = ceil(float(floor_days))
+                effective_ttl_days = max(ttl_days, counters["revisit_floor_days"])
+                counters["ttl_days_effective"] = effective_ttl_days
+                if effective_ttl_days > ttl_days:
+                    logger.warning(
+                        "deactivate_stale source=%s run_id=%d TTL поднят с %d до %d сут: "
+                        "свип за %d сут доказал живой строку, молчавшую %d сут "
+                        "(квантиль %.3f, segments=%r) — при ttl_days=%d снятие означало бы "
+                        "«мы не дошли», а не «объявление снято»",
+                        listing_source,
+                        run_id,
+                        ttl_days,
+                        effective_ttl_days,
+                        health_window_days,
+                        counters["revisit_floor_days"],
+                        revisit_floor_quantile,
+                        segments,
+                        ttl_days,
+                    )
+
         # segments is None -> все сегменты (поведение avito). segments=[...] -> только
         # перечисленные сегменты. Используем `is not None` (НЕ truthy): пустой список []
         # означает "ни один сегмент" (= ANY(ARRAY[]) ничего не матчит, деактивирует 0),
@@ -299,7 +446,7 @@ def deactivate_stale_listings(
         if segments is not None:
             params: dict[str, Any] = {
                 "listing_source": listing_source,
-                "ttl_days": ttl_days,
+                "ttl_days": effective_ttl_days,
                 "segments": segments,
                 "run_id": run_id,
             }
@@ -307,7 +454,7 @@ def deactivate_stale_listings(
         else:
             params = {
                 "listing_source": listing_source,
-                "ttl_days": ttl_days,
+                "ttl_days": effective_ttl_days,
                 "run_id": run_id,
             }
             result = db.execute(_build_all_segments_sql(staleness_column), params)
@@ -318,10 +465,11 @@ def deactivate_stale_listings(
         runs_mod.mark_done(db, run_id, counters)
         logger.info(
             "deactivate_stale source=%s run_id=%d done: deactivated=%d "
-            "(ttl_days=%d, segments=%r, staleness_column=%s)",
+            "(ttl_days=%d эффективный, задан %d, segments=%r, staleness_column=%s)",
             listing_source,
             run_id,
             counters["deactivated"],
+            effective_ttl_days,
             ttl_days,
             segments,
             staleness_column,
diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py
new file mode 100644
index 00000000..76f34665
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py
@@ -0,0 +1,334 @@
+"""Пол TTL по измеренному циклу переобхода (#2659).
+
+Гейт здоровья (#2710) отвечает «источник собирается?». Этот пол отвечает на второй
+вопрос issue — «а достаточно ли ttl_days, чтобы молчание означало снятие?». Пока свип
+возвращается к строке реже, чем раз в ttl_days, TTL меряет нашу выборку, а не жизнь
+объявления, и источник при этом ЗДОРОВ — гейт молчит.
+
+Ключевой тест — test_effective_ttl_covers_every_proven_false_kill: он проигрывает
+РЕАЛЬНЫЙ прод-случай (127 строк, снятых на возрасте 29.9..30.3 суток при TTL=30 и
+доказанно вернувшихся живыми) и требует, чтобы эффективный TTL накрыл каждую. На
+старом коде — без пола — эффективный TTL остаётся 30, и тест падает на всех срезах.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+from app.tasks import deactivate_stale_avito as task_mod
+
+# ── Прод-замер 2026-08-09 (read-only), из-за которого пол существует ───────────
+# С деплоя гейта 06.08 TTL снял 1 028 строк, 127 уже снова активны (12.4%).
+# В Екатеринбурге — единственном городе с настоящим покрытием — доля ложных 100%.
+_FALSE_KILLS_BY_CITY: dict[str, tuple[int, int]] = {  # срез -> (снято, снова активны)
+    "cian/Екатеринбург": (103, 103),
+    "yandex/Екатеринбург": (24, 24),
+    "cian/без города": (560, 0),
+    "yandex/без города": (341, 0),
+}
+# Возраст строки на момент снятия у всех 127 доказанно ложных снятий, суток.
+_FALSE_KILL_AGE_MIN = 29.9
+_FALSE_KILL_AGE_MAX = 30.3
+
+# Пол, который отдаёт ТОТ ЖЕ запрос на проде (percentile_disc 0.99, окно 3 суток,
+# срез = срез TTL-джобы). Ключ -> (listing_source, segments, ttl_days сейчас, пол).
+_PROD_FLOORS: dict[str, tuple[str, list[str] | None, int, float]] = {
+    "cian/vtorichka": ("cian", ["vtorichka"], 30, 34.0),
+    "yandex/vtorichka": ("yandex", ["vtorichka"], 30, 74.3),
+    "avito/все сегменты": ("avito", None, 10, 69.7),
+}
+
+# Разрывы переобхода по срезу TTL-джобы (listing_source_snapshots, 40 суток):
+# источник -> (p90, p99, ttl_days сейчас). Домклик — контрольная группа: почти
+# полное суточное покрытие, TTL лежит в 4.5 раза выше хвоста, и снятие у него
+# действительно означает снятие.
+_REVISIT_TAIL: dict[str, tuple[float, float, int]] = {
+    "domklik/vtorichka": (1.9, 3.1, 14),
+    "cian/vtorichka": (10.9, 26.6, 30),
+    "yandex/vtorichka": (5.7, 43.0, 30),
+    "avito/vtorichka": (29.1, 42.1, 10),
+}
+
+
+# ── Фейковая сессия ───────────────────────────────────────────────────────────
+
+
+class _FakeResult:
+    def __init__(self, rowcount: int = 0, scalar_value: Any = None) -> None:
+        self.rowcount = rowcount
+        self._scalar = scalar_value
+
+    def scalar(self) -> Any:
+        return self._scalar
+
+
+class _FakeDB:
+    """Session-заглушка: percentile_disc -> пол, count(*) -> подтверждения, UPDATE -> rowcount."""
+
+    def __init__(
+        self,
+        *,
+        floor_days: float | None,
+        confirmations: int = 10_000,
+        rowcount: int = 137,
+    ) -> None:
+        self._floor = floor_days
+        self._confirmations = confirmations
+        self._rowcount = rowcount
+        self.executed: list[tuple[str, dict[str, Any] | None]] = []
+        self.committed = False
+        self.rolled_back = False
+
+    def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
+        sql = str(stmt.text)
+        self.executed.append((sql, params))
+        if "percentile_disc" in sql:
+            return _FakeResult(scalar_value=self._floor)
+        if "SELECT count(*)" in sql:
+            return _FakeResult(scalar_value=self._confirmations)
+        return _FakeResult(rowcount=self._rowcount)
+
+    def commit(self) -> None:
+        self.committed = True
+
+    def rollback(self) -> None:
+        self.rolled_back = True
+
+    @property
+    def floor_query(self) -> tuple[str, dict[str, Any] | None]:
+        return next((e for e in self.executed if "percentile_disc" in e[0]), ("", None))
+
+    @property
+    def update_query(self) -> tuple[str, dict[str, Any] | None]:
+        return next((e for e in self.executed if "UPDATE listings" in e[0]), ("", None))
+
+
+def _run(db: _FakeDB, monkeypatch: pytest.MonkeyPatch, **kwargs: Any) -> dict[str, int]:
+    monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
+    monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None)
+    return task_mod.deactivate_stale_listings(
+        db,  # type: ignore[arg-type]
+        1,
+        listing_source=kwargs.pop("listing_source", "cian"),
+        ttl_days=kwargs.pop("ttl_days", 30),
+        **kwargs,
+    )
+
+
+# ── Исторический случай: 127 доказанных ложных снятий ─────────────────────────
+
+
+def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.MonkeyPatch) -> None:
+    """Ни одно из 127 доказанно ложных снятий не должно повториться.
+
+    Все они произошли на возрасте 29.9..30.3 суток. Эффективный TTL обязан быть
+    строго выше этого возраста на КАЖДОМ прод-срезе — иначе следующий прогон
+    снимет ту же строку снова.
+    """
+    for slice_name, (source, segments, ttl_days, floor) in _PROD_FLOORS.items():
+        db = _FakeDB(floor_days=floor)
+        out = _run(
+            db,
+            monkeypatch,
+            listing_source=source,
+            ttl_days=ttl_days,
+            segments=segments,
+            revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE,
+        )
+        effective = out["ttl_days_effective"]
+        assert effective > _FALSE_KILL_AGE_MAX, (
+            f"{slice_name}: эффективный TTL {effective} не накрывает возраст ложного "
+            f"снятия {_FALSE_KILL_AGE_MAX} — 127 строк снимутся снова"
+        )
+        _, update_params = db.update_query
+        assert update_params is not None
+        assert (
+            update_params["ttl_days"] == effective
+        ), f"{slice_name}: UPDATE получил не поднятый TTL — пол посчитан и выброшен"
+
+
+def test_false_kill_ages_sit_inside_the_old_ttl(monkeypatch: pytest.MonkeyPatch) -> None:
+    """Замер согласован сам с собой: снимали ровно на границе TTL=30, не раньше."""
+    assert _FALSE_KILL_AGE_MIN < 30.0 <= _FALSE_KILL_AGE_MAX
+    for source, _segments, ttl_days, _floor in _PROD_FLOORS.values():
+        if source in ("cian", "yandex"):
+            assert ttl_days == 30, f"{source}: прод-TTL разошёлся с замером"
+
+
+def test_false_kill_rate_is_total_where_coverage_is_real() -> None:
+    """В городе с настоящим покрытием ложны ВСЕ снятия — это и есть корень #2659."""
+    for slice_name in ("cian/Екатеринбург", "yandex/Екатеринбург"):
+        killed, returned = _FALSE_KILLS_BY_CITY[slice_name]
+        assert returned == killed, f"{slice_name}: замер разошёлся"
+    total_killed = sum(k for k, _ in _FALSE_KILLS_BY_CITY.values())
+    total_returned = sum(r for _, r in _FALSE_KILLS_BY_CITY.values())
+    assert total_killed == 1028
+    assert total_returned == 127
+
+
+def test_control_group_domklik_needs_no_floor() -> None:
+    """Домклик — контроль: при почти полном суточном обходе TTL с запасом выше хвоста.
+
+    Если бы пол был нужен всем подряд, он был бы нужен и источнику со сплошным
+    покрытием. Ему не нужен — значит меряем именно покрытие, а не «TTL маловат».
+
+    Разделяет источники ЗАПАС над хвостом, а не сам факт превышения: у домклика
+    TTL/p99 = 4.5, у остальных трёх 0.24..1.13, то есть порог сидит вплотную к
+    хвосту или внутри него. Именно у самого «благополучного» из трёх (cian, 1.13)
+    и случились 103 доказанно ложных снятия: p99 запаса не даёт, снимает 1% живых.
+    """
+    _p90, p99, ttl = _REVISIT_TAIL["domklik/vtorichka"]
+    assert ttl / p99 > 4, "домклик перестал быть контрольной группой — перемерить"
+    for name, (_p90, p99, ttl) in _REVISIT_TAIL.items():
+        if name.startswith("domklik"):
+            continue
+        assert ttl / p99 < 1.5, f"{name}: TTL отошёл от хвоста обхода, замер устарел"
+
+
+# ── Контракт пола ─────────────────────────────────────────────────────────────
+
+
+def test_floor_never_lowers_configured_ttl(monkeypatch: pytest.MonkeyPatch) -> None:
+    """Пол поднимает порог и только. Здоровый источник с коротким циклом не даёт
+    поводу снять больше, чем задано оператором."""
+    db = _FakeDB(floor_days=3.1)
+    out = _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99)
+    assert out["ttl_days_effective"] == 30
+    _, update_params = db.update_query
+    assert update_params is not None
+    assert update_params["ttl_days"] == 30
+
+
+def test_floor_is_rounded_up_not_down(monkeypatch: pytest.MonkeyPatch) -> None:
+    """34.0 суток разрыва -> TTL 34, а 34.2 -> 35: округление в сторону осторожности."""
+    db = _FakeDB(floor_days=34.2)
+    out = _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99)
+    assert out["revisit_floor_days"] == 35
+    assert out["ttl_days_effective"] == 35
+
+
+def test_floor_disabled_keeps_old_behaviour(monkeypatch: pytest.MonkeyPatch) -> None:
+    """quantile=0 -> ни одного лишнего запроса, поведение как до правки."""
+    db = _FakeDB(floor_days=74.3)
+    out = _run(db, monkeypatch, ttl_days=30)
+    assert out == {"deactivated": 137}
+    assert len(db.executed) == 1
+    assert "percentile_disc" not in db.executed[0][0]
+
+
+def test_missing_snapshot_history_leaves_ttl_as_configured(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """NULL (истории снимков за окно нет) -> пол не выдумывается, TTL как задан."""
+    db = _FakeDB(floor_days=None)
+    out = _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99)
+    assert "revisit_floor_days" not in out
+    _, update_params = db.update_query
+    assert update_params is not None
+    assert update_params["ttl_days"] == 30
+
+
+def test_floor_runs_before_any_write(monkeypatch: pytest.MonkeyPatch) -> None:
+    """Пол считается ДО UPDATE: снятое объявление возвращает только повторный сбор."""
+    db = _FakeDB(floor_days=74.3)
+    _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99)
+    kinds = ["floor" if "percentile_disc" in sql else "update" for sql, _ in db.executed]
+    assert kinds.index("floor") < kinds.index("update")
+
+
+def test_floor_measures_same_slice_as_update(monkeypatch: pytest.MonkeyPatch) -> None:
+    """Срез пола совпадает со срезом UPDATE: тот же source и те же сегменты."""
+    db = _FakeDB(floor_days=74.3)
+    _run(
+        db,
+        monkeypatch,
+        listing_source="yandex",
+        segments=["vtorichka"],
+        revisit_floor_quantile=0.99,
+    )
+    floor_sql, floor_params = db.floor_query
+    assert "ANY(CAST(:segments AS text[]))" in floor_sql
+    assert floor_params is not None
+    assert floor_params["segments"] == ["vtorichka"]
+    assert floor_params["listing_source"] == "yandex"
+
+
+def test_floor_uses_same_staleness_column_as_ttl(monkeypatch: pytest.MonkeyPatch) -> None:
+    """domklik считает свежесть по scraped_at (#2204) — пол обязан мерить ту же колонку,
+    иначе bulk-touch по last_seen_at показал бы цикл обхода там, где сбора нет."""
+    db = _FakeDB(floor_days=20.0)
+    _run(
+        db,
+        monkeypatch,
+        listing_source="domklik",
+        ttl_days=14,
+        staleness_column="scraped_at",
+        revisit_floor_quantile=0.99,
+    )
+    floor_sql, _ = db.floor_query
+    assert "l.scraped_at" in floor_sql
+    assert "l.last_seen_at" not in floor_sql
+
+
+def test_floor_rejects_invalid_staleness_column(monkeypatch: pytest.MonkeyPatch) -> None:
+    """Whitelist колонки работает и на пути пола — интерполяции чужого имени нет."""
+    db = _FakeDB(floor_days=20.0)
+    with pytest.raises(ValueError):
+        _run(db, monkeypatch, staleness_column="is_active", revisit_floor_quantile=0.99)
+    assert db.executed == []
+
+
+def test_health_gate_still_wins_over_floor(monkeypatch: pytest.MonkeyPatch) -> None:
+    """Нездоровый источник блокируется гейтом ДО того, как считается пол:
+    лишний тяжёлый запрос по мёртвому источнику не нужен."""
+    db = _FakeDB(floor_days=74.3, confirmations=10)
+    out = _run(
+        db,
+        monkeypatch,
+        min_confirmations=500,
+        revisit_floor_quantile=0.99,
+    )
+    assert out["skipped_unhealthy"] == 1
+    assert db.floor_query[0] == ""
+    assert db.update_query[0] == ""
+
+
+def test_floor_sql_is_psycopg_v3_safe() -> None:
+    sql = str(task_mod._build_revisit_floor_sql("last_seen_at", with_segments=True).text)
+    assert "CAST(:revisit_quantile AS double precision)" in sql
+    assert "CAST(:health_window_days AS integer)" in sql
+    assert not re.search(r":\w+::", sql)
+    assert "UPDATE" not in sql.upper()
+    assert "DELETE" not in sql.upper()
+
+
+def test_floor_only_counts_rows_the_sweep_actually_refound() -> None:
+    """Пол меряет выживших, а не «мы к ним не приходили»: свежесть обязана СДВИНУТЬСЯ
+    относительно предыдущего снимка, иначе разрыв нулевой и хвост схлопнется в ноль."""
+    sql = str(task_mod._build_revisit_floor_sql("last_seen_at", with_segments=False).text)
+    assert "l.last_seen_at > prev.last_seen_at" in sql
+
+
+def test_default_quantile_is_high_enough_for_the_prod_case() -> None:
+    """Ниже 0.99 опускать нельзя без нового замера: именно на 0.99 прод-запрос даёт
+    34.0 для cian/vtorichka, что накрывает возраст ложных снятий 30.3."""
+    assert task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE >= 0.99
+    assert _PROD_FLOORS["cian/vtorichka"][3] > _FALSE_KILL_AGE_MAX
+
+
+def test_handler_wires_revisit_floor_from_schedule_params() -> None:
+    """Читаем исходник файлом: product_handlers тянет scraper_kit, которого в
+    юнит-окружении может не быть, а проверяем мы проводку, а не импорт."""
+    handlers = Path(__file__).resolve().parents[1] / "app" / "services" / "product_handlers.py"
+    src = handlers.read_text("utf-8")
+    job = src.split("async def _job_deactivate_stale")[1].split("\nasync def ")[0]
+    flat = " ".join(job.split())
+    assert 'params.get( "revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE )' in flat
+    assert "revisit_floor_quantile=revisit_floor_quantile" in job

From f3bcb1a25f6e52db22a34659b0ca89863aee5a01 Mon Sep 17 00:00:00 2001
From: bot-backend 
Date: Sun, 9 Aug 2026 17:38:40 +0000
Subject: [PATCH 17/23] =?UTF-8?q?fix(tradein/cian):=20=D0=BE=D0=B1=D0=BE?=
 =?UTF-8?q?=D0=B3=D0=B0=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=96=D0=9A=20?=
 =?UTF-8?q?=D0=BF=D0=B0=D0=B4=D0=B0=D0=BB=D0=BE=20=D0=BD=D0=B5=20=D0=BD?=
 =?UTF-8?q?=D0=B0=20=D1=80=D0=B0=D0=B7=D0=BC=D0=B5=D1=82=D0=BA=D0=B5,=20?=
 =?UTF-8?q?=D0=B0=20=D0=BD=D0=B0=20=D1=81=D0=BE=D0=B6=D0=B6=D1=91=D0=BD?=
 =?UTF-8?q?=D0=BD=D0=BE=D0=BC=20=D1=83=D0=B7=D0=BB=D0=B5=20(#2767)=20(#279?=
 =?UTF-8?q?8)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 tradein-mvp/backend/app/api/v1/admin.py       |   4 +-
 .../app/tasks/cian_history_backfill.py        |  14 +-
 .../app/tasks/newbuilding_enrich_backfill.py  |  18 ++-
 .../fixtures/cian_waf_block_zhk_page.html     |   1 +
 .../backend/tests/test_2767_cian_waf_block.py | 143 ++++++++++++++++++
 .../tests/test_2767_newbuilding_parse_miss.py |  17 ++-
 ...scraper_kit_group_c_backfill_kit_parity.py |   8 +-
 .../test_scraper_kit_newbuilding_endpoint.py  |  13 +-
 .../scraper_kit/providers/cian/newbuilding.py | 100 +++++++++---
 9 files changed, 278 insertions(+), 40 deletions(-)
 create mode 100644 tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html
 create mode 100644 tradein-mvp/backend/tests/test_2767_cian_waf_block.py

diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py
index 86a5539a..89d6ef8d 100644
--- a/tradein-mvp/backend/app/api/v1/admin.py
+++ b/tradein-mvp/backend/app/api/v1/admin.py
@@ -1953,7 +1953,9 @@ async def scrape_cian_newbuilding(
         save_newbuilding_enrichment,
     )
 
-    enrichment = await fetch_newbuilding(zhk_url, config=RealScraperConfig())
+    enrichment = await fetch_newbuilding(
+        zhk_url, config=RealScraperConfig(), proxy_provider=_kit_proxy_provider()
+    )
     if enrichment is None:
         raise HTTPException(404, f"Could not parse Cian newbuilding page: {zhk_url}")
 
diff --git a/tradein-mvp/backend/app/tasks/cian_history_backfill.py b/tradein-mvp/backend/app/tasks/cian_history_backfill.py
index b6c5907b..2cb69680 100644
--- a/tradein-mvp/backend/app/tasks/cian_history_backfill.py
+++ b/tradein-mvp/backend/app/tasks/cian_history_backfill.py
@@ -43,7 +43,11 @@ from sqlalchemy import text
 from sqlalchemy.orm import Session
 
 from app.core.config import settings
-from app.services.scraper_adapters import RealMatcherAdapter, RealScraperConfig
+from app.services.scraper_adapters import (
+    RealMatcherAdapter,
+    RealProxyProvider,
+    RealScraperConfig,
+)
 from app.services.scraper_settings import get_scraper_delay
 
 logger = logging.getLogger(__name__)
@@ -230,7 +234,13 @@ async def backfill_cian_history(
 
                 enrichment = None
                 try:
-                    enrichment = await fetch_newbuilding(zhk_url, config=RealScraperConfig())
+                    # proxy_provider (#2767): тот же сожжённый env-узел бил и сюда —
+                    # это второй вызывающий fetch_newbuilding, чинить надо оба.
+                    enrichment = await fetch_newbuilding(
+                        zhk_url,
+                        config=RealScraperConfig(),
+                        proxy_provider=RealProxyProvider(),
+                    )
                 except Exception as exc:
                     logger.warning(
                         "cian_newbuilding fetch failed for house_id=%s url=%s: %s",
diff --git a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py
index 93dd3113..abd51d13 100644
--- a/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py
+++ b/tradein-mvp/backend/app/tasks/newbuilding_enrich_backfill.py
@@ -399,9 +399,15 @@ async def backfill_newbuilding_enrichment(
         save_newbuilding_enrichment,
     )
 
-    from app.services.scraper_adapters import RealScraperConfig
+    from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
 
     scraper_config = RealScraperConfig()
+    # #2767: обогащение было ЕДИНСТВЕННЫМ cian-путём мимо пула прокси — весь сбор шёл
+    # через env-узел сайдкара, и когда Циан забанил его exit-IP, 8 суток по 25 попыток
+    # уходили в тот же адрес (страница блокировки вместо карточки). Провайдер здесь ≠
+    # «включить пул»: реально пул задействуется, только если включён
+    # config.use_proxy_pool_browser (build_browser_fetcher внутри fetch_newbuilding).
+    proxy_provider = RealProxyProvider()
 
     result = NewbuildingEnrichBackfillResult()
     t0 = time.time()
@@ -525,7 +531,9 @@ async def backfill_newbuilding_enrichment(
         # ── Fetch (network; anti-bot surface) ──────────────────────────────
         enrichment = None
         try:
-            enrichment = await fetch_newbuilding(zhk_url, config=scraper_config)
+            enrichment = await fetch_newbuilding(
+                zhk_url, config=scraper_config, proxy_provider=proxy_provider
+            )
         except Exception as exc:
             logger.warning(
                 "newbuilding fetch failed house_id=%s url=%s: %s", house_id, zhk_url, exc
@@ -535,8 +543,12 @@ async def backfill_newbuilding_enrichment(
             continue
 
         if enrichment is None:
+            # Без «(captcha / parse miss?)» (#2767): догадка автора кода в тексте лога
+            # читается дальше как факт и один раз уже увела диагноз не туда. Причина
+            # печатается строкой ВЫШЕ, в самом месте отказа (html_len + antibot_markers).
             logger.warning(
-                "newbuilding fetch returned None house_id=%s url=%s (captcha / parse miss?)",
+                "newbuilding fetch returned None house_id=%s url=%s — причина в строке "
+                "'initialState extraction failed' выше",
                 house_id,
                 zhk_url,
             )
diff --git a/tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html b/tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html
new file mode 100644
index 00000000..3fd69b35
--- /dev/null
+++ b/tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html
@@ -0,0 +1 @@
+Ошибка - ЦианISIDPlhwqgqcowpM8uZOWWzawmWX3JbKNOWV5y8jbBk1QTMxXSbNTCYzv2eY56%2BzgvWctsm1nNNBInjiTIE4wTKyIXqNmtYlOzmhaTK60Tn8LgFw465ooSTZRlZbWNAVRqsSkSbwX3cLXr%2BXama%2BvH5PpteyqjVoMEnZZ9fy7U9Mq6lNhNqPwTg6YASdghUaijPGTLJGRj1xv2zyp3nGfDDlmBEufGFsQSmWpaquZAFvWbMSIVagrgmWV%2FpwZY%2B05W7zk%2B48X94xHfjeo42qibiytt3w1NTImtWX6RAUPZOEWRMJX%2FDAhDnp6PpcNPWJMlIzO54LIEgTLTbPMeT8Zcz8bbz8bbz8bbz8bcxqGIlysW6SCSEHnI3OmWtyI1qNpU%2BaVl4fL4yPXGUHMlzpFO8g7x3nL5nBgMCqbNNM489eVZ8CQV07DTtFVFNVZlZbprTPdHkXK8dpDGUHO2YoUriDzZ%2FPcifCVNlgCap0b6RzwJbvsziCR09wO2bk%2B2Fh0BtH1djPV2M9XYz1djPV2MtsmyQM5f7xfaMjZdAbCdhzBtLjB7V9hsvkNjwjKUT8C9Zw3rBwyJ6wsNWNoze1g2nkHtUNoereRt7INYvs9XYwnf%2FAM6pU5R2c0UDE8gAHwP%2FAIr%2FxAA4EQABAwEFBAgFAgcBAAAAAAABAAIRECExQVGRAxIgYQQiUHGBobHRMDKSwdJA8BMjM0JgguHx%2F9oACAEDAQk%2FAPgbQSMJtW01sTgRy7OMNF5z5BWoJxgYK%2FEdmXus1oN5y2e6cx%2F2aXO7MuF6vCvx76f22I%2FK5vjJ7MxCwVrcimhs440F8Hs0dceYqLMEI7OHeEOqELB2g2Ozf%2FV8hN2QlGQUBC3UIPDu7qIgRhwRu4LdjQ13Ykwt0jzrgmhrcAb%2FABujuW7GivoC52QtPeYuHNbuyH1fiulR3NP5KH8gI%2B5Tf4buZ9wKxHNbqvqBGMrdKEOx4jACsYLh9zQ9QrIff4vOuayqOoNZ9qZ0vRlxvKEqE2EIcLjzXzM%2BDnw3L%2BmDfmeAzFnxedc1lQwSChAGFM6P3T3LbDQo2Zoq0H1WC%2BY%2FBz4f9jlyq0gH4%2FOuayV6Mk%2FuPBCwIRLQVnQwBem7ozdf4DEeK6S7ws9Cukv%2Bo%2B66Q8xhvO96Z1dDsDetsNFtm6LbN0Tt5wvMRTPgM7S7uqIi6ULCjIFLyhB4mzPC2RbwDqAWnxQIGHMZoWBXDzRuaAhNqvRkNN2EhCSiGhbb96LbQB97BhmihZQyBI0KEpiYmIQUEIqf5jssOavNBZhKuoIeL7L62u9RwCQaGokGhoJFLWnDgzKNGx3Zmhmgkug2YQZBPIkRQ2UAA5c6PKedU86oyeC%2BLFa5xv8Aag6oVgHBdiOeYpejaQOHaBhzIn1BXSR9I%2FFdJH0j8V0mQCLA0W%2BQptA05kT6grpE8g0ew4L65mtxV62IIzk%2ByY1nMkzpulHecbzn7dws4OXHlUWLrNOcSPRCLOESCpJm0EjyuQ63C2R3hM9EzzCZ5hM8wmeiEFBYpkjvCZ5j3QgCo3gbcL1s9SPdC1XH9yjvDIkSPRMKYVbOATZaeYTIH21oJB7ls%2FT3Qg1E6LZye8e6EH9C4hPOpTzqU86lPOpTzqUZKyo4jxTifHgcQnnWpTynnWjiE4mjiE86ngMJ51P6hknkmGf8M%2F%2FZ" alt="logo">

Обнаружен подозрительный трафик

Просмотрите возможные причины ошибки по ссылке ниже. Если ошибка повторяется, напишите в службу поддержки или на почту support@cian.ru.

Укажите в письме эти данные:

  • IP-адрес: 46.8.110.92
  • ID запроса: 9f4b43ee-bd7e-4b91-acde-639dac97a366
  • Код страницы: cian_waf_block
" +) + + @pytest.mark.asyncio async def test_waf_block_is_reported_as_a_ban_of_the_node(monkeypatch, waf_html, caplog) -> None: """Блок → `report_ban`, пока lease ещё жив. @@ -123,6 +136,35 @@ async def test_waf_block_is_reported_as_a_ban_of_the_node(monkeypatch, waf_html, assert "cian_waf_block" in "\n".join(r.getMessage() for r in caplog.records) +@pytest.mark.asyncio +async def test_parsed_page_never_bans_the_node_even_with_captcha_word(monkeypatch) -> None: + """Разобранная страница НЕ банит узел, даже если на ней написано «captcha». + + Живая регрессия 2026-08-09: первая версия этой правки звала `_blocked_by` на КАЖДОМ + ответе, и за первые 17 секунд прогона забанила два ЗДОРОВЫХ узла (9 и 10) на + страницах, которые успешно разобрались — обычная карточка ЖК грузит скрипт + SmartCaptcha, подстрока там есть всегда. Признак «есть подпись защиты» осмыслен + только вместе с «состояния нет»: список маркеров создавался объяснять УЖЕ + случившийся отказ разбора, а не служить самостоятельным детектором. + """ + fetcher = _spy_fetcher(_HEALTHY_CARD_WITH_CAPTCHA_SCRIPT) + monkeypatch.setattr( + "scraper_kit.providers.cian.newbuilding.build_browser_fetcher", + MagicMock(return_value=fetcher), + ) + + result = await fetch_newbuilding( + "https://zhk-pihtovyy-ekb-i.cian.ru", + config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"), + ) + + # Предпосылка теста: страница действительно и разбирается, и содержит слово-ловушку. + assert result is not None + assert result.cian_internal_house_id == 108855 + assert "captcha" in _HEALTHY_CARD_WITH_CAPTCHA_SCRIPT + fetcher.report_ban.assert_not_called() + + @pytest.mark.asyncio async def test_proxy_provider_reaches_the_fetcher_factory(monkeypatch, waf_html) -> None: """Провод целиком: provider из задачи → build_browser_fetcher → пул. diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py index a2d19d77..89034166 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py @@ -227,20 +227,27 @@ async def fetch_newbuilding( # прогон из 25 домов даёт до 25 acquire → до 25 релончей camoufox (~8 с каждый). # При паузе 18 с между домами это терпимо и вдобавок само по себе ротация IP. # Понадобится дешевле — поднимать фетчер в вызывающий цикл и передавать сюда. - async with browser: - html = await browser.fetch(zhk_url) - # Блок распознаётся ПОКА lease жив: после выхода из `async with` узел уже - # отпущен и `report_ban` стал бы no-op. Пул после этого не выдаёт узел под - # source='cian' (scrape_proxy_source_bans), и следующий дом идёт с другого IP. - blocked = _blocked_by(html) - if blocked is not None: - browser.report_ban(f"cian newbuilding: {blocked}") - # Cian ЖК-карточка: MFE 'newbuilding-card-desktop-frontend', key 'initialState' # (verified live 2026-06-15; перепроверено живьём 2026-08-09 — разметка та же). mfe = "newbuilding-card-desktop-frontend" - nb_state = extract_state(html, mfe=mfe, key="initialState") + async with browser: + html = await browser.fetch(zhk_url) + # Разбор — ВНУТРИ контекста, чтобы бан репортился, пока lease жив: после выхода + # из `async with` узел уже отпущен и `report_ban` стал бы no-op. + nb_state = extract_state(html, mfe=mfe, key="initialState") + # Условие бана — «состояния НЕТ и есть подпись защиты», а не одна подпись. + # Живая регрессия 2026-08-09 (поймана на проде через 17 секунд после деплоя): + # маркер по одному лишь тексту забанил два ЗДОРОВЫХ узла на странице, которая + # РАЗОБРАЛАСЬ, — обычная карточка ЖК грузит скрипт SmartCaptcha, и подстрока + # "captcha" на ней есть всегда. Список маркеров создавался для объяснения УЖЕ + # СЛУЧИВШЕГОСЯ отказа разбора (`_describe_parse_miss` зовётся только оттуда); + # применять его как самостоятельный детектор — менять смысл признака. + if nb_state is None: + blocked = _blocked_by(html) + if blocked is not None: + browser.report_ban(f"cian newbuilding: {blocked}") + if nb_state is None: logger.warning( "Cian newbuilding %s: initialState extraction failed — %s", From 7cd8c63b895324b9215d2d7addec96c499bd968f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 9 Aug 2026 18:15:55 +0000 Subject: [PATCH 20/23] =?UTF-8?q?fix(tradein/proxy):=20=D1=83=D0=B7=D0=B5?= =?UTF-8?q?=D0=BB,=20=D0=B7=D0=B0=D1=80=D0=B5=D0=B7=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=B7?= =?UTF-8?q?=D0=B0=20=D0=94=D0=BE=D0=BC=D0=BA=D0=BB=D0=B8=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?,=20=D0=B4=D0=BE=20=D0=94=D0=BE=D0=BC=D0=BA=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D0=B4=D0=BE=D1=85=D0=BE=D0=B4=D0=B8?= =?UTF-8?q?=D1=82=20(#2800=20A)=20(#2802)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/proxy_pool.py | 23 +++++--- ...scrape_proxy_domclick_affinity_release.sql | 52 +++++++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py index ed40dc63..5e2cb9c5 100644 --- a/tradein-mvp/backend/app/services/proxy_pool.py +++ b/tradein-mvp/backend/app/services/proxy_pool.py @@ -228,14 +228,21 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe чужая — только запасной вариант, чтобы источник не голодал при живых свободных узлах чужой affinity (#2600). - Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity — см. - 173_scrape_proxies_add_domclick_affinity.sql: у domclick ровно один узел (id=1), - намеренно вырезанный из общего пула, потому что QRATOR банит все прокси кроме этого - одного чистого residential-адреса. Если fallback заберёт его под avito/cian/yandex, - domclick останется без прокси вообще — хуже, чем голодание исходного источника, - которое фикс призван устранить. Кандидат участвует в fallback, только если его - affinity='any' ИЛИ у этой affinity есть ДРУГОЙ enabled-узел (EXISTS-подзапрос) — - т.е. выдача не обнулит доступность выделенной affinity целиком. + Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity: если + fallback заберёт его под чужой источник, «свой» останется без прокси вообще — хуже, + чем голодание исходного источника, которое фикс призван устранить. Кандидат + участвует в fallback, только если его affinity='any' ИЛИ у этой affinity есть ДРУГОЙ + enabled-узел (EXISTS-подзапрос) — т.е. выдача не обнулит доступность выделенной + affinity целиком. + + Исторический повод для этой защиты (173_scrape_proxies_add_domclick_affinity.sql — + единственный residential-узел id=1, закреплённый за domclick, потому что QRATOR + банил остальные) снят миграцией 253 (#2800): живая проба показала, что как раз до + рабочего хоста Домклика (bff-search-web.domclick.ru) этот узел НЕ доходит, а + Авито/Яндекс через него работают — резервация держала узел за источником, которому + он не годен, и прятала от тех, кому годен. Узлов с выделенной affinity на проде + сейчас нет, но САМА защита остаётся: значение 'domclick' допустимо констрейнтом, и + следующий выделенный узел должен получить её сразу, а не после повторного разбора. ОБА запроса отсекают узлы с АКТИВНЫМ баном по ЭТОМУ provider'у (scrape_proxy_source_bans.banned_until > now(), #2600 п.2) — узел, забаненный Авито, diff --git a/tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql b/tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql new file mode 100644 index 00000000..b1f63939 --- /dev/null +++ b/tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql @@ -0,0 +1,52 @@ +-- 253_scrape_proxy_domclick_affinity_release.sql +-- Снять с узла резервацию provider_affinity='domclick' (#2800). +-- +-- WHY (замер, не рассуждение — живая проба 09.08.2026, тракт сайдкар+camoufox, +-- POST /fetch на robots.txt рабочего хоста каждой площадки): +-- +-- узел | affinity | avito | ekb.cian.ru | realty.yandex.ru | bff-search-web.domclick.ru +-- -----+----------+-------+--------------------+------------------+--------------------------- +-- 1 | domclick | 200 | 200 «Ошибка — Циан»| 200 | 500 NS_ERROR_PROXY_BAD_GATEWAY +-- 9 | any | 200 | 200 | 200 | 200 +-- 10 | any | 200 | 200 | 200 | 200 +-- 11 | any | 200 | 200 | 200 | 200 +-- +-- Узел, закреплённый 173-й миграцией СПЕЦИАЛЬНО за Домкликом, до рабочего хоста +-- Домклика не доходит вообще (NS_ERROR_PROXY_BAD_GATEWAY на bff-search-web — +-- именно туда ходит боевой сбор, см. providers/domclick/serp.py::_BFF_BASE), при +-- этом Авито и Яндекс через него отвечают штатно. Резервация даёт ровно обратный +-- эффект задуманному: единственный источник, которому узел ГОДЕН НЕ БЫЛ, держал его +-- за собой, а два источника, которым он годен, его не видели — +-- acquire('avito'|'yandex') отбирает по provider_affinity IN (source,'any'), а +-- fallback этот узел не берёт (защита последнего узла выделенной affinity). +-- +-- 'any', а НЕ enabled=false: узел жив для двух площадок из четырёх, выключать его +-- целиком — терять четверть и без того дефицитного пула (#2638). +-- +-- WHAT: +-- provider_affinity='domclick' → 'any' для узлов, у которых affinity именно такая. +-- CHECK-констрейнт (173) не трогаем: значение 'domclick' остаётся допустимым, если +-- в пуле появится узел, который до Домклика реально доходит. +-- +-- ЧТО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ (граница честная): +-- Она НЕ чинит Домклик. acquire('domclick') и до неё видел все четыре узла +-- (affinity IN ('domclick','any')), т.е. шанс вытянуть узел 1 и потратить первый +-- бакет впустую был и остаётся 1/4 — закрывает это проба по паре «узел × источник» +-- (#2800 часть B), а не смена affinity. Здесь снимается только резервация. +-- +-- IDEMPOTENCY / SAFETY: +-- Один UPDATE в транзакции; повторный прогон не находит строк (no-op) — auto-apply +-- strict на деплое это требует. Блокирующего DDL нет (см. +-- scripts/check-migration-lock-timeout.py: правило про ALTER/DROP/CREATE INDEX), +-- UPDATE берёт row-lock на единичные строки. +-- +-- Dependencies: 157_scrape_proxies.sql, 173_scrape_proxies_add_domclick_affinity.sql + +BEGIN; + +UPDATE scrape_proxies +SET provider_affinity = 'any', + updated_at = now() +WHERE provider_affinity = 'domclick'; + +COMMIT; From 08bb9d6549b106aac5b8b4927ba512b01f86f8ec Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 9 Aug 2026 18:28:37 +0000 Subject: [PATCH 21/23] =?UTF-8?q?fix(tradein/proxy):=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=20=D1=81=D0=BF=D1=80=D0=B0=D1=88=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BA=D0=B0=D0=B6=D0=B4=D1=83=D1=8E=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=BE=D1=89=D0=B0=D0=B4=D0=BA=D1=83,=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B4=D0=B8=D0=BA=D1=82=20=D0=BF=D0=B8=D1=88=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=BD=D0=B0=20=D0=BF=D0=B0=D1=80=D1=83=20=C2=AB?= =?UTF-8?q?=D1=83=D0=B7=D0=B5=D0=BB=20=C3=97=20=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=B8=D0=BA=C2=BB=20(#2800=20B)=20(#2803)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/proxy_pool.py | 281 +++++++++++-- .../backend/tests/services/test_proxy_pool.py | 8 +- .../backend/tests/test_2723_browser_probe.py | 18 +- .../tests/test_2800_per_source_probe.py | 382 ++++++++++++++++++ .../src/scraper_kit/browser_fetcher.py | 79 +++- 5 files changed, 717 insertions(+), 51 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_2800_per_source_probe.py diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py index 5e2cb9c5..bf1f26e7 100644 --- a/tradein-mvp/backend/app/services/proxy_pool.py +++ b/tradein-mvp/backend/app/services/proxy_pool.py @@ -91,6 +91,28 @@ Sticky session lease (browser-путь, живая регрессия 2026-08): «Непригоден для браузера» — это НЕ исключение из пула: acquire() лишь отдаёт такой узел последним (ORDER BY), потому что при 4 узлах (#2638) голодание хуже. +Проба на ПАРУ «узел × источник» (#2800, продолжение #2723): + - #2723 починил ТРАНСПОРТ пробы (ходить браузером, как работа). Ходила она при этом + для всех узлов на один зашитый адрес — robots.txt Авито. Прокси-узел не «жив/мёртв» + вообще: замер на проде 09.08.2026 — узел id=1 отдаёт 200 на Авито и Яндексе и 500 + NS_ERROR_PROXY_BAD_GATEWAY на рабочем хосте Домклика, имея browser_fail_streak=0 и + свежую пробу. Зелёная проба означала «годен для Авито», а читалась как «годен». + - Теперь каждый узел за такт опрашивается по КАЖДОМУ источнику, который ему может + достаться (browser_fetcher.PROBE_SOURCES ∩ affinity), по РАБОЧЕМУ хосту площадки + (apex-домен не годится: `domclick.ru` через узел id=1 отвечает 200, а + `bff-search-web.domclick.ru`, куда ходит сбор, — 500). + - Вердикт пары пишется В СУЩЕСТВУЮЩУЮ таблицу scrape_proxy_source_bans (новой + сущности не заводим — эта ровно про пару и её уже читает acquire): подтверждённый + отказ → строка бана с reason=_PROBE_BAN_REASON, успех → снятие СВОЕЙ строки. + Чужие строки (бан, распознанный боевым сбором) проба не трогает — robots.txt + площадка отдаёт и забаненному IP, так что дешёвый успех не имеет права стирать + дорогой вердикт живого сбора (тот же принцип, что «ipify не стирает браузерный»). + - Узловые поля (browser_fail_streak/browser_unfit_since) сохраняют своё значение + «браузерный тракт через узел не работает ВООБЩЕ» и обновляются по итогу ВСЕГО + креста: хоть одна зелёная площадка → ok; все красные транспортом → провал узла. + Отказ одной площадки узел глобально не пятнает — иначе мы бы своими руками + вернули то самое схлопывание диагнозов. + psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. """ @@ -125,6 +147,7 @@ __all__ = [ "mark_banned", "mark_browser_health", "mark_health", + "mark_source_probe", "reap_stale_leases", "release", "run_proxy_healthcheck", @@ -195,6 +218,26 @@ BROWSER_PROBE_MINUTES = 360 # намеренно не обновляется (см. mark_browser_health). BROWSER_UNFIT_THRESHOLD = 2 +# ── проба на пару «узел × источник» (#2800) ────────────────────────────────── +# ЦЕНА, посчитанная до правки (замер 09.08.2026, тот же тракт): +# - было: 4 узла × 1 адрес / 360 мин = 16 навигаций в сутки, все на Авито; +# - стало: 4 узла × 4 источника / 360 мин = 64 навигации в сутки, то есть +# 16 robots.txt НА ПЛОЩАДКУ в сутки против ~1000 боевых /fetch; +# - одна проба 9–18 с (замерено) → такт с крестом ~3 мин против ~50 с; прогонов +# healthcheck с браузерной пробой по-прежнему 4 в сутки (гейт browser_check_at). +# Запусков camoufox НЕ прибавляется пропорционально: сайдкар релончит браузер при +# смене ЖЕЛАЕМОГО прокси, а крест идёт узел-за-узлом — 4 релонча за такт, как и было. +# Разрежённая схема (по одному источнику за такт, round-robin) рассматривалась и +# отвергнута: вердикт пары протухал бы до 24 ч при бане в 6 ч — окно, в котором +# acquire снова выдаёт узел, не спросив. +# +# Причина в scrape_proxy_source_bans, которой владеет ИМЕННО проба. Отличает её +# вердикт от бана, распознанного боевым сбором (mark_banned из report_ban): успешная +# проба снимает ТОЛЬКО свои строки. Без этого дешёвый robots.txt, который площадка +# отдаёт и забаненному IP, стирал бы дорогой вердикт живого сбора — ровно ошибка +# #2723 («дешёвая проба стирает вердикт дорогого тракта»), только на паре. +_PROBE_BAN_REASON = "probe:browser" + # deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для # mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что # в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не @@ -682,9 +725,14 @@ def mark_browser_health( return "fail" -def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: +def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = None) -> None: """Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2. + `reason` попадает в одноимённую колонку и служит МЕТКОЙ ВЛАДЕЛЬЦА строки: по + умолчанию 'banned:' (бан распознан боевым сбором), у браузерной пробы — + _PROBE_BAN_REASON (#2800). Снимать чужую строку никто не должен, поэтому + clear_source_bans умеет фильтровать по ней (`only_reason`). + Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация — транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно @@ -820,7 +868,7 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: { "proxy_id": proxy_id, "source": source, - "reason": f"banned:{source}", + "reason": reason or f"banned:{source}", "base_hours": SOURCE_BAN_BASE_HOURS, "max_hours": SOURCE_BAN_MAX_HOURS, "max_fails": MAX_CONSECUTIVE_FAILS, @@ -866,7 +914,14 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None: ) -def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, reason: str) -> int: +def clear_source_bans( + db: Session, + proxy_id: int, + *, + source: str | None = None, + reason: str, + only_reason: str | None = None, +) -> int: """Снять баны узла по источникам (#2600 п.2). Returns число снятых строк. ЗАЧЕМ ОТДЕЛЬНАЯ РУЧКА: до п.2 ложный бан лечился оператором через @@ -889,6 +944,13 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, SOURCE_BAN_BASE_HOURS. `reason` идёт только в лог (человекочитаемый повод — «manual enable», «ip rotated»). + + `only_reason` — ФИЛЬТР по колонке reason, т.е. «снимать только строки, которые + написал я» (#2800). Нужен браузерной пробе: её успешный robots.txt — слабое + свидетельство, площадка отдаёт его и забаненному IP, поэтому снимать им бан, + распознанный боевым сбором по капче/QRATOR-заглушке, нельзя. Оператор и ротация + IP этот фильтр НЕ ставят: там повод как раз объявить историю пары недействительной + целиком. None — снимать всё, как и раньше. """ rows = db.execute( text( @@ -896,10 +958,11 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, DELETE FROM scrape_proxy_source_bans WHERE proxy_id = CAST(:proxy_id AS bigint) AND (CAST(:source AS text) IS NULL OR source = CAST(:source AS text)) + AND (CAST(:only_reason AS text) IS NULL OR reason = CAST(:only_reason AS text)) RETURNING source """ ), - {"proxy_id": proxy_id, "source": source}, + {"proxy_id": proxy_id, "source": source, "only_reason": only_reason}, ).fetchall() db.commit() if rows: @@ -913,6 +976,75 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, return len(rows) +def mark_source_probe( + db: Session, + proxy_id: int, + *, + source: str, + ok: bool, + fail_kind: str | None = None, + detail: str = "", +) -> str: + """Записать вердикт браузерной пробы по ПАРЕ «узел × источник» (#2800). + + Пара — то, чего до сих пор не хватало: узел не «жив/мёртв» вообще, он годен или + не годен КОНКРЕТНОЙ площадке. Хранилище для этого уже есть и его уже читает + `acquire(source)` — `scrape_proxy_source_bans`; новой сущности не заводим. + + КОМУ ПРИНАДЛЕЖИТ ОТКАЗ (шкала та же, что у `classify_browser_probe`, но граница + другая — здесь судится ПАРА, а не узел): + - "sidecar" — общая зависимость лежит, к паре отношения не имеет → "ignored". + Иначе одна упавшая зависимость забанила бы разом все пары (#2686 в третий раз); + - "proxy" — через этот узел до площадки не доходит транспорт + (NS_ERROR_PROXY_*, camoufox не поднялся) → бан пары; + - "page" — дошли, но площадка отдала ЭТОМУ exit-IP не ресурс, а заглушку + (200 + «Ошибка — Циан» вместо robots.txt) → тоже бан пары. + Для УЗЛА этот исход по-прежнему «не виноват» (см. mark_browser_health), для + ПАРЫ — виноват ровно он: собирать через такой узел эту площадку нельзя. + + Успех снимает ТОЛЬКО строку, написанную пробой (`only_reason`). Бан, распознанный + боевым сбором, остаётся: robots.txt площадка отдаёт и забаненному IP, и разрешить + дешёвой пробе гасить дорогой вердикт значило бы повторить #2723 на паре. + + Защита последнего узла и эскалация срока — целиком из `mark_banned`, здесь ничего + своего: если после бана у `acquire(source)` не осталось бы кандидатов, бан не + пишется (голодание хуже работы через плохой узел). + + Returns: "ok" | "cleared" (сняли свой бан) | "banned" | "ignored". + """ + if ok: + cleared = clear_source_bans( + db, + proxy_id, + source=source, + reason=f"browser probe OK for source={source} ({detail})", + only_reason=_PROBE_BAN_REASON, + ) + return "cleared" if cleared else "ok" + + if fail_kind not in ("proxy", "page"): + logger.warning( + "proxy_pool: pair probe FAILED id=%d source=%s, но отказ НЕ принадлежит паре " + "(fail_kind=%s): %s — вердикт не пишем", + proxy_id, + source, + fail_kind, + detail, + ) + return "ignored" + + logger.warning( + "proxy_pool: pair probe FAILED id=%d source=%s (fail_kind=%s): %s — пишем бан " + "пары, узел остаётся первосортным для остальных площадок (#2800)", + proxy_id, + source, + fail_kind, + detail, + ) + mark_banned(db, proxy_id, source=source, reason=_PROBE_BAN_REASON) + return "banned" + + def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int: """Освободить lease'ы старше older_than_minutes (упавший sweep не вызвал release). @@ -976,8 +1108,39 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No return False, None, None, "other" -async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) -> str: - """Одна браузерная проба узла + запись вердикта. Returns исход mark_browser_health. +def _probe_sources_for(affinity: str) -> list[str]: + """Источники, которым узел с такой affinity МОЖЕТ достаться (#2800). + + Ровно предикат основной выборки `acquire`: `provider_affinity IN (:source,'any')`. + Спрашивать площадки, которым узел всё равно не выдадут, — платить за диагностику, + которой никто не воспользуется. + + ponytail: fallback-заход acquire умеет отдать узел и чужому источнику (когда своих + свободных нет) — такая пара останется без вердикта и решится как раньше, по факту + прогона. Полный крест по ВСЕМ источникам для каждого узла стоил бы столько же + только на проде (там сейчас все узлы 'any'), а на пуле с выделенными affinity рос + бы зря. Если fallback станет частым — снять условие, цена известна: N_узлов × 4. + """ + from scraper_kit.browser_fetcher import PROBE_SOURCES + + return [s for s in PROBE_SOURCES if affinity in (s, "any")] + + +async def _run_pair_probes( + db: Session, proxy_id: int, url: str, kind: str, affinity: str +) -> tuple[str, dict[str, int]]: + """Крест «этот узел × каждая его площадка» + запись вердиктов (#2800). + + Возвращает (исход mark_browser_health для УЗЛА, счётчики по парам). + + Два уровня вердикта, и они не пересекаются: + - ПАРА (`mark_source_probe` → scrape_proxy_source_bans) — по каждой площадке + отдельно, это то, что читает `acquire(source)`; + - УЗЕЛ (`mark_browser_health` → browser_fail_streak/browser_unfit_since) — по + итогу ВСЕГО креста: хоть одна площадка ответила → браузерный тракт через узел + работает (ok); все отказали транспортом → отказ узла. Отказ ОДНОЙ площадки + узел глобально не пятнает — иначе на месте вылеченного схлопывания диагнозов + появилось бы новое. Best-effort: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет healthcheck — ipify-часть уже отработала и её результат записан. Диагностика не @@ -985,18 +1148,60 @@ async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) -> """ from scraper_kit.browser_fetcher import probe_proxy_via_browser - try: - ok, fail_kind, detail = await probe_proxy_via_browser( - _settings.browser_http_endpoint, url, proxy_kind=kind + counters = {"pair_checked": 0, "pair_banned": 0, "pair_cleared": 0} + fail_kinds: list[str] = [] + any_ok = False + last_detail = "" + + for source in _probe_sources_for(affinity): + try: + ok, fail_kind, detail = await probe_proxy_via_browser( + _settings.browser_http_endpoint, url, proxy_kind=kind, source=source + ) + if not ok and fail_kind == "proxy": + # Подтверждение НЕМЕДЛЕННО, а не через такт: запуск camoufox бывает + # флаки сам по себе, а бан пары стоит источнику 6 часов узла. Повтор + # идёт по уже поднятому браузеру с тем же прокси — секунды, и только + # на отказах. Порог «2 подряд» у УЗЛОВОГО вердикта живёт своей жизнью + # (BROWSER_UNFIT_THRESHOLD), здесь он был бы сутками ожидания. + ok, fail_kind, detail = await probe_proxy_via_browser( + _settings.browser_http_endpoint, url, proxy_kind=kind, source=source + ) + except Exception: + logger.warning( + "proxy_pool: pair probe crashed id=%d source=%s — вердикт не записан", + proxy_id, + source, + exc_info=True, + ) + continue + + counters["pair_checked"] += 1 + last_detail = detail + if ok: + any_ok = True + else: + fail_kinds.append(fail_kind or "other") + outcome = mark_source_probe( + db, proxy_id, source=source, ok=ok, fail_kind=fail_kind, detail=detail ) - except Exception: - logger.warning( - "proxy_pool: browser probe crashed for proxy id=%d — вердикт не записан", - proxy_id, - exc_info=True, - ) - return "ignored" - return mark_browser_health(db, proxy_id, ok, fail_kind=fail_kind, detail=detail) + if outcome == "banned": + counters["pair_banned"] += 1 + elif outcome == "cleared": + counters["pair_cleared"] += 1 + + if counters["pair_checked"] == 0: + return "ignored", counters # крест не состоялся — узел не судим + + if any_ok: + return mark_browser_health(db, proxy_id, True, detail=last_detail), counters + # Все площадки отказали. Узлу это принадлежит, только если КАЖДЫЙ отказ — + # транспортный: смесь с "page"/"sidecar" значит «дело не (только) в узле». + node_kind = "proxy" if all(k == "proxy" for k in fail_kinds) else fail_kinds[0] + return ( + mark_browser_health(db, proxy_id, False, fail_kind=node_kind, detail=last_detail), + counters, + ) def _mask(url: str) -> str: @@ -1032,18 +1237,20 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: В конце — purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад (см. комментарий у самого DELETE: отложенность — это и есть сброс ban_count). - БРАУЗЕРНАЯ ПРОБА (#2723): узлам, прошедшим ipify и не проверявшимся браузером - дольше BROWSER_PROBE_MINUTES, дополнительно гоняется проба ЧЕРЕЗ САЙДКАР (тот же - тракт, что у боевого сбора: camoufox стартует с этим прокси, потом навигация на - robots.txt площадки). Её вердикт идёт в ОТДЕЛЬНЫЕ поля (mark_browser_health) и - никогда не смешивается с consecutive_fails/enabled. Гейт — settings. - use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и проба - измеряла бы то, чем никто не пользуется. + БРАУЗЕРНАЯ ПРОБА (#2723, на пару — #2800): узлам, прошедшим ipify и не + проверявшимся браузером дольше BROWSER_PROBE_MINUTES, гоняется КРЕСТ проб ЧЕРЕЗ + САЙДКАР — по одной навигации на каждую площадку, которую этот узел может + обслуживать (тот же тракт, что у боевого сбора: camoufox стартует с этим прокси, + потом навигация на robots.txt РАБОЧЕГО хоста площадки). Вердикт пары идёт в + scrape_proxy_source_bans (его читает acquire(source)), вердикт узла — в отдельные + browser_*-поля; ни один из них не смешивается с consecutive_fails/enabled. Гейт — + settings.use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и + проба измеряла бы то, чем никто не пользуется. Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на один и тот же upstream-endpoint (ipify) не нужен. Returns counters {reaped, checked, ok, failed, revived, bans_purged, browser_checked, browser_ok, - browser_unfit, browser_refit}. + browser_unfit, browser_refit, pair_checked, pair_banned, pair_cleared}. """ reaped = reap_stale_leases(db) @@ -1051,7 +1258,7 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: db.execute( text( """ - SELECT id, url, kind, enabled, disabled_reason, + SELECT id, url, kind, enabled, disabled_reason, provider_affinity, (browser_check_at IS NULL OR browser_check_at < now() - make_interval( mins => CAST(:browser_probe_minutes AS integer) @@ -1082,6 +1289,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: browser_ok = 0 browser_unfit = 0 browser_refit = 0 + pair_checked = 0 + pair_banned = 0 + pair_cleared = 0 for row in proxies: proxy_id = int(row["id"]) url = str(row["url"]) @@ -1111,8 +1321,13 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: # вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет # не тот узел», из-за которого #2723 и появилась. if ok and row["browser_probe_due"] and _settings.use_proxy_pool_browser: - outcome = await _run_browser_probe(db, proxy_id, url, str(row["kind"])) + outcome, pair_counters = await _run_pair_probes( + db, proxy_id, url, str(row["kind"]), str(row["provider_affinity"]) + ) browser_checked += 1 + pair_checked += pair_counters["pair_checked"] + pair_banned += pair_counters["pair_banned"] + pair_cleared += pair_counters["pair_cleared"] if outcome in ("ok", "refit"): browser_ok += 1 if outcome == "refit": @@ -1142,7 +1357,8 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: logger.info( "proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d " - "bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d", + "bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d " + "pair_checked=%d pair_banned=%d pair_cleared=%d", reaped, checked, ok_count, @@ -1153,6 +1369,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: browser_ok, browser_unfit, browser_refit, + pair_checked, + pair_banned, + pair_cleared, ) return { "reaped": reaped, @@ -1167,4 +1386,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: "browser_ok": browser_ok, "browser_unfit": browser_unfit, "browser_refit": browser_refit, + # Вердикты по ПАРАМ (#2800). Тоже отдельно от узловых: browser_ok=1 и + # pair_banned=2 одновременно — это не противоречие, а точный диагноз + # «браузер через узел работает, но две площадки его не пускают». + "pair_checked": pair_checked, + "pair_banned": pair_banned, + "pair_cleared": pair_cleared, } diff --git a/tradein-mvp/backend/tests/services/test_proxy_pool.py b/tradein-mvp/backend/tests/services/test_proxy_pool.py index a62f9886..bd360aed 100644 --- a/tradein-mvp/backend/tests/services/test_proxy_pool.py +++ b/tradein-mvp/backend/tests/services/test_proxy_pool.py @@ -397,12 +397,18 @@ class FakeSession: ) if "DELETE FROM scrape_proxy_source_bans" in sql and "proxy_id = CAST" in sql: - # clear_source_bans: снять баны узла (все либо один source), #2600 п.2 + # clear_source_bans: снять баны узла (все либо один source), #2600 п.2. + # Фильтр по reason (#2800) гейтим по подстроке боевого SQL — как ban-фильтры + # в acquire-ветке: иначе мок «чинил» бы код, который фильтра не содержит, и + # тест на «успешная проба не гасит чужой бан» остался бы зелёным на сломанном. + filters_reason = "reason = CAST(:only_reason AS text)" in sql + only_reason = p.get("only_reason") if filters_reason else None cleared = [ b for b in self.bans if b["proxy_id"] == p["proxy_id"] and (p["source"] is None or b["source"] == p["source"]) + and (only_reason is None or b.get("reason") == only_reason) ] self.bans = [b for b in self.bans if b not in cleared] return _FakeResult([{"source": b["source"]} for b in cleared]) diff --git a/tradein-mvp/backend/tests/test_2723_browser_probe.py b/tradein-mvp/backend/tests/test_2723_browser_probe.py index 0bfb8dae..f44e0181 100644 --- a/tradein-mvp/backend/tests/test_2723_browser_probe.py +++ b/tradein-mvp/backend/tests/test_2723_browser_probe.py @@ -260,27 +260,35 @@ async def test_healthcheck_marks_unfit_when_http_green_browser_red( row = db._by_id(1) assert row["browser_unfit_since"] is not None assert row["enabled"] is True and row["consecutive_fails"] == 0 - assert len(calls) == 2 + # За такт узел опрашивается по КАЖДОЙ обслуживаемой площадке (#2800), а каждый + # транспортный отказ ещё и подтверждается повтором: 4 источника × 2 попытки за + # прогон. Узловой вердикт по-прежнему один на прогон — browser_checked == 1 выше. + assert len(calls) == 2 * 4 * 2 async def test_healthcheck_browser_probe_respects_slow_tick( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет.""" + """Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет. + + Крест по площадкам (#2800) такт НЕ участил: он умножил цену ОДНОГО прогона на + число обслуживаемых источников (здесь 4), а прогонов с браузерной пробой + по-прежнему один на BROWSER_PROBE_MINUTES. + """ calls: list[str] = [] _patch_probes(monkeypatch, calls=calls) db = FakeSession([_proxy(1)]) await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] - assert len(calls) == 1 + assert len(calls) == 4 await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] - assert len(calls) == 1, "браузерная проба обязана идти реже ipify — она стоит camoufox" + assert len(calls) == 4, "браузерная проба обязана идти реже ipify — она стоит camoufox" db._by_id(1)["browser_check_at"] = datetime.now(UTC) - timedelta( minutes=BROWSER_PROBE_MINUTES + 1 ) await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] - assert len(calls) == 2 + assert len(calls) == 8 async def test_healthcheck_skips_browser_probe_when_http_dead( diff --git a/tradein-mvp/backend/tests/test_2800_per_source_probe.py b/tradein-mvp/backend/tests/test_2800_per_source_probe.py new file mode 100644 index 00000000..7eca4afd --- /dev/null +++ b/tradein-mvp/backend/tests/test_2800_per_source_probe.py @@ -0,0 +1,382 @@ +"""#2800 — браузерная проба спрашивает КАЖДУЮ площадку, вердикт пишется на ПАРУ. + +Продолжение #2723 на другой оси. Там проба ходила не тем транспортом; здесь — +верным транспортом, но всегда на один адрес (robots.txt Авито), поэтому её зелёный +ответ означал «узел годен для Авито», а читался как «узел годен вообще». + +Замер на проде 09.08.2026 (тот же тракт: сайдкар → camoufox с этим прокси → навигация): + + узел | affinity | avito | ekb.cian.ru | realty.ya.ru | bff-search-web.domclick.ru + -----+----------+-------+---------------------+--------------+--------------------------- + 1 | domclick | 200 | 200 «Ошибка — Циан» | 200 | 500 NS_ERROR_PROXY_BAD_GATEWAY + 10 | any | 200 | 200 | 200 | 200 + +Что сторожится (каждый тест ниже падает на коде до фикса): + + 1. ГЛАВНОЕ: узел, зелёный по Авито и мёртвый по Домклику, ОТБРАКОВЫВАЕТСЯ для + Домклика и остаётся первосортным для Авито. До фикса `acquire('domclick')` + выдавал его как ни в чём не бывало. + 2. Адрес пробы — рабочий хост КАЖДОЙ площадки, а не один зашитый и не apex-домен + (`domclick.ru` через тот же узел отвечает 200 — проба по нему была бы зелёной). + 3. HTTP 200 с заглушкой вместо robots.txt — это отказ пары, а не успех. + 4. Успешная проба снимает ТОЛЬКО свою строку бана: robots.txt площадка отдаёт и + забаненному IP, и гасить им вердикт живого сбора нельзя (та же ошибка, что + «дешёвая ipify стирает браузерный вердикт» в #2723). + 5. Отказ ОДНОЙ площадки не пятнает узел глобально; отказ ВСЕХ — пятнает (узловой + вердикт #2723 сохранён). + 6. Лежащий сайдкар не пишет ни одного бана пары (#2686-класс). + 7. Цена такта названа числом и закреплена: узлов × обслуживаемых источников. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest +import scraper_kit.browser_fetcher as bf + +from app.services import proxy_pool +from app.services.proxy_pool import BROWSER_UNFIT_THRESHOLD, acquire, release +from tests.services.test_proxy_pool import FakeSession, _proxy + +# Живой замер с прода 09.08.2026 — узел h1 мёртв для Домклика и годен остальным. +_LIVE_MATRIX: dict[tuple[str, str], tuple[bool, str | None, str]] = { + ("http://u:p@h1:8080", "domclick"): ( + False, + "proxy", + '{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}', + ), +} + + +def _patch_probes( + monkeypatch: pytest.MonkeyPatch, + matrix: dict[tuple[str, str], tuple[bool, str | None, str]], + calls: list[tuple[str, str]] | None = None, + *, + default: tuple[bool, str | None, str] = (True, None, "html_len=16477"), +) -> None: + """ipify всегда зелёная; браузерная проба отвечает по матрице (прокси, источник).""" + + async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]: + return True, "1.2.3.4", 10, None + + async def _fake_browser( + endpoint: str, proxy_url: str, **kw: Any + ) -> tuple[bool, str | None, str]: + source = str(kw.get("source", "avito")) + if calls is not None: + calls.append((proxy_url, source)) + return matrix.get((proxy_url, source), default) + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http) + monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True) + monkeypatch.setattr(bf, "probe_proxy_via_browser", _fake_browser) + + +# ── 1. главное: вердикт разведён по источникам ─────────────────────────────── + + +async def test_node_dead_for_domclick_is_not_issued_to_domclick( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Узел зелёный по Авито и мёртвый по Домклику: Домклику НЕ выдаём, Авито — выдаём. + + Именно этот сценарий четверо суток давал `domclick_city_sweep` ноль лотов при + `browser_fail_streak=0` и свежей пробе. + """ + _patch_probes(monkeypatch, _LIVE_MATRIX) + db = FakeSession([_proxy(1), _proxy(2)]) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + # Домклику достаётся только исправный узел… + first = acquire(db, "domclick") # type: ignore[arg-type] + assert first is not None and first.id == 2 + + # …а когда он занят, Домклик остаётся БЕЗ прокси, вместо того чтобы получить + # заведомо мёртвый узел 1 и сжечь на нём прогон. ЭТО и есть поломка, ради которой + # заведена задача: до фикса здесь выдавался узел 1 с browser_fail_streak=0. + assert acquire(db, "domclick") is None, ( # type: ignore[arg-type] + "Домклику выдан узел, у которого рабочий хост Домклика отвечает NS_ERROR_PROXY_BAD_GATEWAY" + ) + + # Для Авито тот же узел 1 — полноценный кандидат: бан у пары, не у узла. + lease = acquire(db, "avito") # type: ignore[arg-type] + assert lease is not None and lease.id == 1 + + ban = db._ban(1, "domclick") + assert ban is not None + assert ban["reason"] == "probe:browser", "строку должна опознавать сама проба" + assert db._ban(1, "avito") is None and db._ban(2, "domclick") is None + assert counters["pair_banned"] == 1 + + +async def test_one_dead_platform_does_not_stain_the_node_globally( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Узловой вердикт остаётся про узел: одна мёртвая площадка его не помечает.""" + _patch_probes(monkeypatch, _LIVE_MATRIX) + db = FakeSession([_proxy(1), _proxy(2)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + row = db._by_id(1) + assert row["browser_unfit_since"] is None + assert row["browser_fail_streak"] == 0 + assert row["enabled"] is True and row["consecutive_fails"] == 0 + + +async def test_all_platforms_dead_still_marks_the_node(monkeypatch: pytest.MonkeyPatch) -> None: + """Обратная сторона: транспорт не доходит НИКУДА → это уже диагноз узлу (#2723 цел).""" + dead_everywhere = { + ("http://u:p@h1:8080", src): (False, "proxy", "503 browser unavailable") + for src in ("avito", "cian", "yandex", "domclick") + } + _patch_probes(monkeypatch, dead_everywhere) + db = FakeSession([_proxy(1), _proxy(2)]) + + for _ in range(BROWSER_UNFIT_THRESHOLD): + db._by_id(1)["browser_check_at"] = None # снять гейт такта, ускорить подтверждение + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert db._by_id(1)["browser_unfit_since"] is not None + assert db._by_id(2)["browser_unfit_since"] is None + + +# ── 2-3. адрес пробы и «200 ≠ ответ площадки» ──────────────────────────────── + + +@pytest.mark.parametrize( + ("source", "must_contain"), + [ + ("avito", "www.avito.ru"), + ("cian", "ekb.cian.ru"), + ("yandex", "realty.yandex.ru"), + # apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200, + # а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной. + ("domclick", "bff-search-web.domclick.ru"), + ], +) +async def test_probe_asks_the_working_host_of_each_source( + monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str +) -> None: + seen: dict[str, Any] = {} + + class _Resp: + status_code = 200 + text = '{"html": "
User-agent: *
"}' + + @staticmethod + def json() -> dict[str, str]: + return {"html": "
User-agent: *
"} + + class _Client: + def __init__(self, **_kw: Any) -> None: ... + + async def __aenter__(self) -> _Client: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def post(self, url: str, json: dict[str, Any]) -> _Resp: + seen["payload"] = json + return _Resp() + + monkeypatch.setattr(bf.httpx, "AsyncClient", _Client) + ok, _fail_kind, _detail = await bf.probe_proxy_via_browser( + "http://tradein-browser:3000", "http://u:p@node:8080", source=source + ) + + assert ok is True + assert must_contain in seen["payload"]["url"] + assert seen["payload"]["url"].endswith("/robots.txt") # нагрузки на площадку нет + # Инстанс сайдкара остаётся 'generic' — проба не отбирает лок у боевой сессии. + assert seen["payload"]["source"] == "generic" + + +async def test_stub_page_with_status_200_is_a_failure(monkeypatch: pytest.MonkeyPatch) -> None: + """374 КБ «Ошибка — Циан» с кодом 200 — это отказ пары, а не успех пробы.""" + stub = "Ошибка - Циан…" + + class _Resp: + status_code = 200 + text = "{}" + + @staticmethod + def json() -> dict[str, str]: + return {"html": stub} + + class _Client: + def __init__(self, **_kw: Any) -> None: ... + + async def __aenter__(self) -> _Client: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def post(self, url: str, json: dict[str, Any]) -> _Resp: + return _Resp() + + monkeypatch.setattr(bf.httpx, "AsyncClient", _Client) + ok, fail_kind, detail = await bf.probe_proxy_via_browser( + "http://tradein-browser:3000", "http://u:p@node:8080", source="cian" + ) + + assert ok is False + # Тракт узла исправен — виновата ПАРА: площадка не отдала ресурс этому exit-IP. + assert fail_kind == "page" + assert "not robots.txt" in detail + + +async def test_stub_page_bans_the_pair(monkeypatch: pytest.MonkeyPatch) -> None: + """«page» не принадлежит узлу (#2723), но принадлежит паре — собирать через неё нельзя.""" + _patch_probes( + monkeypatch, + {("http://u:p@h1:8080", "cian"): (False, "page", "not robots.txt (html_len=374168)")}, + ) + db = FakeSession([_proxy(1), _proxy(2)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert db._ban(1, "cian") is not None + assert db._by_id(1)["browser_unfit_since"] is None # узел не при чём + + +# ── 4. проба снимает только свою строку ────────────────────────────────────── + + +async def test_probe_clears_only_its_own_ban(monkeypatch: pytest.MonkeyPatch) -> None: + """Зелёный robots.txt снимает вердикт ПРОБЫ и не трогает бан, распознанный сбором. + + robots.txt площадка отдаёт и забаненному IP — разрешить дешёвому успеху гасить + дорогой вердикт значило бы повторить #2723 на паре. + """ + later = datetime.now(UTC) + timedelta(hours=6) + db = FakeSession( + [_proxy(1), _proxy(2)], + bans=[ + { + "proxy_id": 1, + "source": "avito", + "banned_until": later, + "ban_count": 1, + "reason": "banned:avito", # распознан боевым сбором (капча/QRATOR) + }, + { + "proxy_id": 1, + "source": "cian", + "banned_until": later, + "ban_count": 1, + "reason": "probe:browser", # прошлый вердикт самой пробы + }, + ], + ) + _patch_probes(monkeypatch, {}) # все площадки отвечают + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert db._ban(1, "avito") is not None, "чужой бан проба снимать не имеет права" + assert db._ban(1, "cian") is None, "свой вердикт проба обязана снять" + assert counters["pair_cleared"] == 1 + + +# ── 5-6. чужие отказы ──────────────────────────────────────────────────────── + + +async def test_sidecar_outage_bans_nothing(monkeypatch: pytest.MonkeyPatch) -> None: + """Лежащий сайдкар не должен забанить разом все пары (#2686-класс).""" + down = { + (f"http://u:p@h{pid}:8080", src): (False, "sidecar", "ConnectError") + for pid in (1, 2) + for src in ("avito", "cian", "yandex", "domclick") + } + _patch_probes(monkeypatch, down) + db = FakeSession([_proxy(1), _proxy(2)]) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["pair_banned"] == 0 + assert db.bans == [] + assert db._by_id(1)["browser_unfit_since"] is None + + +# ── 7. цена такта ──────────────────────────────────────────────────────────── + + +async def test_probe_cost_is_nodes_times_servable_sources( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Крест ограничен источниками, которым узел вообще может достаться. + + Диагностика не должна превращаться в нагрузку: узел с выделенной affinity + спрашивает ОДНУ площадку, 'any' — все четыре. На проде это 4 узла × 4 источника + = 16 навигаций за такт (раз в BROWSER_PROBE_MINUTES), то есть 16 robots.txt на + площадку в сутки против ~1000 боевых /fetch. + """ + calls: list[tuple[str, str]] = [] + _patch_probes(monkeypatch, {}, calls) + db = FakeSession([_proxy(1, affinity="any"), _proxy(2, affinity="domclick")]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert sorted(s for url, s in calls if url.endswith("h1:8080")) == [ + "avito", + "cian", + "domclick", + "yandex", + ] + assert [s for url, s in calls if url.endswith("h2:8080")] == ["domclick"] + assert len(calls) == 5 + + +async def test_confirmed_failure_needs_a_second_look(monkeypatch: pytest.MonkeyPatch) -> None: + """Транспортный отказ пары подтверждается повтором — запуск camoufox бывает флаки. + + Повтор идёт по уже поднятому браузеру и только на отказах, поэтому цена такта из + теста выше не меняется, пока всё зелено. + """ + calls: list[tuple[str, str]] = [] + _patch_probes(monkeypatch, _LIVE_MATRIX, calls) + db = FakeSession([_proxy(1), _proxy(2)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert calls.count(("http://u:p@h1:8080", "domclick")) == 2 + assert calls.count(("http://u:p@h1:8080", "avito")) == 1 + + +async def test_flaky_failure_does_not_ban_the_pair(monkeypatch: pytest.MonkeyPatch) -> None: + """Один провал, второй заход зелёный → бан пары не пишется.""" + attempts: dict[str, int] = {} + + async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]: + return True, "1.2.3.4", 10, None + + async def _flaky(endpoint: str, proxy_url: str, **kw: Any) -> tuple[bool, str | None, str]: + source = str(kw.get("source", "avito")) + key = f"{proxy_url}|{source}" + attempts[key] = attempts.get(key, 0) + 1 + if source == "domclick" and proxy_url.endswith("h1:8080") and attempts[key] == 1: + return False, "proxy", "503 browser unavailable" + return True, None, "html_len=150" + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http) + monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True) + monkeypatch.setattr(bf, "probe_proxy_via_browser", _flaky) + db = FakeSession([_proxy(1), _proxy(2)]) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["pair_banned"] == 0 + assert db._ban(1, "domclick") is None + lease = acquire(db, "domclick") # type: ignore[arg-type] + assert lease is not None + release(db, lease.id) # type: ignore[arg-type] diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py index 640f7492..034de30f 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py @@ -37,22 +37,50 @@ _RETRY_SLEEP_S: float = 1.0 _HTTP_TIMEOUT_S: float = 120.0 # навигация медленная → щедрый таймаут # ── проба узла ПО БРАУЗЕРНОМУ ТРАКТУ (#2723) ───────────────────────────────── -# Адрес пробы. Требования к нему ровно три, и robots.txt Авито им отвечает: +# Адрес пробы. Требования к нему ровно три, и robots.txt им отвечает: # 1) тот же тракт, что у работы — сайдкар, camoufox, ЭТОТ прокси, настоящая # навигация. Все 90 записанных обрывов сбора («browser unavailable (proxy may # be down)») рождались на launch'е camoufox с прокси — проба обязана его делать; -# 2) та же площадка, что реально отказывает (100% обрывов — avito): TLS-рукопожатие -# и маршрут до её edge, а не до нейтрального хоста; -# 3) НУЛЕВАЯ нагрузка на площадку: robots.txt — статический файл ~4КБ, который +# 2) та же площадка, что реально отказывает: TLS-рукопожатие и маршрут до ЕЁ edge, +# а не до нейтрального хоста; +# 3) НУЛЕВАЯ нагрузка на площадку: robots.txt — статический файл, который # автоматическим клиентам читать прямо предписано. НЕ выдача и НЕ карточка. -# Такт пробы редкий (proxy_pool.BROWSER_PROBE_MINUTES) — при 4 узлах это ~16 -# запросов в сутки против ~1000 боевых /fetch (замер на проде 06.08). -_PROXY_PROBE_URL: str = "https://www.avito.ru/robots.txt" -# source='generic' НАМЕРЕННО, хотя адрес авитовский: сайдкар держит по инстансу -# camoufox на провайдера с отдельным локом, и проба с source='avito' забирала бы лок -# боевого инстанса и релончила его (прокси пробы ≠ прокси сессии) — ровно тот -# relaunch-шторм, который лечил sticky-lease фикс. 'generic' — свой инстанс, боевые -# развёртки его не используют. +# +# АДРЕС НА КАЖДЫЙ ИСТОЧНИК, а не один зашитый (#2800). До этого проба всех узлов +# ходила на Авито, и её зелёный ответ читался как «узел годен», хотя означал +# «годен для Авито». Замер на проде 09.08.2026 показал ровно ту цену: узел id=1 +# отдавал 200 на Авито/Яндексе и 500 NS_ERROR_PROXY_BAD_GATEWAY на Домклике, имея +# при этом browser_fail_streak=0 и свежую пробу. +# +# ХОСТ — РАБОЧИЙ, А НЕ APEX-ДОМЕН. Тот же замер: через узел id=1 +# `domclick.ru/robots.txt` отдаёт 200, а `bff-search-web.domclick.ru/robots.txt` — +# 500. Боевой сбор Домклика ходит именно на bff (providers/domclick/serp.py:: +# _BFF_BASE), поэтому проба по apex была бы зелёной и бесполезной — та же ошибка +# «проба идёт не рабочим путём», что и #2723, на третьей оси. +_PROBE_URLS: dict[str, str] = { + "avito": "https://www.avito.ru/robots.txt", + "cian": "https://ekb.cian.ru/robots.txt", # рабочий хост — providers/cian/serp.py base_url + "yandex": "https://realty.yandex.ru/robots.txt", # providers/yandex/serp.py::_GATE_URL + "domclick": "https://bff-search-web.domclick.ru/robots.txt", +} +_PROXY_PROBE_URL: str = _PROBE_URLS["avito"] +# Источники, по которым вообще есть что спрашивать. Публичный кортеж — proxy_pool +# перебирает его, чтобы не заводить второй список площадок на стороне backend'а. +PROBE_SOURCES: tuple[str, ...] = tuple(_PROBE_URLS) + +# HTTP 200 + непустой HTML ещё не значит «площадка ответила»: замер 09.08 — Циан +# через узел id=1 отдаёт 200 и 374 КБ HTML со страницей «Ошибка — Циан» вместо +# robots.txt. Такой ответ проба до #2800 засчитывала как успех. Маркер ниже есть в +# robots.txt всех четырёх рабочих хостов (проверено вживую) и отсутствует в +# странице-заглушке — самый дешёвый способ отличить «отдали ресурс» от «отдали +# отказ с кодом 200». +_PROBE_CONTENT_MARKER: str = "User-agent" +# source='generic' В ТЕЛЕ /fetch НАМЕРЕННО, какой бы площадке ни принадлежал адрес: +# сайдкар держит по инстансу camoufox на провайдера с отдельным локом, и проба с +# source='avito' забирала бы лок боевого инстанса и релончила его (прокси пробы ≠ +# прокси сессии) — ровно тот relaunch-шторм, который лечил sticky-lease фикс. +# 'generic' — свой инстанс, боевые развёртки его не используют. Аргумент `source` у +# probe_proxy_via_browser выбирает АДРЕС (какую площадку спрашиваем), а не инстанс. _PROXY_PROBE_SOURCE: str = "generic" # Щедрее ipify-пробы (10с) на порядок: сюда входит холодный запуск camoufox — 8.3с # замерено на проде вместе с релончем, плюс запас на медленный узел. @@ -126,8 +154,10 @@ def classify_browser_probe(status: int | None, detail: str) -> str: browser_fail_streak. - "sidecar" — сайдкар недоступен/не сконфигурирован (connect error, таймаут, 503 «no proxy configured», прочие 5xx). Узел не виноват. - - "page" — тракт сработал, но ответ не похож на страницу (пустое тело). - Узел не виноват; повод посмотреть на площадку, не на пул. + - "page" — тракт сработал, но ответ не похож на запрошенный ресурс (пустое + тело либо 200 со страницей-заглушкой вместо robots.txt, #2800). + Браузерный тракт узла исправен — не годится ПАРА «узел × + площадка»: этому exit-IP площадка ресурс не отдала. """ if status is None: return "sidecar" # до ответа не дошло — сайдкар/сеть контейнера @@ -143,10 +173,11 @@ async def probe_proxy_via_browser( proxy_url: str, *, proxy_kind: str = "http", - url: str = _PROXY_PROBE_URL, + source: str = "avito", + url: str | None = None, timeout_s: float = _PROXY_PROBE_TIMEOUT_S, ) -> tuple[bool, str | None, str]: - """Проверить узел ТЕМ ЖЕ трактом, которым идёт работа: сайдкар → camoufox → прокси. + """Проверить ПАРУ «узел × площадка» тем же трактом, что и работа: сайдкар → camoufox → прокси. Standalone (не метод `BrowserFetcher`) и БЕЗ пула: аренда узла здесь не нужна и вредна — health-checker проверяет узлы, в том числе арендованные, и не должен @@ -156,13 +187,18 @@ async def probe_proxy_via_browser( делает goto на origin, т.е. на ГЛАВНУЮ страницу площадки — это уже заметная нагрузка на неё, ради которой проба и затевалась бы наоборот. + `source` выбирает АДРЕС пробы (`_PROBE_URLS`, #2800) — рабочий хост именно этой + площадки. Прежняя сигнатура спрашивала только Авито, и её зелёный ответ означал + «узел годен для Авито», а читался как «узел годен». `url` (явный адрес) остаётся + для тестов и разовых проверок и перекрывает `source`. + Returns: (ok, fail_kind, detail). ok=True → fail_kind=None. Иначе fail_kind — "proxy" / "sidecar" / "page" (см. classify_browser_probe), detail — обрезанный текст для лога. """ payload: dict[str, object] = { - "url": url, + "url": url or _PROBE_URLS.get(source, _PROXY_PROBE_URL), "source": _PROXY_PROBE_SOURCE, "proxy": proxy_url, "proxy_kind": proxy_kind, @@ -184,6 +220,15 @@ async def probe_proxy_via_browser( html = "" if not html: return False, classify_browser_probe(resp.status_code, detail), "empty html" + if _PROBE_CONTENT_MARKER not in html: + # 200 и непустое тело, но это не robots.txt — площадка отдала заглушку + # ЭТОМУ exit-IP (замер 09.08: Циан через узел id=1 → 374 КБ «Ошибка — Циан»). + # Тракт узла исправен, негодна пара — отсюда "page", а не "proxy". + return ( + False, + "page", + f"not robots.txt (html_len={len(html)}): {' '.join(html.split())[:120]}", + ) return True, None, f"html_len={len(html)}" From 9cd6db023b160e30be69482235a155a7566f01b1 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 9 Aug 2026 18:41:02 +0000 Subject: [PATCH 22/23] =?UTF-8?q?fix(tradein/cian):=20=D0=A6=D0=B8=D0=B0?= =?UTF-8?q?=D0=BD=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=20MFE=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=96=D0=9A=20=E2=80=94=20=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BE=D0=B1=D0=B0=20=D0=B8=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=20(#2767)=20(#2804)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/tests/test_2767_cian_waf_block.py | 46 +++++++++++++++++ .../tests/test_2767_newbuilding_parse_miss.py | 8 ++- .../scraper_kit/providers/cian/newbuilding.py | 51 ++++++++++++++++--- 3 files changed, 95 insertions(+), 10 deletions(-) diff --git a/tradein-mvp/backend/tests/test_2767_cian_waf_block.py b/tradein-mvp/backend/tests/test_2767_cian_waf_block.py index 660db0f7..43d793df 100644 --- a/tradein-mvp/backend/tests/test_2767_cian_waf_block.py +++ b/tradein-mvp/backend/tests/test_2767_cian_waf_block.py @@ -183,3 +183,49 @@ async def test_proxy_provider_reaches_the_fetcher_factory(monkeypatch, waf_html) ) factory.assert_called_once_with(config, "cian", proxy_provider=provider) + + +# ── новый фронт Циана: тот же ключ под другим именем MFE ────────────────────── + +# Форма живой страницы zhk-kosmos-ekb-i.cian.ru (2026-08-09): состояние лежит под +# 'newbuilding-card-desktop-fichering-frontend', СТАРОГО имени на странице нет вовсе. +_FICHERING_CARD = ( + "" +) + + +@pytest.mark.asyncio +async def test_state_is_found_under_the_renamed_fichering_mfe(monkeypatch) -> None: + """ЖК на новом фронте Циана разбирается — вместе с графиком цен. + + Именно из-за этого имени `houses_price_dynamics` стояла с 2026-07-26: страница + приходила целиком, состояние в ней было, но под другим MFE — и отказ выглядел как + «разметка изменилась», хотя форма состояния та же. + """ + fetcher = _spy_fetcher(_FICHERING_CARD) + monkeypatch.setattr( + "scraper_kit.providers.cian.newbuilding.build_browser_fetcher", + MagicMock(return_value=fetcher), + ) + + result = await fetch_newbuilding( + "https://zhk-kosmos-ekb-i.cian.ru", + config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"), + ) + + assert result is not None + assert result.cian_internal_house_id == 3235089 + assert result.name == "Космос" + # График — тот самый, ради которого задача и существует. + assert [p["month_date"] for p in result.realty_valuation_chart] == [ + "2026-06-01", + "2026-07-01", + ] + assert [p["price_per_sqm"] for p in result.realty_valuation_chart] == [4100000.0, 4250000.0] + fetcher.report_ban.assert_not_called() diff --git a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py index 28b16f56..81056835 100644 --- a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py +++ b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py @@ -53,9 +53,13 @@ def test_describe_parse_miss_reports_size_and_no_markers() -> None: def test_describe_parse_miss_names_found_markers() -> None: - """Заглушка: маркер назван, но диагноз не выносится — только перечисление найденного.""" + """Стена капчи: маркер назван, но диагноз не выносится — только перечисление найденного. + + Опознаётся по заголовку «Вы не робот?», а НЕ по подстроке "captcha": последняя есть + на любой здоровой карточке ЖК (скрипт SmartCaptcha) и была снята из списка после + того, как забанила здоровые узлы (#2767). + """ described = _describe_parse_miss("
Вы не робот?
") - assert "captcha" in described assert "вы не робот" in described assert "html_len=" in described diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py index 89034166..4211f73f 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py @@ -1,7 +1,9 @@ """Cian.ru newbuilding (ЖК) catalog page scraper. URL: https://zhk---i.cian.ru/ -MFE: 'newbuilding-card-desktop-frontend', key: 'initialState' +MFE: 'newbuilding-card-desktop-frontend' ИЛИ 'newbuilding-card-desktop-fichering-frontend' +(Циан раскатывает новый фронт на часть ЖК — оба имени живые, см. _NEWBUILDING_MFES), +key: 'initialState' Sister state containers extracted from same MFE initialState top-level keys: - realtyValuation: 7-month price chart (data.priceDynamics.chart.data.{labels,values}) @@ -82,8 +84,13 @@ _CATPH_NEWBUILDING_SERP = ( # то есть по размеру НЕОТЛИЧИМА от целой карточки — «~1 МБ значит разметка» не работает, # различает только маркер. Молчание списка читалось как «защита проверена и не нашлась»; # на деле список просто не знал этой подписи (см. capture в #2767). +# Голой подстроки "captcha" здесь НЕТ намеренно (живая проба 2026-08-09): ЛЮБАЯ здоровая +# карточка ЖК грузит скрипт SmartCaptcha, поэтому "captcha" на ней есть всегда. Стена +# капчи опознаётся по своему заголовку «Вы не робот?» — он в списке ниже и на проде +# срабатывал вместе с "captcha" на всех 15-килобайтных стенах, так что снятие ложного +# маркера ничего не теряет. Пока он тут был, отказ разбора большой (682-886 КБ) страницы +# сопровождался баном ЗДОРОВОГО узла с причиной "captcha". _ANTIBOT_MARKERS = ( - "captcha", # покрывает и recaptcha, и smartcaptcha "qrator", "ddos-guard", "cf-chl", # Cloudflare challenge @@ -109,6 +116,37 @@ def _describe_parse_miss(html: str) -> str: return f"html_len={len(html)} antibot_markers={','.join(found) if found else 'none'}" +# MFE, под которым Циан кладёт состояние ЖК-карточки. Имён ДВА, и оба живые — замер +# 2026-08-09, один и тот же прогон: +# zhk-pihtovyy-ekb-i.cian.ru → newbuilding-card-desktop-frontend +# zhk-kosmos-ekb-i.cian.ru → newbuilding-card-desktop-fichering-frontend +# zhk-les-ekb-i.cian.ru → newbuilding-card-desktop-fichering-frontend +# Второе имя (`fichering`) — раскатка нового фронта Циана на ЧАСТЬ ЖК. Страницы под ним +# приходят целиком (810-993 КБ, ни капчи, ни WAF), состояние в них лежит под тем же +# ключом `initialState` и той же формы: `newbuilding.id`/`name` на месте, +# `realtyValuation.data.priceDynamics` — тоже. Именно из-за него `houses_price_dynamics` +# стояла с 2026-07-26: у ЖК, переехавших на новый фронт, график есть, но старое имя MFE +# его не находило, а отказ выглядел как «страница целая, разметка изменилась». +# Порядок — исторический первым: большинство ЖК всё ещё на нём, лишнего разбора не будет. +_NEWBUILDING_MFES = ( + "newbuilding-card-desktop-frontend", + "newbuilding-card-desktop-fichering-frontend", +) + + +def _extract_nb_state(html: str) -> tuple[dict[str, Any] | None, str]: + """Состояние ЖК-карточки + имя MFE, под которым оно нашлось. + + Имя возвращается, потому что sister-контейнеры читаются из `extract_all_states()` + по ТОМУ ЖЕ MFE — искать их под чужим именем бессмысленно. + """ + for mfe in _NEWBUILDING_MFES: + state = extract_state(html, mfe=mfe, key="initialState") + if state is not None: + return state, mfe + return None, _NEWBUILDING_MFES[0] + + def _blocked_by(html: str) -> str | None: """Найденные подписи защиты, если страница — блок; иначе None. @@ -227,15 +265,11 @@ async def fetch_newbuilding( # прогон из 25 домов даёт до 25 acquire → до 25 релончей camoufox (~8 с каждый). # При паузе 18 с между домами это терпимо и вдобавок само по себе ротация IP. # Понадобится дешевле — поднимать фетчер в вызывающий цикл и передавать сюда. - # Cian ЖК-карточка: MFE 'newbuilding-card-desktop-frontend', key 'initialState' - # (verified live 2026-06-15; перепроверено живьём 2026-08-09 — разметка та же). - mfe = "newbuilding-card-desktop-frontend" - async with browser: html = await browser.fetch(zhk_url) # Разбор — ВНУТРИ контекста, чтобы бан репортился, пока lease жив: после выхода # из `async with` узел уже отпущен и `report_ban` стал бы no-op. - nb_state = extract_state(html, mfe=mfe, key="initialState") + nb_state, mfe = _extract_nb_state(html) # Условие бана — «состояния НЕТ и есть подпись защиты», а не одна подпись. # Живая регрессия 2026-08-09 (поймана на проде через 17 секунд после деплоя): # маркер по одному лишь тексту забанил два ЗДОРОВЫХ узла на странице, которая @@ -250,8 +284,9 @@ async def fetch_newbuilding( if nb_state is None: logger.warning( - "Cian newbuilding %s: initialState extraction failed — %s", + "Cian newbuilding %s: initialState extraction failed (искали в MFE %s) — %s", zhk_url, + "/".join(_NEWBUILDING_MFES), _describe_parse_miss(html), ) return None From 27e199e3700bb6d479a454e9c73c3c93ed1c8cc0 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 9 Aug 2026 20:13:10 +0000 Subject: [PATCH 23/23] =?UTF-8?q?fix(tradein/proxy):=20=D1=83=D0=BF=D0=B0?= =?UTF-8?q?=D0=B2=D1=88=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B1=D0=B0=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D1=81=D0=B2=D0=B0=D0=B8=D0=B2=D0=B0=D0=BB?= =?UTF-8?q?=D0=B0=20=D1=81=D0=B5=D0=B1=D0=B5=20=D0=B1=D0=B0=D0=BD=20=D0=B1?= =?UTF-8?q?=D0=BE=D0=B5=D0=B2=D0=BE=D0=B3=D0=BE=20=D1=81=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=20(#2800)=20(#2805)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/proxy_pool.py | 109 +++++++++++++++--- .../backend/tests/services/test_proxy_pool.py | 17 +++ .../tests/test_2800_per_source_probe.py | 76 +++++++++++- 3 files changed, 184 insertions(+), 18 deletions(-) diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py index bf1f26e7..8ed2176e 100644 --- a/tradein-mvp/backend/app/services/proxy_pool.py +++ b/tradein-mvp/backend/app/services/proxy_pool.py @@ -725,14 +725,43 @@ def mark_browser_health( return "fail" -def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = None) -> None: +def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = None) -> str: """Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2. + Returns: "banned" (строка записана/продлена) | "deferred" (активная строка пары + принадлежит другому вердикту, владельца не меняем) | "protected" (защита последнего + узла) | "missing" (нет такого proxy_id). + `reason` попадает в одноимённую колонку и служит МЕТКОЙ ВЛАДЕЛЬЦА строки: по умолчанию 'banned:' (бан распознан боевым сбором), у браузерной пробы — _PROBE_BAN_REASON (#2800). Снимать чужую строку никто не должен, поэтому clear_source_bans умеет фильтровать по ней (`only_reason`). + ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ (дефект #2803, реализовался на проде 09.08.2026: + пара (1, cian) была `banned:cian, ban_count=1, до 00:21`, упавшая проба через + ON CONFLICT переписала её в `probe:browser, ban_count=2, до 07:43`). Фильтр + «снимаю только своё» защищает лишь до тех пор, пока чужую строку нельзя ПРИСВОИТЬ: + присвоенная строка становится «своей», и следующая успешная проба снимает ею бан, + который поставил боевой сбор по настоящему отказу площадки. Плюс теряется + происхождение: 'banned:cian' («площадка нас отбила») и 'probe:browser' («наша проба + не смогла») — разные факты с разными последствиями (ровно ловушка #2764), а ban_count + начинает считать события РАЗНОГО рода одной эскалацией (на проде это удлинило отдых + пары с 6 ч до 12 ч). + + Правило в `WHERE` у DO UPDATE: строку берём, если она ИСТЕКЛА (живого владельца нет), + ИЛИ она уже наша (та же метка — обычная эскалация), ИЛИ мы боевой сбор (`live_reason`). + Иначе — ничего: ни reason, ни ban_count, ни срок. Продлевать чужой бан «безвредно» + только на словах: срок пересчитывается от now() по НАШЕЙ эскалации и способен + УКОРОТИТЬ уже эскалированный чужой бан. Бан и так стоит — делать нечего. + + АСИММЕТРИЯ НАМЕРЕННАЯ: боевой сбор строку пробы перехватывает. Его вердикт сильнее + (площадка реально отбила именно сейчас), пара остаётся забаненной, а метка становится + ТОЧНЕЕ. Запретить ему это значило бы оставить строку за пробой — и её же зелёный + robots.txt снёс бы настоящий бан площадки, то есть тот самый дефект, только зеркально + и хуже. Цена перехвата — ban_count наследуется (отдых чуть длиннее заслуженного); + обнулять его на смене владельца нельзя: тогда запись пробы стирала бы память об + эскалации боевых банов пары. + Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация — транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно @@ -791,6 +820,10 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = сюда попадают уже обёрнутыми в try/except, но сам mark_banned ошибки БД не глотает (падает как обычно) — caller решает, ловить или нет. """ + # Метка боевого сбора: право перехватить АКТИВНУЮ строку пары есть только у неё + # (см. докстринг "ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ"). + live_reason = f"banned:{source}" + effective_reason = reason or live_reason # Сериализует check+insert ниже с другими конкурентными mark_banned (см. докстринг # "КОНКУРЕНТНОСТЬ"). Держится до db.commit()/rollback() этой транзакции. db.execute( @@ -862,13 +895,20 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = ) AS integer)), reason = CAST(:reason AS text), updated_at = now() + -- Владельца АКТИВНОЙ строки не меняем: берём истёкшую (владельца нет), + -- свою же (обычная эскалация) или перебиваем боевым сбором — он сильнее + -- пробы. Иначе 0 rows и ветка "deferred" ниже (дефект #2803). + WHERE scrape_proxy_source_bans.banned_until <= now() + OR scrape_proxy_source_bans.reason = CAST(:reason AS text) + OR CAST(:reason AS text) = CAST(:live_reason AS text) RETURNING ban_count, banned_until """ ), { "proxy_id": proxy_id, "source": source, - "reason": reason or f"banned:{source}", + "reason": effective_reason, + "live_reason": live_reason, "base_hours": SOURCE_BAN_BASE_HOURS, "max_hours": SOURCE_BAN_MAX_HOURS, "max_fails": MAX_CONSECUTIVE_FAILS, @@ -888,10 +928,40 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = row["banned_until"], row["ban_count"], ) - return + return "banned" + + # 0 rows — ТРИ разные причины, и путать их нельзя: чужой активный владелец, защита + # последнего узла, отсутствующий узел. Читаем состояние ТОЛЬКО ради точного лога + # (на решение уже не влияет), но диагноз должен называть то, что произошло. + holder = ( + db.execute( + text( + """ + SELECT reason, banned_until + FROM scrape_proxy_source_bans + WHERE proxy_id = CAST(:proxy_id AS bigint) + AND source = CAST(:source AS text) + AND banned_until > now() + """ + ), + {"proxy_id": proxy_id, "source": source}, + ) + .mappings() + .fetchone() + ) + if holder is not None and holder["reason"] != effective_reason: + logger.info( + "proxy_pool: proxy id=%d source=%s — бан пары уже стоит от %r до %s; вердикт " + "%r его НЕ перебивает (владельца активной строки меняет только боевой сбор, " + "иначе проба присвоила бы чужой бан и потом сняла бы его как свой)", + proxy_id, + source, + holder["reason"], + holder["banned_until"], + effective_reason, + ) + return "deferred" - # 0 rows: либо узла нет, либо защита последнего узла отменила запись бана — читаем - # текущее состояние ТОЛЬКО для точного лога (на решение уже не влияет). current = ( db.execute( text( @@ -904,14 +974,15 @@ def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = ) if current is None: logger.warning("proxy_pool: mark_banned id=%d not found — no-op", proxy_id) - else: - logger.warning( - "proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для " - "source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому " - "источнику (голодание хуже, чем работа через забаненный узел).", - proxy_id, - source, - ) + return "missing" + logger.warning( + "proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для " + "source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому " + "источнику (голодание хуже, чем работа через забаненный узел).", + proxy_id, + source, + ) + return "protected" def clear_source_bans( @@ -1004,13 +1075,18 @@ def mark_source_probe( Успех снимает ТОЛЬКО строку, написанную пробой (`only_reason`). Бан, распознанный боевым сбором, остаётся: robots.txt площадка отдаёт и забаненному IP, и разрешить - дешёвой пробе гасить дорогой вердикт значило бы повторить #2723 на паре. + дешёвой пробе гасить дорогой вердикт значило бы повторить #2723 на паре. Обратная + половина того же правила живёт в `mark_banned`: чужую АКТИВНУЮ строку проба не + присваивает (дефект #2803) — иначе фильтр `only_reason` перестаёт защищать, ведь + присвоенная строка уже «своя». Защита последнего узла и эскалация срока — целиком из `mark_banned`, здесь ничего своего: если после бана у `acquire(source)` не осталось бы кандидатов, бан не пишется (голодание хуже работы через плохой узел). - Returns: "ok" | "cleared" (сняли свой бан) | "banned" | "ignored". + Returns: "ok" | "cleared" (сняли свой бан) | "ignored" | исход `mark_banned` + ("banned" | "deferred" | "protected" | "missing") — счётчик пар считает баном + только реально записанный бан. """ if ok: cleared = clear_source_bans( @@ -1041,8 +1117,7 @@ def mark_source_probe( fail_kind, detail, ) - mark_banned(db, proxy_id, source=source, reason=_PROBE_BAN_REASON) - return "banned" + return mark_banned(db, proxy_id, source=source, reason=_PROBE_BAN_REASON) def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int: diff --git a/tradein-mvp/backend/tests/services/test_proxy_pool.py b/tradein-mvp/backend/tests/services/test_proxy_pool.py index bd360aed..478e394a 100644 --- a/tradein-mvp/backend/tests/services/test_proxy_pool.py +++ b/tradein-mvp/backend/tests/services/test_proxy_pool.py @@ -387,6 +387,17 @@ class FakeSession: } self.bans.append(ban) else: + # #2803-follow-up: активную строку чужого владельца не перехватываем. + # Гейтим по подстроке боевого SQL (как ban-предикаты выше) — иначе мок + # реализовал бы защиту сам и тест был бы зелёным на сломанном коде. + defends_owner = "scrape_proxy_source_bans.banned_until <= now()" in sql + if ( + defends_owner + and ban["banned_until"] > now + and ban.get("reason") != p["reason"] + and p["reason"] != p.get("live_reason") + ): + return _FakeResult([]) # владельца активной строки не меняем # эскалация: срок = base * 2^(новый ban_count - 1), потолок max_hours ban["ban_count"] += 1 hours = min(p["base_hours"] * 2 ** (ban["ban_count"] - 1), p["max_hours"]) @@ -419,6 +430,12 @@ class FakeSession: self.bans = [b for b in self.bans if b["banned_until"] >= cutoff] return _FakeResult(purged) + if "SELECT reason, banned_until" in sql: # mark_banned: кто держит активный бан пары + ban = self._ban(p["proxy_id"], p["source"]) + if ban is None or ban["banned_until"] <= datetime.now(UTC): + return _FakeResult([]) + return _FakeResult([{"reason": ban.get("reason"), "banned_until": ban["banned_until"]}]) + if "SELECT enabled, disabled_reason FROM scrape_proxies" in sql: # mark_banned diag read row = self._by_id(p["id"]) if row is None: diff --git a/tradein-mvp/backend/tests/test_2800_per_source_probe.py b/tradein-mvp/backend/tests/test_2800_per_source_probe.py index 7eca4afd..37fc10fe 100644 --- a/tradein-mvp/backend/tests/test_2800_per_source_probe.py +++ b/tradein-mvp/backend/tests/test_2800_per_source_probe.py @@ -283,11 +283,85 @@ async def test_probe_clears_only_its_own_ban(monkeypatch: pytest.MonkeyPatch) -> counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] - assert db._ban(1, "avito") is not None, "чужой бан проба снимать не имеет права" + avito = db._ban(1, "avito") + assert avito is not None, "чужой бан проба снимать не имеет права" + assert (avito["reason"], avito["ban_count"]) == ("banned:avito", 1), "и не переписывать" assert db._ban(1, "cian") is None, "свой вердикт проба обязана снять" assert counters["pair_cleared"] == 1 +# ── 4b. …и не присваивает чужую (дефект #2803, реализовался на проде) ───────── + + +async def test_probe_does_not_steal_a_live_ban(monkeypatch: pytest.MonkeyPatch) -> None: + """Упавшая проба НЕ переписывает активный бан, поставленный боевым сбором. + + Прод 09.08.2026, пара (1, cian): строка `banned:cian, ban_count=1, до 00:21` после + упавшей пробы стала `probe:browser, ban_count=2, до 07:43`. Фильтр «снимаю только + своё» при этом цел, но защищать перестаёт: присвоенная строка уже «своя», и + следующая успешная проба сняла бы ею бан, который площадка поставила по-настоящему. + Плюс сама метка перестаёт быть свидетельством («нас отбили» неотличимо от «мы не + смогли», #2764), а ban_count складывает события разного рода в одну эскалацию — + отдых пары вырос с 6 ч до 12 ч. + """ + until = datetime.now(UTC) + timedelta(hours=6) + db = FakeSession( + [_proxy(1), _proxy(2)], + bans=[ + { + "proxy_id": 1, + "source": "cian", + "banned_until": until, + "ban_count": 1, + "reason": "banned:cian", # боевой сбор: Циан отдал заглушку + } + ], + ) + _patch_probes( + monkeypatch, + {("http://u:p@h1:8080", "cian"): (False, "page", "not robots.txt (html_len=374168)")}, + ) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + ban = db._ban(1, "cian") + assert ban is not None + assert ban["reason"] == "banned:cian", "проба присвоила себе бан боевого сбора" + assert ban["ban_count"] == 1, "два события разного рода посчитаны одной эскалацией" + assert ban["banned_until"] == until, "чужой срок проба не пересчитывает (может и укоротить)" + assert counters["pair_banned"] == 0, "счётчик не должен объявлять баном то, чего не записал" + + +async def test_live_ban_takes_over_the_probe_row(monkeypatch: pytest.MonkeyPatch) -> None: + """Зеркало намеренно НЕ симметрично: боевой сбор строку пробы перехватывает. + + Его вердикт сильнее — площадка отбила нас именно сейчас, — пара остаётся забаненной, + а метка становится точнее. Если запретить и ему, строка останется за пробой, и её же + зелёный robots.txt снесёт настоящий бан площадки: тот же дефект, только зеркально. + """ + db = FakeSession( + [_proxy(1), _proxy(2)], + bans=[ + { + "proxy_id": 1, + "source": "cian", + "banned_until": datetime.now(UTC) + timedelta(hours=6), + "ban_count": 1, + "reason": "probe:browser", + } + ], + ) + + proxy_pool.mark_banned(db, 1, source="cian") # type: ignore[arg-type] + + assert db._ban(1, "cian")["reason"] == "banned:cian" + + # …и с этой минуты зелёная проба его не снимет — ради чего перехват и нужен. + _patch_probes(monkeypatch, {}) + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert db._ban(1, "cian") is not None + + # ── 5-6. чужие отказы ────────────────────────────────────────────────────────