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'
"
+ 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 = (
+ "
- )}
{/* 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": "
"}
+
+ 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. чужие отказы ────────────────────────────────────────────────────────