From 0ed934ea09984f2cd0fd0adfb92fa8ffc33c6ccd Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Wed, 2 Sep 2026 14:44:38 +0500
Subject: [PATCH 01/13] =?UTF-8?q?fix(scraper-kit):=20=D1=87=D0=B5=D0=BA?=
=?UTF-8?q?=D0=BF=D0=BE=D0=B8=D0=BD=D1=82=20avito=5Fcity=5Fsweep=20=D0=B4?=
=?UTF-8?q?=D0=BE=D0=B6=D0=B8=D0=B2=D0=B0=D0=B5=D1=82=20=D0=B4=D0=BE=20?=
=?UTF-8?q?=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=82=D0=BE?=
=?UTF-8?q?=D1=80=D0=B0=20(#3319)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
0 из 67 прогонов за 60 дней имели done_buckets в counters: точку писала одна
строка внутри цикла якорей, а каждый выход (mark_done — включая ранний #1950
«SERP собран, detail заблокирован», — mark_banned, mark_failed) отдавал голый
counters.to_dict(). Точка держалась только на jsonb-мерже в runs.py, то есть на
свойстве чужого модуля, которого этот файл не проверяет.
- payload любого выхода собирается одной функцией _ckpt() — done_buckets несут
все 14 записей, а не одна;
- якорь, умерший по таймауту, больше не считается пройденным (тот же инвариант,
что у generic-except): SERP мог успеть, detail нет, и резюм пропускал такой
якорь навсегда при штатно завершившемся прогоне;
- SIGTERM-дрейн помечается counters.interrupted=1 и участвует в резюме. Статус
остаётся 'done' — ни один читатель статуса не меняется; метка та же, что у
rosreestr_dkp-дрейна. 'done' в _RESUME_STATUSES НЕ добавлен: чистый полный
обход резюмить нечего. Дрейн перед IMV-фазой помечен отдельно
(imv_phase_drained) — якоря там пройдены все, подхват собрал бы ноль.
---
.../tests/test_3319_citysweep_checkpoint.py | 214 ++++++++++++++++++
.../src/scraper_kit/orchestration/pipeline.py | 56 +++--
.../scraper_kit/orchestration/scheduler.py | 9 +-
3 files changed, 262 insertions(+), 17 deletions(-)
create mode 100644 tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py
diff --git a/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py b/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py
new file mode 100644
index 00000000..6ae05a73
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py
@@ -0,0 +1,214 @@
+"""Чекпоинт avito_city_sweep доживает до финализатора (#3319).
+
+Прод-факт, из которого выросла задача: 0 из 67 прогонов за 60 дней имеют в
+counters ключ done_buckets. Механизм #3074 (запись точки) и механизм #930
+(подхват точки) существуют оба, но между ними нет ни одного прогона: точку
+писала ровно одна строка внутри цикла якорей, а КАЖДЫЙ выход из прогона
+(mark_done — включая ранний выход #1950 «SERP собран, detail заблокирован», —
+mark_banned, mark_failed) отдавал голый counters.to_dict() без неё.
+
+Три инварианта, ради которых тест:
+ 1. done-выход несёт done_buckets — иначе точка существует только в логе.
+ 2. Якорь, умерший по таймауту, НЕ пройден: SERP мог успеть, detail нет.
+ Пройденным его записать = резюм пропустит его навсегда и молча.
+ 3. SIGTERM-дрейн отличим от полного обхода (counters.interrupted=1) и
+ участвует в резюме — статус у обоих 'done', счётчики частичные.
+"""
+
+from __future__ import annotations
+
+import os
+
+# Settings собирается автофикстурой conftest'а и требует database_url. Выставляем
+# до остальных импортов — так же, как в test_3074_avito_anchor_checkpoint.py.
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+import json
+import types
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+ANCHOR_A = (56.83, 60.60, "ekb-center")
+ANCHOR_B = (56.79, 60.63, "ekb-south")
+
+
+class _FakeDb:
+ """Все UPDATE'ы с counters (heartbeat И финализаторы) складываются по порядку."""
+
+ def __init__(self) -> None:
+ self.writes: list[dict[str, Any]] = []
+
+ def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any:
+ if params and "counters" in params:
+ self.writes.append(json.loads(params["counters"]))
+ return MagicMock()
+
+ def commit(self) -> None: ...
+ def rollback(self) -> None: ...
+
+
+class _FakeAsyncSession:
+ def __init__(self, *_a: Any, **_kw: Any) -> None: ...
+
+ async def __aenter__(self) -> _FakeAsyncSession:
+ return self
+
+ async def __aexit__(self, *_e: Any) -> None:
+ return None
+
+
+class _FakeScraper:
+ """Двойник AvitoScraper: помнит визиты, роняет заданный якорь заданной ошибкой."""
+
+ visited: list[tuple[float, float]] = [] # noqa: RUF012 — тестовый сборник
+ raise_on: tuple[float, float] | None = None
+ exc: type[BaseException] | None = None
+ lots_per_anchor: int = 0
+
+ def __init__(self, *_a: Any, **_kw: Any) -> None:
+ self._browser = None
+ self._cffi = None
+
+ async def fetch_around(self, lat: float, lon: float, *_a: Any, **_kw: Any) -> list:
+ _FakeScraper.visited.append((lat, lon))
+ if _FakeScraper.raise_on == (lat, lon) and _FakeScraper.exc is not None:
+ raise _FakeScraper.exc("якорь сорвался")
+ return [MagicMock() for _ in range(_FakeScraper.lots_per_anchor)]
+
+
+def _config() -> types.SimpleNamespace:
+ return types.SimpleNamespace(
+ scraper_fetch_mode="cffi",
+ scraper_proxy_url=None,
+ use_proxy_pool_browser=False,
+ browser_http_endpoint=None,
+ environment="test",
+ avito_serp_ok_not_banned=True,
+ )
+
+
+async def _run(
+ *,
+ raise_on: tuple[float, float] | None = None,
+ exc: type[BaseException] | None = None,
+ shutdown_after_first: bool = False,
+ saved: tuple[int, int] = (0, 0),
+ lots_per_anchor: int = 0,
+) -> _FakeDb:
+ from scraper_kit.orchestration import pipeline as pl
+
+ _FakeScraper.visited = []
+ _FakeScraper.raise_on = raise_on
+ _FakeScraper.exc = exc
+ _FakeScraper.lots_per_anchor = lots_per_anchor
+ db = _FakeDb()
+
+ def _shutdown() -> bool:
+ return shutdown_after_first and bool(_FakeScraper.visited)
+
+ with (
+ patch.object(pl, "AvitoScraper", _FakeScraper),
+ patch.object(pl, "AsyncSession", _FakeAsyncSession),
+ patch.object(pl, "save_listings", lambda *_a, **_kw: saved),
+ patch.object(pl.runs, "is_cancelled", lambda *_a: False),
+ ):
+ await pl.run_avito_city_sweep(
+ db, # type: ignore[arg-type]
+ run_id=3319,
+ config=_config(),
+ matcher=MagicMock(),
+ enrichment=MagicMock(),
+ anchors=[ANCHOR_A, ANCHOR_B],
+ enrich_houses=False,
+ enrich_imv=False,
+ detail_top_n=0,
+ shutdown_requested=_shutdown,
+ )
+ return db
+
+
+@pytest.mark.asyncio
+async def test_done_exit_carries_checkpoint() -> None:
+ """Финализатор полного обхода несёт done_buckets, а не голые счётчики."""
+ db = await _run()
+
+ assert db.writes[-1].get("done_buckets") == ["ekb-center", "ekb-south"], (
+ "финальный (done) выход отдал counters без чекпоинта — точки в прогоне нет"
+ )
+
+
+@pytest.mark.asyncio
+async def test_serp_ok_done_exit_carries_checkpoint() -> None:
+ """Ранний done-выход #1950 («SERP собран, detail заблокирован») — тоже.
+
+ Именно этим выходом кончается типичный прод-прогон, и он происходит РАНЬШЕ
+ единственной строки, которая писала точку.
+ """
+ from scraper_kit.orchestration import pipeline as pl
+
+ db = await _run(
+ raise_on=(ANCHOR_B[0], ANCHOR_B[1]),
+ exc=pl.AvitoBlockedError,
+ saved=(1, 0), # SERP intake > 0 → ветка ставит 'done', а не 'banned'
+ lots_per_anchor=1,
+ )
+
+ last = db.writes[-1]
+ assert "enrichment_abort_note" in last, "сработала не та ветка выхода"
+ assert last.get("done_buckets") == ["ekb-center"], (
+ "ранний done-выход потерял якорь, пройденный до блокировки"
+ )
+
+
+@pytest.mark.asyncio
+async def test_timed_out_anchor_is_not_checkpointed() -> None:
+ """Якорь, умерший по таймауту, не считается пройденным.
+
+ Иначе резюм пропустит его навсегда, и это будет незаметно: прогон
+ завершается штатно, просто часть города не собирается никогда.
+ """
+ db = await _run(raise_on=(ANCHOR_A[0], ANCHOR_A[1]), exc=TimeoutError)
+
+ ckpt = db.writes[-1].get("done_buckets")
+ assert "ekb-center" not in ckpt, "якорь-таймаут попал в чекпоинт"
+ assert "ekb-south" in ckpt, "исправный якорь не зафиксирован"
+
+
+@pytest.mark.asyncio
+async def test_drain_exit_is_distinguishable_from_full_done() -> None:
+ """SIGTERM-дрейн помечен interrupted=1; полный обход — нет."""
+ drained = await _run(shutdown_after_first=True)
+ full = await _run()
+
+ assert drained.writes[-1].get("interrupted") == 1, (
+ "оборванный дрейном прогон неотличим от полного обхода"
+ )
+ assert drained.writes[-1].get("done_buckets") == ["ekb-center"]
+ assert "interrupted" not in full.writes[-1], "полный обход помечен как оборванный"
+
+
+def _prev_run(counters: dict[str, Any]) -> types.SimpleNamespace:
+ return types.SimpleNamespace(
+ prev_id=4707,
+ prev_status="done",
+ prev_counters=counters,
+ same_params=True,
+ age_h=2.0,
+ interval_days="7",
+ )
+
+
+def test_drained_done_is_resumable_but_clean_done_is_not() -> None:
+ """Метка дрейна доходит до решения о резюме — иначе она диагностика ради себя."""
+ from scraper_kit.orchestration.scheduler import _resume_decision
+
+ ckpt = {"done_buckets": ["ekb-center"], "resume_chain": 0}
+
+ resume_from, verdict = _resume_decision(_prev_run({**ckpt, "interrupted": 1}))
+ assert resume_from == 4707, f"дрейн не подхвачен: {verdict}"
+
+ resume_from, verdict = _resume_decision(_prev_run(ckpt))
+ assert resume_from is None, "полный обход подхватывать нечего"
+ assert verdict["resume_reason"] == "status_done"
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 71599c87..2cbacf9c 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
@@ -1183,6 +1183,18 @@ async def run_avito_city_sweep(
)
_done_anchors: set[str] = set(_skip_anchors)
+ def _ckpt(**extra: Any) -> dict[str, Any]:
+ """Счётчики прогона ВМЕСТЕ с чекпоинтом — payload любого выхода (#3319).
+
+ До этого точку писала ровно одна строка внутри цикла якорей, а все
+ финализаторы (mark_done/mark_banned/mark_failed, включая ранний выход
+ #1950 «SERP OK, detail заблокирован») отдавали голый `counters.to_dict()`.
+ Точка держалась исключительно на jsonb-мерже в runs.py — на свойстве
+ ЧУЖОГО модуля, которого этот файл ничем не проверяет; выход, случившийся
+ раньше первой записи (или писатель без мержа), терял её молча.
+ """
+ return {**counters.to_dict(), "done_buckets": sorted(_done_anchors), **extra}
+
_loc = get_city_location(city_slug)
# #262 wave 2: avito_slug у CityLocation Optional — не у каждого известного города
# он подтверждён (403/429 на исчерпанном пуле при проверке, либо omonym-коллизия).
@@ -1287,7 +1299,7 @@ async def run_avito_city_sweep(
len(_anchors),
name,
)
- runs.update_heartbeat(db, run_id, counters.to_dict())
+ runs.update_heartbeat(db, run_id, _ckpt())
return counters
elif shutdown_requested():
# Кооперативный SIGTERM-drain (#1182 Phase 3a): останавливаемся
@@ -1301,8 +1313,13 @@ async def run_avito_city_sweep(
len(_anchors),
name,
)
- runs.update_heartbeat(db, run_id, counters.to_dict())
- runs.mark_done(db, run_id, counters.to_dict())
+ # #3319: 'done' с counters.interrupted=1 — НЕ полный обход
+ # (та же метка, что у rosreestr_dkp-дрейна). Без неё оборванный
+ # деплоем прогон неотличим от честно обошедшего все якоря:
+ # статус тот же, счётчики частичные, а резюм его не берёт.
+ # Читатели статуса не трогаем — 'done' остаётся 'done'.
+ runs.update_heartbeat(db, run_id, _ckpt(interrupted=1))
+ runs.mark_done(db, run_id, _ckpt(interrupted=1))
return counters
logger.info(
@@ -1762,6 +1779,12 @@ async def run_avito_city_sweep(
_avito_anchor_timeout,
)
counters.errors_count += 1
+ # #3319: тот же инвариант, что у generic-except ниже. Якорь,
+ # умерший по таймауту, ПРОЙДЕН НЕ БЫЛ: SERP мог успеть, а
+ # detail/houses — нет, и какая именно часть осталась несобранной,
+ # здесь неизвестно. Считать его пройденным значит, что резюм
+ # пропустит его навсегда — молча, при штатно завершившемся прогоне.
+ _anchor_ok = False
except (AvitoBlockedError, AvitoRateLimitedError) as e:
logger.error(
"city-sweep run_id=%d ABORT at anchor #%d/%d (%s) — blocked: %s",
@@ -1773,7 +1796,7 @@ async def run_avito_city_sweep(
)
counters.errors_count += 1
counters.anchors_done = idx
- runs.update_heartbeat(db, run_id, counters.to_dict())
+ runs.update_heartbeat(db, run_id, _ckpt())
# #1950: если SERP уже собрал лоты и заблокировало только detail/houses,
# ставим 'done' (не 'banned') — partial intake сохранён.
# За флагом avito_serp_ok_not_banned (default True).
@@ -1794,14 +1817,14 @@ async def run_avito_city_sweep(
runs.mark_done(
db,
run_id,
- {**counters.to_dict(), "enrichment_abort_note": _note}, # type: ignore[arg-type]
+ _ckpt(enrichment_abort_note=_note), # type: ignore[arg-type]
)
else:
runs.mark_banned(
db,
run_id,
str(e),
- counters.to_dict(),
+ _ckpt(),
ban_kind=ban_kind_of_exception(e),
)
return counters
@@ -1819,9 +1842,7 @@ async def run_avito_city_sweep(
# завершится штатно. Тот же инвариант, что у combo в yandex-свипе.
if _anchor_ok:
_done_anchors.add(name)
- runs.update_heartbeat(
- db, run_id, {**counters.to_dict(), "done_buckets": sorted(_done_anchors)}
- )
+ runs.update_heartbeat(db, run_id, _ckpt())
# ── IMV-фаза: финальный обход тронутых домов ──────────
if enrich_imv and all_touched_house_ids:
@@ -1831,7 +1852,7 @@ async def run_avito_city_sweep(
run_id,
len(all_touched_house_ids),
)
- runs.mark_done(db, run_id, counters.to_dict())
+ runs.mark_done(db, run_id, _ckpt())
return counters
elif shutdown_requested():
# SIGTERM-drain до IMV-фазы: финализируем без дорогой IMV-оценки.
@@ -1841,7 +1862,10 @@ async def run_avito_city_sweep(
run_id,
len(all_touched_house_ids),
)
- runs.mark_done(db, run_id, counters.to_dict())
+ # #3319: тоже дрейн, но якоря пройдены ВСЕ — резюмить нечего
+ # (пропустил бы весь список и собрал ноль), поэтому метка
+ # диагностическая, а не резюм-флаг `interrupted`.
+ runs.mark_done(db, run_id, _ckpt(imv_phase_drained=1))
return counters
logger.info(
@@ -1855,7 +1879,7 @@ async def run_avito_city_sweep(
# без update_heartbeat, и reap_zombies помечает живой run
# 'zombie' → последующий mark_done становится no-op (дубль-sweep).
def _imv_heartbeat() -> None:
- runs.update_heartbeat(db, run_id, counters.to_dict())
+ runs.update_heartbeat(db, run_id, _ckpt())
imv_result = await enrichment.process_houses_imv_batch(
db,
@@ -1867,7 +1891,7 @@ async def run_avito_city_sweep(
counters.imv_enriched += imv_result.saved
counters.imv_failed += imv_result.errors
counters.errors_count += imv_result.errors
- runs.update_heartbeat(db, run_id, counters.to_dict())
+ runs.update_heartbeat(db, run_id, _ckpt())
logger.info(
"city-sweep run_id=%d: IMV phase done — attempted=%d enriched=%d failed=%d",
run_id,
@@ -1892,9 +1916,9 @@ async def run_avito_city_sweep(
db.rollback()
except Exception:
pass
- runs.update_heartbeat(db, run_id, counters.to_dict())
+ runs.update_heartbeat(db, run_id, _ckpt())
- runs.mark_done(db, run_id, counters.to_dict())
+ runs.mark_done(db, run_id, _ckpt())
logger.info(
"city-sweep run_id=%d done: anchors=%d/%d lots=%d (ins=%d/upd=%d) "
"houses=%d/%d detail=%d/%d imv=%d/%d errors=%d",
@@ -1916,7 +1940,7 @@ async def run_avito_city_sweep(
except Exception as exc:
logger.exception("city-sweep run_id=%d: fatal error", run_id)
- runs.mark_failed(db, run_id, str(exc), counters.to_dict())
+ runs.mark_failed(db, run_id, str(exc), _ckpt())
raise
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 0892d454..d49712c7 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
@@ -638,7 +638,14 @@ def _resume_decision(row: Any) -> tuple[int | None, dict[str, Any]]:
}
_boot_reaped_zombie = row.prev_status == "zombie" and prev_counters.get("boot_reaped") is True
- if row.prev_status not in _RESUME_STATUSES and not _boot_reaped_zombie:
+ # 'done' с counters.interrupted=1 — SIGTERM-drain (#3319): статус штатный, но обход
+ # оборван на границе корзины, часть дерева не собрана. Сам статус в _RESUME_STATUSES
+ # не добавлен НАМЕРЕННО: чистое 'done' — полный проход, резюмить у него нечего, а
+ # подхват такой точки означал бы, что источник больше никогда не обходится целиком.
+ # Метка — та же, что у rosreestr_dkp-дрейна (app/services/scheduler.py), поэтому ни
+ # один читатель статуса не меняется.
+ _drained_done = row.prev_status == "done" and bool(prev_counters.get("interrupted"))
+ if row.prev_status not in _RESUME_STATUSES and not _boot_reaped_zombie and not _drained_done:
verdict["resume_reason"] = f"status_{row.prev_status}"
elif not row.same_params:
verdict["resume_reason"] = "params_changed"
From 4feb61c006630394645824f697a80402a30372fa Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Wed, 2 Sep 2026 14:49:27 +0500
Subject: [PATCH 02/13] =?UTF-8?q?fix(tradein):=20=D1=80=D0=B5=D0=B7=D0=BE?=
=?UTF-8?q?=D0=BB=D0=B2=D0=B8=D1=82=D1=8C=20=D1=80=D0=BE=D0=BB=D1=8C=20?=
=?UTF-8?q?=D0=B8=D0=B7=20=D1=80=D0=B5=D0=B5=D1=81=D1=82=D1=80=D0=B0,=20ro?=
=?UTF-8?q?les.yaml=20=E2=80=94=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20f?=
=?UTF-8?q?allback=20(#3316)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Роль жила в двух местах сразу: люди заводятся в БД (`tradein_users.role`),
а `get_role` читал ТОЛЬКО `auth/roles.yaml` — и никто эти два источника не
сверял. Дефект двусторонний:
* вверх: менеджер заводил сотрудника с именем, которое уже числится в
roles.yaml админом (проверялись лишь regex и уникальность в БД) — на
входе тот получал admin из YAML, то есть чтение ЛЮБОЙ чужой оценки
(admin проходит мимо ownership-check в trade_in.py) и безлимитную квоту;
* вниз: сотрудник, которого в roles.yaml нет, ловил KeyError → 403 на
СОБСТВЕННУЮ оценку.
Источник теперь один и лечится один раз — в `app.core.auth.get_role`:
реестр (`tradein_users.role` / `auth.users.role`) спрашивается первым,
roles.yaml остаётся fallback для legacy-юзеров, у которых строки в реестре
нет. Реестр недоступен → тоже fallback: падение БД не выключает legacy-вход.
Вызывающие (rbac, trade_in, team, account_quota) не менялись.
Сопутствующее, чтобы поведение существующих аккаунтов не поехало:
* rbac_guard выбирает матчер путей по РОДУ роли (роль реестра → DB_ROLE_PATHS),
иначе employee/manager на legacy-пути получил бы 403 на всё;
* get_user_scope отдаёт scope роли реестра из того же DB_ROLE_PATHS;
* право на персональный `unlimited` осталось за roles.yaml (account_quota +
_batch_quota_status) — фикс убирает эскалацию, а не раздаёт новую;
* `_batch_quota_status` берёт роли из уже прочитанных строк — иначе список
«Команды» снова стал бы N+1.
Defense-in-depth: create_employee отдаёт 409 на username, за которым в
roles.yaml числится не-employee роль.
---
tradein-mvp/backend/app/api/v1/team.py | 44 +++++-
tradein-mvp/backend/app/core/auth.py | 85 ++++++++++-
tradein-mvp/backend/app/core/rbac.py | 8 +-
.../backend/app/services/account_quota.py | 13 +-
.../backend/tests/test_role_single_source.py | 142 ++++++++++++++++++
5 files changed, 273 insertions(+), 19 deletions(-)
create mode 100644 tradein-mvp/backend/tests/test_role_single_source.py
diff --git a/tradein-mvp/backend/app/api/v1/team.py b/tradein-mvp/backend/app/api/v1/team.py
index d42bd250..1a422822 100644
--- a/tradein-mvp/backend/app/api/v1/team.py
+++ b/tradein-mvp/backend/app/api/v1/team.py
@@ -78,7 +78,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from sqlalchemy.sql.elements import TextClause
-from app.core.auth import get_role
+from app.core.auth import get_role, yaml_role
from app.core.config import settings
from app.core.db import get_db
from app.core.password import hash_password
@@ -288,7 +288,9 @@ def _upsert_quota_override(
)
-def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str, Any]]:
+def _batch_quota_status(
+ db: Session, usernames: list[str], known_roles: dict[str, str] | None = None
+) -> dict[str, dict[str, Any]]:
"""Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса
вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563).
@@ -351,13 +353,21 @@ def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str
result: dict[str, dict[str, Any]] = {}
for username in usernames:
override = override_by_username.get(username)
- try:
- role = get_role(username)
- except KeyError:
- role = None
+ # #3316: get_role ходит в реестр, а вызывающий уже прочитал роли этих
+ # же строк — иначе батч снова стал бы N+1 (ловит
+ # test_list_employees_query_count_is_not_n_plus_1). Роль реестра —
+ # ровно то, что вернул бы get_role: он спрашивает реестр первым.
+ role: str | None
+ if known_roles is not None and username in known_roles:
+ role = known_roles[username]
+ else:
+ try:
+ role = get_role(username)
+ except KeyError:
+ role = None
if role == "admin":
unlimited = True
- elif role is not None:
+ elif yaml_role(username) is not None:
unlimited = bool(override is not None and override["unlimited"])
else:
# username не в roles.yaml — is_unlimited() короткое замыкание на
@@ -432,6 +442,20 @@ async def create_employee(
`identity_db` — реестр (строка сотрудника), `db` — продуктовая квота;
в дефолтном режиме это одна и та же сессия и одна транзакция.
"""
+ # #3316 defense-in-depth: имя, за которым в roles.yaml уже числятся права
+ # (admin/pilot/analyst), занять нельзя. Роль резолвится из реестра первой
+ # (app.core.auth.get_role), так что эскалации не было бы и без этой
+ # проверки — но совпадение имён само по себе означает двух разных людей с
+ # одним логином, и дешевле отказать на входе, чем разбирать это в логах.
+ legacy = yaml_role(body.username)
+ if legacy is not None and legacy != "employee":
+ logger.warning(
+ "create_employee: %r refused — username занят в roles.yaml (role=%s)",
+ body.username,
+ legacy,
+ )
+ raise HTTPException(status_code=409, detail="username reserved in roles config")
+
schema = identity_schema()
existing = identity_db.execute(
text(f"SELECT id FROM {schema.users_table} WHERE username = :u"),
@@ -762,7 +786,11 @@ async def list_employees(
.all()
)
- quota_by_username = _batch_quota_status(db, [row["username"] for row in rows])
+ quota_by_username = _batch_quota_status(
+ db,
+ [row["username"] for row in rows],
+ known_roles={row["username"]: row["role"] for row in rows},
+ )
return [_employee_out(row, quota_by_username[row["username"]]) for row in rows]
diff --git a/tradein-mvp/backend/app/core/auth.py b/tradein-mvp/backend/app/core/auth.py
index affe6c4e..0906875b 100644
--- a/tradein-mvp/backend/app/core/auth.py
+++ b/tradein-mvp/backend/app/core/auth.py
@@ -8,6 +8,11 @@ from repo root. We deliberately do NOT share code between repos via
When updating one copy, update the other.
+⚠️ РАСХОЖДЕНИЕ С ЗЕРКАЛОМ (#3316, намеренное — не «синхронизировать» обратно):
+здесь `get_role` резолвит роль СНАЧАЛА из реестра людей (`tradein_users.role` /
+`auth.users.role`), и только потом из YAML. У основного бэкенда реестра нет,
+там копия остаётся YAML-only.
+
Caddy gates the whole site with basic_auth (см. `caddy/users.caddy.snippet`)
и пропускает в backend заголовок `X-Authenticated-User: ` через
`header_up X-Authenticated-User {http.auth.user.id}` в каждом reverse_proxy.
@@ -25,13 +30,15 @@ import logging
import re
from functools import lru_cache
from pathlib import Path
-from typing import Literal, TypedDict
+from typing import Literal, TypedDict, cast
import yaml
logger = logging.getLogger(__name__)
-Role = Literal["admin", "pilot", "analyst", "expired"]
+# legacy roles.yaml-роли + роли реестра ('admin'|'manager'|'employee', CHECK
+# tradein м.192 / auth м.004). Оба набора приходят из одного `get_role` (#3316).
+Role = Literal["admin", "pilot", "analyst", "expired", "manager", "employee"]
class UserScope(TypedDict):
@@ -155,8 +162,65 @@ def _load_roles_config() -> dict:
# ---------------------------------------------------------------------------
+def yaml_role(username: str) -> Role | None:
+ """Роль из roles.yaml (без похода в реестр) или None, если юзера там нет.
+
+ Нужна там, где спрашивают именно про legacy-файл, а не про эффективную роль:
+ `team.create_employee` (#3316) не даёт занять имя, за которым в YAML уже
+ числятся права.
+ """
+ users: dict[str, Role] = _load_roles_config()["users"]
+ return users.get(username)
+
+
+def _registry_role(username: str) -> str | None:
+ """Роль из реестра людей (`tradein_users.role` / `auth.users.role`) или None.
+
+ None означает «реестр про этого юзера ничего не сказал»: строки нет, роль
+ пустая, либо реестр вообще недоступен. Во всех трёх случаях решение
+ остаётся за roles.yaml — падение БД не имеет права выключить legacy-вход.
+
+ Имя таблицы берётся из фиксированного словаря `identity_schema()`, значение
+ едет bind-параметром: снаружи в SQL не попадает ничего.
+ """
+ try:
+ from sqlalchemy import text
+
+ from app.services.identity_store import identity_schema, identity_session
+
+ schema = identity_schema()
+ with identity_session() as db:
+ row = db.execute(
+ text(f"SELECT role FROM {schema.users_table} WHERE username = :username"),
+ {"username": username},
+ ).fetchone()
+ except Exception:
+ logger.exception(
+ "registry role lookup failed for %r — fallback to roles.yaml",
+ username,
+ )
+ return None
+ if row is None or not row.role:
+ return None
+ return str(row.role)
+
+
def get_role(username: str) -> Role:
- """Return the role for *username* or raise KeyError if unknown."""
+ """Эффективная роль *username*: реестр (БД) первый, roles.yaml — fallback.
+
+ Raises KeyError, если юзера нет ни там, ни там.
+
+ #3316: раньше роль резолвилась ТОЛЬКО из roles.yaml, при том что люди
+ заводятся в БД (`tradein_users`) — два дефекта разом. Вверх: сотрудник,
+ чьё имя совпало с YAML-админом, получал admin (IDOR по чужим оценкам +
+ безлимит квоты). Вниз: сотрудник, которого в YAML нет, получал KeyError →
+ 403 на СОБСТВЕННУЮ оценку. Единственный источник истины теперь один, и он
+ здесь — вызывающие (rbac, trade_in, team, account_quota) не меняются.
+ """
+ db_role = _registry_role(username)
+ if db_role is not None:
+ return cast(Role, db_role)
+
config = _load_roles_config()
users: dict[str, Role] = config["users"]
if username not in users:
@@ -217,13 +281,22 @@ def get_user_scope(username: str) -> UserScope:
"""
config = _load_roles_config()
role = get_role(username)
- role_def = config["roles"][role]
+ role_def = config["roles"].get(role)
+ if role_def is None:
+ # Роль реестра (employee/manager) — её scope живёт в DB_ROLE_PATHS, а не
+ # в roles.yaml (#3316: get_role теперь может вернуть и такую роль).
+ from app.services.auth_session import get_db_role_scope
+
+ allowed_paths, deny_paths = get_db_role_scope(role)
+ else:
+ allowed_paths = list(role_def.get("paths", []) or [])
+ deny_paths = list(role_def.get("deny", []) or [])
display_name, org, email = get_profile_for_user(username)
return UserScope(
username=username,
role=role,
- allowed_paths=list(role_def.get("paths", []) or []),
- deny_paths=list(role_def.get("deny", []) or []),
+ allowed_paths=allowed_paths,
+ deny_paths=deny_paths,
brand=get_brand_for_user(username),
display_name=display_name,
org=org,
diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py
index 06f14afc..62701225 100644
--- a/tradein-mvp/backend/app/core/rbac.py
+++ b/tradein-mvp/backend/app/core/rbac.py
@@ -38,7 +38,7 @@ from fastapi.responses import JSONResponse, Response
from app.core.auth import get_role, is_path_allowed
from app.core.config import settings
-from app.services.auth_session import get_db_role_scope, get_session_user
+from app.services.auth_session import DB_ROLE_PATHS, get_db_role_scope, get_session_user
from app.services.identity_store import identity_session
logger = logging.getLogger(__name__)
@@ -334,7 +334,11 @@ async def rbac_guard(
# scope-narrowed юзер не смог бы получить свою роль вовсе.
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
external_path = _EXTERNAL_PREFIX + path
- if from_session:
+ # Матчер выбирается по РОДУ роли, а не только по источнику (#3316):
+ # с DB-first резолвом legacy trusted-header путь тоже может отдать роль
+ # реестра (employee/manager), а её паттернов в roles.yaml нет — сверка
+ # с `is_path_allowed` дала бы 403 на всё.
+ if from_session or role in DB_ROLE_PATHS:
allowed = _db_role_path_allowed(role, external_path)
else:
try:
diff --git a/tradein-mvp/backend/app/services/account_quota.py b/tradein-mvp/backend/app/services/account_quota.py
index 10d778ca..7f420657 100644
--- a/tradein-mvp/backend/app/services/account_quota.py
+++ b/tradein-mvp/backend/app/services/account_quota.py
@@ -46,7 +46,7 @@ from fastapi import HTTPException
from sqlalchemy import text
from sqlalchemy.orm import Session
-from app.core.auth import get_role
+from app.core.auth import get_role, yaml_role
from app.core.config import settings
logger = logging.getLogger(__name__)
@@ -61,8 +61,7 @@ def limit_exhausted_message(limit: int) -> str:
отличаться от глобального MONTHLY_LIMIT для персонального override ИЛИ
anon default_limit, см. #b2c-antiabuse-2)."""
return (
- f"Лимит из {limit} оценок в этом месяце исчерпан. "
- "За полной версией обращайтесь к Копылову."
+ f"Лимит из {limit} оценок в этом месяце исчерпан. За полной версией обращайтесь к Копылову."
)
@@ -97,6 +96,14 @@ def is_unlimited(db: Session, username: str) -> bool:
return False
if role == "admin":
return True
+ # #3316: get_role резолвит роль из реестра (БД) первой, поэтому сотрудник
+ # team-API больше не даёт KeyError. Право на ПЕРСОНАЛЬНЫЙ безлимит при этом
+ # осталось там же, где было — за roles.yaml: фикс убирает эскалацию, а не
+ # раздаёт новую. Иначе руками проставленный `unlimited` начал бы работать
+ # для аккаунтов, которым он раньше молча игнорировался (и разъехался бы с
+ # `_batch_quota_status` в списке «Команды»).
+ if yaml_role(username) is None:
+ return False
row = db.execute(
text(
"""
diff --git a/tradein-mvp/backend/tests/test_role_single_source.py b/tradein-mvp/backend/tests/test_role_single_source.py
new file mode 100644
index 00000000..da358dcd
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_role_single_source.py
@@ -0,0 +1,142 @@
+"""#3316 — роль резолвится из ОДНОГО источника: реестр (БД) первый, roles.yaml — fallback.
+
+Проверяется значение роли, а не факт вызова механизма:
+ * имя из roles.yaml, заведённое в реестре сотрудником → роль `employee`
+ (эскалации в admin нет: ни IDOR по чужим оценкам, ни безлимитной квоты);
+ * сотрудник, которого в roles.yaml НЕТ → роль резолвится, ownership-check
+ пропускает его к СВОЕЙ оценке и держит на чужой (раньше был KeyError → 403);
+ * legacy-юзер (есть в YAML, в реестре строки нет) → роль ровно как раньше —
+ сверяется ВЕСЬ маппинг roles.yaml, а не один аккаунт;
+ * реестр недоступен → fallback на YAML (падение БД не выключает legacy-вход).
+
+FastAPI здесь не поднимается: резолвер — чистая функция от (реестр, YAML),
+реестр подменяется фейковой сессией.
+"""
+
+from __future__ import annotations
+
+import os
+from collections.abc import Callable, Iterator
+from contextlib import contextmanager
+from types import SimpleNamespace
+from typing import Any
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+import pytest
+
+from app.core import auth
+from app.services import identity_store
+
+_MISSING = object()
+
+
+class _FakeSession:
+ """Отдаёт одну строку `SELECT role ...` — или ничего, если роли нет."""
+
+ def __init__(self, role: str | None) -> None:
+ self.role = role
+ self.sql: str | None = None
+ self.params: dict[str, Any] | None = None
+
+ def execute(self, sql: Any, params: dict[str, Any] | None = None) -> Any:
+ self.sql = str(sql)
+ self.params = params
+ row = None if self.role is None else SimpleNamespace(role=self.role)
+ return SimpleNamespace(fetchone=lambda: row)
+
+
+@pytest.fixture
+def registry(monkeypatch: pytest.MonkeyPatch) -> Callable[..., _FakeSession | None]:
+ """`registry(role)` — что реестр отвечает на запрос роли.
+
+ role=None → строки нет (legacy-юзер); role=_MISSING → реестр падает.
+ """
+
+ def install(role: str | None | object) -> _FakeSession | None:
+ if role is _MISSING:
+
+ @contextmanager
+ def broken_session() -> Iterator[Any]:
+ raise RuntimeError("registry down")
+ yield # pragma: no cover — нужен, чтобы функция была генератором
+
+ monkeypatch.setattr(identity_store, "identity_session", broken_session)
+ return None
+
+ session = _FakeSession(role) # type: ignore[arg-type]
+
+ @contextmanager
+ def fake_session() -> Iterator[_FakeSession]:
+ yield session
+
+ monkeypatch.setattr(identity_store, "identity_session", fake_session)
+ return session
+
+ return install
+
+
+def _yaml_users() -> dict[str, str]:
+ return dict(auth._load_roles_config()["users"])
+
+
+def _yaml_admin() -> str:
+ for username, role in _yaml_users().items():
+ if role == "admin":
+ return username
+ pytest.skip("в auth/roles.yaml нет ни одного admin — тест неприменим")
+
+
+def test_registry_employee_beats_yaml_admin(registry: Callable[..., Any]) -> None:
+ """Эскалация закрыта: имя YAML-админа + строка `employee` в реестре = employee."""
+ victim_name = _yaml_admin()
+ session = registry("employee")
+
+ assert auth.get_role(victim_name) == "employee"
+ # username едет bind-параметром, а не склейкой в SQL.
+ assert session.params == {"username": victim_name}
+ assert victim_name not in (session.sql or "")
+
+
+def test_employee_absent_from_yaml_resolves_and_owns_estimate(
+ registry: Callable[..., Any],
+) -> None:
+ """Сотрудник вне roles.yaml: роль есть, своя оценка читается, чужая — нет."""
+ name = "employee_not_in_yaml_3316"
+ assert name not in _yaml_users()
+ registry("employee")
+
+ from fastapi import HTTPException
+
+ from app.api.v1.trade_in import _assert_estimate_access
+
+ # Продуктовое поведение проверяется ПЕРВЫМ: до #3316 здесь прилетал 403
+ # («user not in roles config») на СОБСТВЕННУЮ оценку сотрудника.
+ _assert_estimate_access(name, name)
+
+ assert auth.get_role(name) == "employee"
+
+ with pytest.raises(HTTPException) as exc:
+ _assert_estimate_access("someone_else", name)
+ assert exc.value.status_code == 404
+
+
+def test_legacy_yaml_users_keep_their_roles(registry: Callable[..., Any]) -> None:
+ """В реестре строки нет → роли ВСЕХ YAML-юзеров ровно те же, что и были."""
+ registry(None)
+ users = _yaml_users()
+ assert users, "roles.yaml без юзеров — сверять нечего"
+ assert {username: auth.get_role(username) for username in users} == users
+
+
+def test_registry_failure_falls_back_to_yaml(registry: Callable[..., Any]) -> None:
+ """Реестр недоступен → legacy-вход продолжает работать по YAML."""
+ registry(_MISSING)
+ assert auth.get_role(_yaml_admin()) == "admin"
+
+
+def test_yaml_role_is_yaml_only(registry: Callable[..., Any]) -> None:
+ """Предикат гварда create_employee смотрит ИМЕННО в YAML, мимо реестра."""
+ registry("employee")
+ assert auth.yaml_role(_yaml_admin()) == "admin"
+ assert auth.yaml_role("employee_not_in_yaml_3316") is None
From 41f21c4969c63c8125267f45c441c8c98f713ce2 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Wed, 2 Sep 2026 14:53:02 +0500
Subject: [PATCH 03/13] =?UTF-8?q?docs(scraper-kit):=20=D0=BE=D0=B1=D0=BE?=
=?UTF-8?q?=D1=81=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20#3319=20?=
=?UTF-8?q?=E2=80=94=20=D1=87=D1=82=D0=BE=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=BD?=
=?UTF-8?q?=D0=BE=20=D1=82=D0=B5=D1=80=D1=8F=D0=BB=D0=BE=20=D1=87=D0=B5?=
=?UTF-8?q?=D0=BA=D0=BF=D0=BE=D0=B8=D0=BD=D1=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ревью опровергло формулировку «точку терял каждый финализатор»: все четыре
писателя в runs.py мержат jsonb (`counters || :counters`), записанный ключ
переживал mark_done/mark_banned/mark_failed. Правка закрывает выходы РАНЬШЕ
первого end-of-anchor heartbeat (cancel/дрейн на первом якоре, ранний done
#1950 на якоре №1) — комментарий и докстринг переписаны на это.
Замер «0 из 67 за 60 дней» назван тем, чем он является: запись появилась
26.08.2026 (#3074) при такте avito 7 суток, выборка почти вся из эры без
механизма. Плюс тест на прогон без ключей (эра до #3074) — метка дрейна не
меняет вердикт «нечего подхватывать».
---
.../tests/test_3319_citysweep_checkpoint.py | 23 ++++++++++++++-----
.../src/scraper_kit/orchestration/pipeline.py | 18 ++++++++++-----
2 files changed, 29 insertions(+), 12 deletions(-)
diff --git a/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py b/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py
index 6ae05a73..a238119b 100644
--- a/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py
+++ b/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py
@@ -1,11 +1,15 @@
"""Чекпоинт avito_city_sweep доживает до финализатора (#3319).
-Прод-факт, из которого выросла задача: 0 из 67 прогонов за 60 дней имеют в
-counters ключ done_buckets. Механизм #3074 (запись точки) и механизм #930
-(подхват точки) существуют оба, но между ними нет ни одного прогона: точку
-писала ровно одна строка внутри цикла якорей, а КАЖДЫЙ выход из прогона
-(mark_done — включая ранний выход #1950 «SERP собран, detail заблокирован», —
-mark_banned, mark_failed) отдавал голый counters.to_dict() без неё.
+Точку писала ровно одна строка — end-of-anchor heartbeat в конце итерации цикла
+якорей. Финализаторы её не стирали (все писатели в runs.py мержат jsonb:
+`counters || :counters`), дыра в другом: выходы, случившиеся РАНЬШЕ первой такой
+записи, точки не оставляли вовсе — cancel/SIGTERM-дрейн на границе первого якоря
+и ранний done #1950 («SERP собран, detail заблокирован») на якоре №1. Ими и
+кончается типичный прод-прогон с `anchors_done: 1` из 5.
+
+Замер «0 из 67 прогонов за 60 дней несут done_buckets» тут НЕ доказательство:
+строка записи появилась только 26.08.2026 (#3074) при такте avito 7 суток —
+выборка почти целиком из эры, где механизма не существовало.
Три инварианта, ради которых тест:
1. done-выход несёт done_buckets — иначе точка существует только в логе.
@@ -212,3 +216,10 @@ def test_drained_done_is_resumable_but_clean_done_is_not() -> None:
resume_from, verdict = _resume_decision(_prev_run(ckpt))
assert resume_from is None, "полный обход подхватывать нечего"
assert verdict["resume_reason"] == "status_done"
+
+ # Прогон из эры до #3074: ключей нет вовсе — метка дрейна не должна менять
+ # вердикт «нечего подхватывать» на что-то другое.
+ _, verdict = _resume_decision(_prev_run({}))
+ assert verdict["resume_reason"] == "status_done"
+ _, verdict = _resume_decision(_prev_run({"interrupted": 1}))
+ assert verdict["resume_reason"] == "no_checkpoint"
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 2cbacf9c..654927ef 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
@@ -1186,12 +1186,18 @@ async def run_avito_city_sweep(
def _ckpt(**extra: Any) -> dict[str, Any]:
"""Счётчики прогона ВМЕСТЕ с чекпоинтом — payload любого выхода (#3319).
- До этого точку писала ровно одна строка внутри цикла якорей, а все
- финализаторы (mark_done/mark_banned/mark_failed, включая ранний выход
- #1950 «SERP OK, detail заблокирован») отдавали голый `counters.to_dict()`.
- Точка держалась исключительно на jsonb-мерже в runs.py — на свойстве
- ЧУЖОГО модуля, которого этот файл ничем не проверяет; выход, случившийся
- раньше первой записи (или писатель без мержа), терял её молча.
+ До этого точку писала ровно одна строка — end-of-anchor heartbeat в конце
+ итерации цикла. Финализаторы её НЕ стирали: все четыре писателя в runs.py
+ мержат jsonb (`counters || :counters`), уже записанный ключ переживал и
+ mark_done, и mark_banned, и mark_failed. Закрывается другая дыра — выходы,
+ случившиеся РАНЬШЕ первой такой записи: cancel/SIGTERM-дрейн на границе
+ первого якоря и ранний done #1950 («SERP собран, detail заблокирован») на
+ якоре №1. Именно им и кончается типичный прод-прогон, у которого
+ `anchors_done: 1` из 5.
+
+ Замер «0 из 67 прогонов за 60 дней несут done_buckets» сам по себе этого НЕ
+ доказывает: строка записи появилась только 26.08.2026 (#3074), а такт avito
+ — 7 суток, так что выборка почти целиком из эры, где механизма не было.
"""
return {**counters.to_dict(), "done_buckets": sorted(_done_anchors), **extra}
From cde95aa9f6ebde0886be0171f2ed832a3cee2746 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Wed, 2 Sep 2026 14:59:14 +0500
Subject: [PATCH 04/13] =?UTF-8?q?test(tradein):=20=D0=BF=D1=80=D0=BE=D0=B4?=
=?UTF-8?q?-=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=B0=D1=86?=
=?UTF-8?q?=D0=B8=D1=8F=20=D1=80=D0=BE=D0=BB=D0=B5=D0=B9=20=D0=B2=20=D0=BF?=
=?UTF-8?q?=D1=80=D0=B8=D1=91=D0=BC=D0=BA=D0=B5=20#3316=20+=20=D0=BA=D0=BE?=
=?UTF-8?q?=D0=BC=D0=BF=D1=80=D0=BE=D0=BC=D0=B8=D1=81=D1=81=20=D1=84=D0=BE?=
=?UTF-8?q?=D0=BB=D0=B1=D1=8D=D0=BA=D0=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review PR #3331: приёмка «роли не изменились» гонялась с ПУСТЫМ реестром, а в
проде строка в БД есть у 12 из 13 юзеров и DB-роль ИНАЯ (kopylov: manager при
YAML pilot, user1: employee при YAML pilot). Добавлены два кейса именно этой
конфигурации:
* YAML pilot + реестр employee → employee, и scope не поехал: allow/deny
DB_ROLE_PATHS['employee'] сверяются со списками роли pilot из roles.yaml
целиком — дрейф ЛЮБОГО из двух списков теперь красный тест, а не тихо
потерянный/выданный раздел в проде;
* YAML pilot + реестр manager → manager, и лишних путей на tradein-периметре
нет: manager отличается от employee ровно префиксом /api/v1/team/** (вне
/trade-in/**), deny-списки совпадают.
Докстринг `_registry_role`: зафиксирован компромисс — при недоступном реестре
фолбэк временно возвращает авторитетность roles.yaml, то есть состояние, которое
фикс и лечит. Сегодня безопасно (прод-коллизий имён нет, новые закрыты
409-гвардом create_employee); появится коллизия — ветку менять на fail-closed.
---
tradein-mvp/backend/app/core/auth.py | 9 ++++
.../backend/tests/test_role_single_source.py | 46 +++++++++++++++++++
2 files changed, 55 insertions(+)
diff --git a/tradein-mvp/backend/app/core/auth.py b/tradein-mvp/backend/app/core/auth.py
index 0906875b..512b924a 100644
--- a/tradein-mvp/backend/app/core/auth.py
+++ b/tradein-mvp/backend/app/core/auth.py
@@ -180,6 +180,15 @@ def _registry_role(username: str) -> str | None:
пустая, либо реестр вообще недоступен. Во всех трёх случаях решение
остаётся за roles.yaml — падение БД не имеет права выключить legacy-вход.
+ ⚠️ Осознанный компромисс (#3316 review): последняя ветка — недоступный
+ реестр — на время сбоя ВОЗВРАЩАЕТ авторитетность roles.yaml, то есть ровно
+ то состояние, которое этот фикс и лечит. Сегодня это безопасно: коллизий
+ имён между реестром и YAML на проде нет, а новые закрыты 409-гвардом в
+ `team.create_employee`. Если коллизия всё же появится (ручной INSERT в
+ реестр, расширение roles.yaml) — сбой БД станет окном эскалации, и тогда
+ эту ветку надо менять на fail-closed (отказ вместо YAML-роли), а не
+ дописывать проверки у вызывающих.
+
Имя таблицы берётся из фиксированного словаря `identity_schema()`, значение
едет bind-параметром: снаружи в SQL не попадает ничего.
"""
diff --git a/tradein-mvp/backend/tests/test_role_single_source.py b/tradein-mvp/backend/tests/test_role_single_source.py
index da358dcd..c60e00a8 100644
--- a/tradein-mvp/backend/tests/test_role_single_source.py
+++ b/tradein-mvp/backend/tests/test_role_single_source.py
@@ -129,6 +129,52 @@ def test_legacy_yaml_users_keep_their_roles(registry: Callable[..., Any]) -> Non
assert {username: auth.get_role(username) for username in users} == users
+def _yaml_pilot() -> str:
+ for username, role in _yaml_users().items():
+ if role == "pilot":
+ return username
+ pytest.skip("в auth/roles.yaml нет ни одного pilot — тест неприменим")
+
+
+def test_prod_config_pilot_in_yaml_employee_in_registry(registry: Callable[..., Any]) -> None:
+ """Прод-конфигурация 12 из 13 аккаунтов: строка в реестре ЕСТЬ и роль там иная.
+
+ Реестр главнее (`employee`), а объём прав от этого не меняется: scope
+ DB-роли `employee` обязан совпадать с вчерашним YAML-scope роли `pilot`.
+ Списки сверяются целиком — дрейф ЛЮБОГО из двух ловится здесь, а не
+ тихой потерей/выдачей раздела в проде.
+ """
+ from app.services.auth_session import DB_ROLE_PATHS
+
+ registry("employee")
+ assert auth.get_role(_yaml_pilot()) == "employee"
+
+ pilot = auth._load_roles_config()["roles"]["pilot"]
+ allow, deny = DB_ROLE_PATHS["employee"]
+ assert sorted(allow) == sorted(pilot["paths"])
+ assert sorted(deny) == sorted(pilot["deny"] or [])
+
+
+def test_prod_config_pilot_in_yaml_manager_in_registry(registry: Callable[..., Any]) -> None:
+ """Конфигурация kopylov: YAML pilot + реестр manager → manager.
+
+ Лишних путей на tradein-периметре это не даёт: manager отличается от
+ employee ровно одним префиксом `/api/v1/team/**` (дашборд «Команды»,
+ ВНЕ `/trade-in/**`), а deny-списки совпадают.
+ """
+ from app.services.auth_session import DB_ROLE_PATHS
+
+ registry("manager")
+ assert auth.get_role(_yaml_pilot()) == "manager"
+
+ emp_allow, emp_deny = DB_ROLE_PATHS["employee"]
+ mgr_allow, mgr_deny = DB_ROLE_PATHS["manager"]
+ extra = set(mgr_allow) - set(emp_allow)
+ assert extra == {"/api/v1/team/**"}
+ assert not any(p.startswith("/trade-in") for p in extra)
+ assert sorted(mgr_deny) == sorted(emp_deny)
+
+
def test_registry_failure_falls_back_to_yaml(registry: Callable[..., Any]) -> None:
"""Реестр недоступен → legacy-вход продолжает работать по YAML."""
registry(_MISSING)
From 18e0f1d9991342b39ae37d243c6ba965dd7c57e4 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Wed, 2 Sep 2026 16:46:44 +0500
Subject: [PATCH 05/13] =?UTF-8?q?test(tradein):=20=D0=B8=D0=B7=D0=BE=D0=BB?=
=?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20test=5Frbac.py=20?=
=?UTF-8?q?=D0=BE=D1=82=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8?=
=?UTF-8?q?=D1=8F=20=D1=80=D0=B5=D0=B5=D1=81=D1=82=D1=80=D0=B0=20(#3316)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CI-красное на голове ветки: 4 теста в tests/test_rbac.py ждали YAML-роль
(kopylov=pilot, user1=pilot), а в CI-базе реестр засеян миграцией 193
(kopylov=manager, user*=employee) — DB-first резолвер честно отдавал роль из БД.
Локально те же тесты были зелёными ровно потому, что БД нет и работал
YAML-fallback: результат файла зависел от ОКРУЖЕНИЯ, а такой тест не проверяет
ничего.
Чинится не подгонкой чисел в ассертах, а изоляцией: файл проверяет ИМЕННО
legacy-путь roles.yaml (разбор файла, globs, guard и /me на trusted-header), и
теперь заявляет это явно — autouse-фикстура `_legacy_yaml_only` глушит реестр
(`_registry_role` → None). Ассерты на YAML-роли после этого законны в любом
окружении. Приоритет реестра, эквивалентность scope employee↔pilot и
конфигурация kopylov (DB manager + YAML pilot) покрыты отдельно —
tests/test_role_single_source.py.
Проверено обоими способами: полный `pytest tests` без сида и он же с
плагином-имитацией засеянного реестра (подменяется тот же шов, что и в проде,
`identity_store.identity_session`) — 5290 passed, 35 skipped в обоих.
---
tradein-mvp/backend/tests/test_rbac.py | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/tradein-mvp/backend/tests/test_rbac.py b/tradein-mvp/backend/tests/test_rbac.py
index ea0837dd..dc3ecc0b 100644
--- a/tradein-mvp/backend/tests/test_rbac.py
+++ b/tradein-mvp/backend/tests/test_rbac.py
@@ -42,6 +42,23 @@ def _reset_auth_cache() -> None:
auth_mod.reset_cache_for_tests()
+@pytest.fixture(autouse=True)
+def _legacy_yaml_only(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Реестр в ЭТОМ файле молчит — здесь проверяется legacy-путь roles.yaml.
+
+ #3316 сделал `get_role` DB-first (реестр → YAML-fallback), и без этой
+ изоляции результат файла зависел бы от ОКРУЖЕНИЯ: локально без БД шла
+ YAML-ветка и всё было зелено, а в CI, где реестр засеян миграцией 193
+ (kopylov=manager, user*=employee), те же ассерты краснели. Тест, который
+ отвечает по-разному в двух окружениях, не проверяет ничего.
+
+ Здесь закреплена ровно YAML-семантика (разбор файла, globs, поведение
+ guard'а и /me на trusted-header пути); DB-first, приоритет реестра и
+ эквивалентность scope employee↔pilot покрыты tests/test_role_single_source.py.
+ """
+ monkeypatch.setattr(auth_mod, "_registry_role", lambda username: None)
+
+
# ---------------------------------------------------------------------------
# Test app — использует РЕАЛЬНЫЙ rbac_guard (app/core/rbac.py), а не копию.
# ---------------------------------------------------------------------------
From e2e7e17f953923bebe7624ea4726514f1ff4ebbe Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Wed, 2 Sep 2026 17:35:48 +0500
Subject: [PATCH 06/13] =?UTF-8?q?fix(domclick):=20=D0=BA=D0=B0=D0=B6=D0=B4?=
=?UTF-8?q?=D0=B0=D1=8F=20=D0=BF=D0=BE=D0=BF=D1=8B=D1=82=D0=BA=D0=B0=20?=
=?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=BE=D1=80=D0=B0=20=D0=BF=D0=BE=D0=BB=D1=83?=
=?UTF-8?q?=D1=87=D0=B0=D0=B5=D1=82=20=D0=B8=D1=81=D1=85=D0=BE=D0=B4=20(#3?=
=?UTF-8?q?332)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Обрыв «пул прокси пуст» уходил из цикла между attempted++ и записью исхода,
поэтому тождество attempted = enriched + failed + blocked ломалось ровно на 1
(прод: 5 прогонов с diff=1). Исход честно failed, не blocked: к площадке не
ходили, отказала наша инфраструктура — тот же разряд, что у транспортных сбоев
(#3283); причина прогона по-прежнему в no_proxy_stop=1 + mark_failed.
Та же дыра закрыта у save_detail_enrichment(...) is False: карточка разобрана,
но строки уже нет — попытка была, исхода не было.
Closes #3332
---
.../app/tasks/domclick_detail_backfill.py | 22 +++
.../test_3332_domclick_counter_identity.py | 179 ++++++++++++++++++
2 files changed, 201 insertions(+)
create mode 100644 tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py
diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py
index a6e4a11e..4d35cd87 100644
--- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py
+++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py
@@ -447,6 +447,19 @@ async def run_domclick_detail_backfill(
enrichment = await fetch_detail(source_url, browser_fetcher=bf, cookies=cookies)
if save_detail_enrichment(db, listing_id, enrichment):
counters.enriched += 1
+ else:
+ # #3332, та же дыра в тождестве, что и у пустого пула: карточку
+ # забрали, а строки уже нет (удалена/деактивирована между
+ # снимком и UPDATE) — попытка была, исхода не было. Тихо
+ # терять её нельзя: расхождение читается как потерянный блок.
+ counters.failed += 1
+ logger.warning(
+ "domclick_detail_backfill: run_id=%d listing %s — карточка "
+ "разобрана, но UPDATE не нашёл строку id=%d",
+ run_id,
+ source_url,
+ listing_id,
+ )
consecutive_blocks = 0
consecutive_soft = 0
@@ -471,6 +484,15 @@ async def run_domclick_detail_backfill(
# Прогон 5399 умер именно так: три «блока» подряд, из них два
# 500 от сайдкара и один пустой пул, отказов площадки — ноль.
if _caused_by_empty_pool(e):
+ # #3332: попытка уже посчитана в attempted (строка выше), а этот
+ # выход из цикла шёл мимо ВСЕХ исходов — тождество
+ # attempted = enriched + failed + blocked ломалось ровно на 1
+ # (прод: 5 прогонов с diff=1, каждый оборванный пустым пулом).
+ # Исход честно failed, а не blocked: к площадке не ходили, это
+ # отказ нашей стороны — тот же разряд, что у транспортных сбоев
+ # ниже. Причина не теряется: в записи прогона стоит
+ # no_proxy_stop=1 и mark_failed с текстом про пул.
+ counters.failed += 1
logger.error(
"domclick_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
"к площадке не ходили. enriched=%d attempted=%d",
diff --git a/tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py b/tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py
new file mode 100644
index 00000000..feb35609
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py
@@ -0,0 +1,179 @@
+"""Тождество счётчиков domclick_detail_backfill: attempted == сумма исходов (#3332).
+
+`counters.attempted` инкрементируется ДО попытки, исход дописывается уже в
+разборе результата. Обрыв «пул прокси пуст» (`_caused_by_empty_pool` →
+`no_proxy_stop = True; break`) уходил из цикла между этими двумя точками, поэтому
+текущая попытка оставалась без исхода: прод дал 5 прогонов с
+`attempted - (enriched + failed + blocked) == 1`. Расхождение читается как
+потерянный отказ площадки, хотя площадку не трогали вовсе.
+
+Исход выбран `failed`, а не `blocked`: к площадке не ходили, отказала наша
+инфраструктура — тот же разряд, что у транспортных сбоев (`_is_transport_failure`,
+#3283). Причину прогона несёт `no_proxy_stop=1` + `mark_failed`, счётчик её не
+подменяет.
+
+Вторая дыра того же класса (закрыта здесь же): `save_detail_enrichment` вернул
+False — карточка разобрана, но строки уже нет; попытка была, исхода не было.
+
+Проверка ПО ЗНАЧЕНИЮ: сравниваются числа, а не «не бросило исключение».
+Харнесс зеркалит tests/test_3196_domclick_ban_kind.py.
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from datetime import UTC, datetime, timedelta
+from unittest.mock import AsyncMock, MagicMock, patch
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+_wp_mock = MagicMock()
+sys.modules.setdefault("weasyprint", _wp_mock)
+
+import pytest # noqa: E402
+from scraper_kit.domclick_exceptions import DomClickBlockedError # noqa: E402
+from scraper_kit.proxy_errors import NoProxyAvailableError # noqa: E402
+
+from app.tasks.domclick_detail_backfill import ( # noqa: E402
+ DomClickDetailBackfillResult,
+ run_domclick_detail_backfill,
+)
+
+_FETCH = "app.tasks.domclick_detail_backfill.fetch_detail"
+_SAVE = "app.tasks.domclick_detail_backfill.save_detail_enrichment"
+_RUNS = "app.tasks.domclick_detail_backfill.runs_mod"
+_SLEEP = "app.tasks.domclick_detail_backfill.asyncio.sleep"
+_SETTINGS = "app.tasks.domclick_detail_backfill.settings"
+_BROWSER_FETCHER = "app.tasks.domclick_detail_backfill.BrowserFetcher"
+_SESSION_SVC = "app.tasks.domclick_detail_backfill.domclick_session_svc"
+
+
+def _make_snapshot(n: int) -> list[dict]:
+ return [
+ {
+ "id": i + 1,
+ "source_url": f"https://ekaterinburg.domclick.ru/card/sale__flat__{i + 1}",
+ }
+ for i in range(n)
+ ]
+
+
+def _mock_db(snapshot: list[dict]) -> MagicMock:
+ db = MagicMock()
+ sel = MagicMock()
+ sel.mappings.return_value.all.return_value = snapshot
+ db.execute.return_value = sel
+ return db
+
+
+def _mock_session_svc() -> MagicMock:
+ svc = MagicMock()
+ svc.load_session.return_value = {"CAS_ID": "123"}
+ svc.COOKIE_EXPIRY_WARN_DAYS = 5
+ svc.session_expires_at.return_value = datetime.now(tz=UTC) + timedelta(days=30)
+ return svc
+
+
+def _mock_browser_fetcher_cls() -> MagicMock:
+ instance = AsyncMock()
+ instance.__aenter__ = AsyncMock(return_value=instance)
+ instance.__aexit__ = AsyncMock(return_value=False)
+ instance.request_context_reset = MagicMock()
+ return MagicMock(return_value=instance)
+
+
+def _empty_pool_block() -> DomClickBlockedError:
+ """Как это приходит с прода: fetch_detail заворачивает ЛЮБОЙ сбой фетча в
+ DomClickBlockedError через `raise ... from exc`, поэтому пустой пул лежит в
+ __cause__ и снаружи выглядит блокировкой."""
+ blocked = DomClickBlockedError("browser fetch failed")
+ blocked.__cause__ = NoProxyAvailableError("domclick")
+ return blocked
+
+
+async def _run(
+ fetch: AsyncMock, *, snapshot: int, save_ok: bool = True
+) -> tuple[DomClickDetailBackfillResult, MagicMock]:
+ db = _mock_db(_make_snapshot(snapshot))
+ runs = MagicMock()
+ with (
+ patch(_SETTINGS, MagicMock(browser_http_endpoint="http://browser:9000")),
+ patch(_SESSION_SVC, _mock_session_svc()),
+ patch(_RUNS, runs),
+ patch(_BROWSER_FETCHER, _mock_browser_fetcher_cls()),
+ patch(_FETCH, fetch),
+ patch(_SAVE, return_value=save_ok),
+ patch(_SLEEP, new_callable=AsyncMock),
+ ):
+ counters = await run_domclick_detail_backfill(
+ db,
+ run_id=3332,
+ params={"batch_size": snapshot, "budget_sec": 3600, "max_consecutive_blocks": 5},
+ )
+ return counters, runs
+
+
+def _assert_identity(counters: DomClickDetailBackfillResult, *, expected_attempted: int) -> None:
+ outcomes = counters.enriched + counters.failed + counters.blocked
+ assert counters.attempted == expected_attempted, (
+ f"attempted={counters.attempted}, ожидали {expected_attempted} попыток"
+ )
+ assert counters.attempted == outcomes, (
+ f"тождество нарушено: attempted={counters.attempted}, "
+ f"сумма исходов={outcomes} (enriched={counters.enriched} "
+ f"failed={counters.failed} blocked={counters.blocked}), "
+ f"потеряно {counters.attempted - outcomes} попыток без исхода"
+ )
+
+
+@pytest.mark.asyncio
+async def test_empty_pool_mid_run_keeps_identity() -> None:
+ """2 успеха, на 3-й попытке пул пуст → attempted=3 == 2 enriched + 1 failed."""
+ fetch = AsyncMock(side_effect=[MagicMock(), MagicMock(), _empty_pool_block()])
+ counters, runs = await _run(fetch, snapshot=5)
+
+ _assert_identity(counters, expected_attempted=3)
+ assert counters.failed == 1, (
+ f"failed={counters.failed}: оборванная пустым пулом попытка должна быть "
+ "засчитана отказом НАШЕЙ стороны"
+ )
+ assert counters.blocked == 0, (
+ f"blocked={counters.blocked}: площадка не отказывала, запроса к ней не было — "
+ "начислять блок значит лгать про причину (#3283)"
+ )
+ # Причина обрыва по-прежнему в записи прогона, а не подменена счётчиком.
+ runs.mark_failed.assert_called_once()
+ assert runs.mark_failed.call_args.args[3]["no_proxy_stop"] == 1
+
+
+@pytest.mark.asyncio
+async def test_missing_row_on_save_keeps_identity() -> None:
+ """save_detail_enrichment вернул False (строки уже нет) → попытка не теряется."""
+ fetch = AsyncMock(return_value=MagicMock())
+ counters, _ = await _run(fetch, snapshot=2, save_ok=False)
+
+ _assert_identity(counters, expected_attempted=2)
+ assert counters.enriched == 0 and counters.failed == 2, (
+ f"enriched={counters.enriched} failed={counters.failed}: "
+ "непрошедший UPDATE — не успех и не блок"
+ )
+
+
+@pytest.mark.asyncio
+async def test_blocked_and_enriched_counted_once() -> None:
+ """Контроль на противоположную ошибку: блоки/успехи по-прежнему по одному разу."""
+ fetch = AsyncMock(
+ side_effect=[
+ MagicMock(),
+ DomClickBlockedError("refused", status=403),
+ MagicMock(),
+ ]
+ )
+ counters, _ = await _run(fetch, snapshot=3)
+
+ _assert_identity(counters, expected_attempted=3)
+ assert (counters.enriched, counters.blocked, counters.failed) == (2, 1, 0), (
+ f"enriched={counters.enriched} blocked={counters.blocked} failed={counters.failed}, "
+ "ожидали 2/1/0 — правка #3332 не должна начислять исход дважды"
+ )
From c093212eaeaff8d2aadc3d4d4bdcecf2fca41800 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Wed, 2 Sep 2026 17:37:13 +0500
Subject: [PATCH 07/13] =?UTF-8?q?fix(mera-public):=20=D1=87=D0=B5=D1=81?=
=?UTF-8?q?=D1=82=D0=BD=D1=8B=D0=B9=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=20?=
=?UTF-8?q?=D0=BF=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20=D0=B8=20?=
=?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=B2=D0=B0=D1=80?=
=?UTF-8?q?=D1=82=D0=B8=D1=80=D1=8B=20=D0=B2=20=D1=80=D0=B0=D1=83=D0=BD?=
=?UTF-8?q?=D0=B4=D0=B0=D1=85=20=D0=B8=D0=B3=D1=80=D1=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Остаток «100 − уверенные» в подписи города был однородным только на словах:
внутрь него входят пустые пробы, на которые describeCoverage отвечает «рядом
не продаётся ничего похожего» и расчёт не предлагает. Текст обещал расчёт и
на них — две наши же строки спорили друг с другом. Остаток разложен на долю
«посчитаем, но разброс шире» и долю «считать не станем».
Возраст объявления бэкенд считает percentile_cont(0.5) — это медиана, а
«в среднем» под скошенным распределением называет другую величину.
Раунды игры брались первыми тремя строками витрины, а она отсортирована по
полноте и свежести: на проде это давало три студии с одной улицы. Выбор
жадный по комнатности и улице, детерминированный — страница серверная.
---
.../__tests__/coverage-copy.test.ts | 40 +++++++++-
.../__tests__/varied-rounds.test.ts | 77 +++++++++++++++++++
.../mera-public/_components/v3/deal-view.ts | 34 ++++++++
.../src/app/mera-public/coverage-copy.ts | 25 ++++--
.../frontend/src/app/mera-public/page.tsx | 6 +-
5 files changed, 168 insertions(+), 14 deletions(-)
create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts
index 3d03e999..57cb0f5f 100644
--- a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts
@@ -63,7 +63,18 @@ describe("describeCoverage", () => {
it("когда дата известна у всех — доля не упоминается вовсе", () => {
// «дата известна у 30 из 30» — шум: доля есть, но она ничего не сообщает.
const [, ageTile] = describeCoverage(probe({ n_listings: 30, n_with_age: 30 })).tiles;
- expect(ageTile.label).toBe("столько в среднем висит объявление");
+ expect(ageTile.label).toBe("столько и дольше висит каждое второе объявление");
+ });
+
+ it("возраст подписан как медиана — «в среднем» под percentile_cont(0.5) неправда", () => {
+ // Бэкенд считает `percentile_cont(0.5)` (trade_in.py::_coverage_probe).
+ // На возрасте объявлений распределение скошено вправо: среднее заметно
+ // больше медианы, и подпись «в среднем» называла бы другую величину.
+ for (const over of [{ n_with_age: 6 }, { n_with_age: 34 }]) {
+ const [, ageTile] = describeCoverage(probe(over)).tiles;
+ expect(ageTile.label).not.toMatch(/в среднем/i);
+ expect(ageTile.label).toMatch(/каждое второе|половин|медиан/i);
+ }
});
it("город вне покрытия — говорим прямо и не предлагаем платный шаг", () => {
@@ -142,9 +153,30 @@ describe("describeCityExpectation", () => {
const said = describeCityExpectation("Ревда");
expect(said?.text).toContain("проверках");
expect(said?.text).not.toMatch(/точност/i);
- // Доли складываются в целое: 16 уверенных + 84 остальных — иначе текст
- // утверждает больше, чем измерено.
- expect(said?.text).toContain("84");
+ });
+
+ it("остаток разложен: где считаем с широким разбросом, а где не считаем вовсе", () => {
+ // Прежний текст называл остаток одним числом (100 − уверенные) и обещал
+ // расчёт на всём нём. Но пустые пробы сидят внутри этого остатка, а
+ // `describeCoverage` отвечает на них «рядом не продаётся ничего похожего»
+ // и платный шаг не предлагает. Числа берём из замера, а не из строки:
+ // подстрокой «3» нашлось бы и внутри «83».
+ for (const measure of CITY_COVERAGE) {
+ const thin = 100 - measure.confidentPct - measure.emptyPct;
+ const said = describeCityExpectation(measure.city);
+ const nums = (said?.text.match(/\d+/g) ?? []).map(Number);
+
+ expect(measure.emptyPct, `${measure.city}: нечего разделять`).toBeGreaterThan(0);
+ expect(nums, `${measure.city}: нет доли уверенных`).toContain(measure.confidentPct);
+ expect(nums, `${measure.city}: нет доли «посчитаем, но шире»`).toContain(thin);
+ expect(nums, `${measure.city}: нет доли пустых когорт`).toContain(measure.emptyPct);
+ expect(measure.confidentPct + thin + measure.emptyPct).toBe(100);
+ // Про пустые когорты сказано, что мы НЕ считаем, — иначе текст обещает
+ // расчёт там, где следующий же экран в нём откажет.
+ expect(said?.text, `${measure.city}: обещан расчёт на пустой когорте`).toMatch(
+ /считать не станем/,
+ );
+ }
});
it("неизвестный город не выдумывает величину", () => {
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts
new file mode 100644
index 00000000..88aa458a
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts
@@ -0,0 +1,77 @@
+/**
+ * Какие сделки попадают в игру «Угадай цену».
+ *
+ * Витрина отсортирована по полноте и свежести, а не по разнообразию, поэтому
+ * первые три её строки на проде оказывались тремя студиями с одной улицы: игра
+ * трижды спрашивала цену одной и той же квартиры. Проверяется ЗНАЧЕНИЕ выбора
+ * — какие именно строки взяты, — а не факт вызова функции.
+ */
+import { describe, expect, it } from "vitest";
+
+import { pickVariedDeals } from "../_components/v3/deal-view";
+import type { ShowcaseDeal } from "../public-api";
+
+const deal = (rooms: number, street: string | null, area_m2 = 40): ShowcaseDeal =>
+ ({
+ district: "Кировский",
+ rooms,
+ area_m2,
+ floor: 3,
+ total_floors: 9,
+ deal_quarter: "II квартал 2026",
+ predicted_rub: 5_000_000,
+ fact_rub: 5_000_000,
+ err_pct: 0,
+ n_analogs: 8,
+ note: "",
+ street_name: street,
+ street_scheme: null,
+ lat: null,
+ lon: null,
+ }) as ShowcaseDeal;
+
+/** Порядок — как на витрине: студии одной улицы стоят первыми. */
+const showcase: readonly ShowcaseDeal[] = [
+ deal(0, "улица Ленина", 25),
+ deal(0, "улица Ленина", 27),
+ deal(0, "улица Ленина", 29),
+ deal(2, "улица Ленина", 62),
+ deal(1, "улица Мира", 38),
+ deal(2, "улица Щорса", 58),
+];
+
+describe("pickVariedDeals", () => {
+ it("три раунда — три разные комнатности и три разные улицы", () => {
+ const picked = pickVariedDeals(showcase, 3);
+ expect(picked).toHaveLength(3);
+ expect(picked.map((d) => d.rooms)).toEqual([0, 1, 2]);
+ expect(picked.map((d) => d.street_name)).toEqual([
+ "улица Ленина",
+ "улица Мира",
+ "улица Щорса",
+ ]);
+ // Прежний slice(0, 3) взял бы три студии с Ленина — вот они, и их нет.
+ expect(picked.map((d) => d.area_m2)).toEqual([25, 38, 58]);
+ });
+
+ it("когда разнообразия нет — берём сколько есть, а не показываем меньше раундов", () => {
+ // Игра не рендерится, пока раундов меньше трёх. Отдать два «ради чистоты
+ // выбора» значило бы погасить секцию из-за однородной витрины.
+ const same = [deal(0, "улица Ленина", 25), deal(0, "улица Ленина", 27), deal(0, "улица Ленина", 29)];
+ expect(pickVariedDeals(same, 3)).toHaveLength(3);
+ });
+
+ it("строки без улицы не считаются повтором улицы", () => {
+ // `street_name` пуст примерно у 8% строк — такая строка про улицу молчит.
+ const noStreet = [deal(1, null, 38), deal(1, "улица Мира", 40), deal(2, null, 58)];
+ expect(pickVariedDeals(noStreet, 2).map((d) => d.area_m2)).toEqual([38, 58]);
+ });
+
+ it("выбор детерминирован — страница рендерится на сервере", () => {
+ // Разный набор на сервере и в браузере — это разъехавшаяся гидрация,
+ // поэтому никакого случайного выбора здесь быть не может.
+ const first = pickVariedDeals(showcase, 3);
+ const second = pickVariedDeals(showcase, 3);
+ expect(first.map((d) => d.area_m2)).toEqual(second.map((d) => d.area_m2));
+ });
+});
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
index b8751743..0a5bdcda 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
@@ -130,6 +130,40 @@ export function toGameRound(deal: ShowcaseDeal, index: number): GameRound {
};
}
+/**
+ * Строки витрины для игры — РАЗНЫЕ по типу квартиры и по улице.
+ *
+ * `slice(0, n)` брал первые в порядке витрины, а он отсортирован по полноте и
+ * свежести (`_sort_key` в `landing_showcase_deals.py`), а не по разнообразию:
+ * на проде это давало три студии с одной улицы, и игра выглядела как одна и та
+ * же квартира трижды. Жадно берём первую строку, у которой И комнатность, И
+ * улица ещё не встречались; когда таких не осталось — добираем первыми
+ * попавшимися, потому что раундов должно быть столько, сколько просили.
+ *
+ * Порядок строго по входу: страница рендерится на сервере, и любой `random`
+ * здесь означал бы разный набор на сервере и в браузере.
+ */
+export function pickVariedDeals(deals: readonly ShowcaseDeal[], n: number): ShowcaseDeal[] {
+ const picked: ShowcaseDeal[] = [];
+ const rooms = new Set();
+ const streets = new Set();
+ for (const deal of deals) {
+ if (picked.length >= n) break;
+ if (rooms.has(deal.rooms)) continue;
+ // Улицы нет примерно у 8% строк — такая строка не «повторяет улицу», она
+ // про неё молчит, и держать её из-за этого вне игры не за что.
+ if (deal.street_name !== null && streets.has(deal.street_name)) continue;
+ picked.push(deal);
+ rooms.add(deal.rooms);
+ if (deal.street_name !== null) streets.add(deal.street_name);
+ }
+ for (const deal of deals) {
+ if (picked.length >= n) break;
+ if (!picked.includes(deal)) picked.push(deal);
+ }
+ return picked;
+}
+
/**
* Разброс расхождений ПОКАЗАННЫХ строк.
*
diff --git a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts
index 42246c37..ffa884a0 100644
--- a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts
@@ -17,7 +17,8 @@
* объявления, а не срок продажи: выборка цензурирована, эти квартиры ещё
* продаются, реальная экспозиция проданных всегда меньше. Написать под
* цифрой «за столько продаются» — соврать; поэтому подпись здесь одна и
- * тестом запинена. И при `median === null` (бэкенд занулил её, когда
+ * тестом запинена. Величина при этом МЕДИАННАЯ (`percentile_cont(0.5)` на
+ * бэкенде), а не средняя — «в среднем» под ней было такой же неправдой. И при `median === null` (бэкенд занулил её, когда
* возраст известен меньше чем по пяти объявлениям) плитки просто нет —
* ноль вместо неизвестного значения был бы худшей из ошибок.
*/
@@ -73,10 +74,14 @@ function tilesFor(probe: CoverageProbe): CoverageTile[] {
const partial = probe.n_with_age < probe.n_listings;
tiles.push({
value: `${probe.median_listing_age_days} дн.`,
- // Формулировка запинена тестом — см. шапку модуля.
+ // Формулировка запинена тестом — см. шапку модуля. «В среднем» отсюда
+ // убрано 02.09: бэкенд считает `percentile_cont(0.5)`
+ // (`trade_in.py::_coverage_probe`) — это МЕДИАНА, и на скошенном вправо
+ // возрасте объявлений она заметно меньше среднего. «Каждое второе» —
+ // то же самое утверждение словами, которые не надо расшифровывать.
label: partial
- ? `столько в среднем висит объявление; дата известна у ${probe.n_with_age} из ${probe.n_listings}`
- : "столько в среднем висит объявление",
+ ? `столько и дольше висит каждое второе объявление; дата известна у ${probe.n_with_age} из ${probe.n_listings}`
+ : "столько и дольше висит каждое второе объявление",
});
}
@@ -103,13 +108,17 @@ export function describeCityExpectation(city: string): CityExpectation | null {
const measure = CITY_COVERAGE.find((m) => m.city === city);
if (!measure) return null;
- const rest = 100 - measure.confidentPct;
+ // Остаток от уверенных — НЕ однородный: в него входят и пустые пробы, где
+ // считать нечего. Пока он назывался одним числом, текст обещал расчёт и на
+ // них тоже, а `describeCoverage` в той же ситуации отвечает «не считаем» —
+ // две наши же строки спорили друг с другом. Делим остаток надвое.
+ const thin = 100 - measure.confidentPct - measure.emptyPct;
return {
text:
`В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} проверках ` +
- `из 100; в остальных ${rest} данных меньше — расчёт мы всё равно сделаем, но разброс ` +
- `будет шире, и в ответе это будет написано. В ${measure.emptyPct} случаях из 100 рядом ` +
- "не находится ни одной похожей квартиры.",
+ `из 100. Ещё в ${thin} данных меньше — расчёт мы сделаем, но разброс будет шире, и в ` +
+ `ответе это будет написано. В оставшихся ${measure.emptyPct} рядом не находится ни одной ` +
+ "похожей квартиры — так и скажем, а считать не станем.",
source: `${CITY_COVERAGE_SOURCE}. Прогнано адресов: ${measure.sampleN}.`,
};
}
diff --git a/tradein-mvp/frontend/src/app/mera-public/page.tsx b/tradein-mvp/frontend/src/app/mera-public/page.tsx
index ace48006..f3a753d6 100644
--- a/tradein-mvp/frontend/src/app/mera-public/page.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/page.tsx
@@ -12,7 +12,7 @@ import { StepsV3 } from "./_components/v3/StepsV3";
import { StickyCtaV3 } from "./_components/v3/StickyCtaV3";
import { TwoPathsV3 } from "./_components/v3/TwoPathsV3";
import { WhatYouGetV3 } from "./_components/v3/WhatYouGetV3";
-import { toGameRound } from "./_components/v3/deal-view";
+import { pickVariedDeals, toGameRound } from "./_components/v3/deal-view";
import { fetchLandingStats, fetchShowcase } from "./public-api";
import styles from "./landing.module.css";
@@ -81,7 +81,9 @@ const MIN_ROUNDS = 3;
export default async function MeraPublicPage() {
const [stats, showcase] = await Promise.all([fetchLandingStats(), fetchShowcase()]);
const deals = showcase?.deals ?? [];
- const rounds = deals.slice(0, MIN_ROUNDS).map(toGameRound);
+ // Не первые три, а три РАЗНЫЕ — иначе игра предлагает угадать одну и ту же
+ // студию с одной улицы трижды (см. `pickVariedDeals`).
+ const rounds = pickVariedDeals(deals, MIN_ROUNDS).map(toGameRound);
return (
<>
From 177207223bc3ca62fb37f9fc67446004d250c5bd Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Wed, 2 Sep 2026 17:41:38 +0500
Subject: [PATCH 08/13] =?UTF-8?q?fix(caddy):=20=D1=81=D0=BD=D1=8F=D1=82?=
=?UTF-8?q?=D1=8C=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D1=8B=D0=B9=20internal-?=
=?UTF-8?q?=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=20=D1=81=20=D0=B0=D0=BD?=
=?UTF-8?q?=D0=BE=D0=BD=D0=B8=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=83?=
=?UTF-8?q?=D1=82=D0=B8=20=D0=B8=20=D1=87=D0=B0=D0=BD=D0=BA=D0=B8=20=D0=B7?=
=?UTF-8?q?=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=BE=D0=B3=D0=BE=20=D0=BA=D0=BE?=
=?UTF-8?q?=D0=BD=D1=82=D1=83=D1=80=D0=B0=20=D1=81=20=D0=BF=D1=83=D0=B1?=
=?UTF-8?q?=D0=BB=D0=B8=D1=87=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B4=D0=BE=D0=BC?=
=?UTF-8?q?=D0=B5=D0=BD=D0=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#3324, п.3: Caddy на gendsgn.ru инжектил X-Internal-Auth-Secret в каждый
анонимный запрос /trade-in/api/* и /trade-in/* (хоп во фронт). Сравнение
недостижимо по построению: backend читает этот заголовок только в
legacy trusted-header ветке rbac.py, и только после непустого
X-Authenticated-User, который тот же самый хоп строкой ниже удаляет.
Второй потребитель секрета (вебхук GlitchTip) берёт его из query-параметра.
Защита от подделки заголовков внутри gendesign_shared — проверка в rbac.py,
она не тронута; инжект работал в обратную сторону, раздавая секрет наружу.
#3324, п.1: meraocenka.ru раздавал анонимам постраничные чанки App Router
чужих маршрутов (chunks/app/{admin,team,scrapers,...}/) — код закрытого
контура с именами внутренних ручек. Матчер отсекает 404-ом «есть подкаталог,
и он не mera-public»; лэндингу нужны только chunks/app/<файл>.js и
chunks/app/mera-public/* (замер на проде). Карта чанков в webpack-*.js
остаётся — имена файлов по-прежнему читаются, полное разделение = split build.
Регресс-тесты в smoke-mera-perimeter.sh отличают 404 Caddy (пустое тело) от
404 Next и отдельно проверяют, что статика лэндинга жива.
---
caddy/sites/apps.caddy | 70 ++++++++++++++++++++++++++++-----
scripts/smoke-mera-perimeter.sh | 45 +++++++++++++++++++++
2 files changed, 105 insertions(+), 10 deletions(-)
diff --git a/caddy/sites/apps.caddy b/caddy/sites/apps.caddy
index 085146a4..83f6293e 100644
--- a/caddy/sites/apps.caddy
+++ b/caddy/sites/apps.caddy
@@ -175,10 +175,28 @@ gendsgn.ru {
# "удалить заголовок" (Caddyfile reverse_proxy directive: `-` =
# delete) — корректное поведение не должно зависеть от того, как именно
# Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции.
- # X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается
- # из env (Set-операция с непустым значением, никак не связана с auth-гейтом
- # basic_auth), это единственное, что теперь отсекает подделку заголовков
- # изнутри gendesign_shared network для legacy dual-mode пути.
+ # X-Internal-Auth-Secret ЗДЕСЬ БОЛЬШЕ НЕ ПОДСТАВЛЯЕТСЯ (#3324). До этой
+ # правки Caddy инжектил его в КАЖДЫЙ запрос этого хопа — включая
+ # анонимный, до всякого логина trade-in.
+ #
+ # ПОЧЕМУ ОН БЫЛ МЁРТВЫМ. Единственное место, где backend читает этот
+ # ЗАГОЛОВОК, — `app/core/rbac.py` (legacy trusted-header ветка): он
+ # сверяется ТОЛЬКО после того, как в запросе нашёлся непустой
+ # X-Authenticated-User (иначе ветка отдаёт 401 раньше, на «no
+ # authenticated user»). А X-Authenticated-User на этом же хопе строкой
+ # ниже удаляется — то есть пара «имя + секрет» через Caddy прийти не
+ # может по построению, сравнение недостижимо. Второй потребитель
+ # секрета, приёмник вебхуков GlitchTip (`app/api/v1/glitchtip.py`),
+ # берёт его из query-параметра `?secret=`, а не из заголовка, и на эту
+ # правку не реагирует.
+ #
+ # ЧТО ОТСЕКАЕТ ПОДДЕЛКУ ЗАГОЛОВКОВ ИЗНУТРИ gendesign_shared — проверка
+ # в rbac.py, а не факт подстановки в Caddy; она остаётся нетронутой.
+ # Инжект работал в обратную сторону: раздавал внутренний секрет по
+ # анонимному пути (он же виден в env контейнера Caddy). Легитимный
+ # dual-mode трафик — внутрисетевой (`docker exec tradein-backend curl
+ # -H 'X-Authenticated-User: …' -H 'X-Internal-Auth-Secret: …'`,
+ # см. auth/roles.yaml) — ходит мимо Caddy и сам несёт оба заголовка.
handle /trade-in/api/* {
# `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
@@ -186,7 +204,6 @@ gendsgn.ru {
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {
header_up -X-Authenticated-User
- header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
@@ -224,9 +241,13 @@ gendsgn.ru {
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
reverse_proxy tradein-frontend:3000 {
# См. комментарий над /trade-in/api/* выше — та же логика (явное
- # удаление вместо Set с пустым {http.auth.user.id}).
+ # удаление вместо Set с пустым {http.auth.user.id}), и по той же
+ # причине здесь больше нет инжекта X-Internal-Auth-Secret
+ # (#3324). На этом хопе он был мёртв ещё очевиднее: адресат —
+ # Next-сервер tradein-frontend, в его коде заголовок не читается
+ # и дальше в backend не пробрасывается (SSR не форвардит
+ # входящие заголовки — `headers()` во фронте не используется).
header_up -X-Authenticated-User
- header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
@@ -463,7 +484,33 @@ meraocenka.ru {
# `_next/*` открыл бы анонимам ещё и `/_next/image` (оптимизация картинок,
# CPU-нагрузка по запросу), который на лэндинге не используется вообще:
# next/image в tradein-mvp/frontend/src/app/mera-public/ не импортируется.
+ #
+ # #3324: внутри разрешённой статики закрыто ПОДДЕРЕВО ЧУЖИХ МАРШРУТОВ.
+ # App Router раскладывает код постранично: `chunks/app/<маршрут>/page-.js`
+ # (замер на проде 02.09.2026 — лэндингу нужны РОВНО `chunks/app/layout-*`,
+ # `chunks/app/error-*`, `chunks/app/global-error-*`, `chunks/app/mera-public/*`
+ # и общие хэш-чанки прямо в `chunks/`). Всё остальное под `chunks/app/` —
+ # это /admin, /scrapers, /team, /history, /cache, /versions, /v2,
+ # /sale-share, /login, /ui-preview: страницы закрытого контура, которые
+ # аноним с публичного домена качал целиком и читал в них имена внутренних
+ # ручек. Матчер отсекает ровно «есть подкаталог, и он не mera-public» —
+ # `not` вместо негативного lookahead, RE2 его не поддерживает.
+ #
+ # Ограничение честно: это убирает КОД, но не имена файлов — карта чанков
+ # лежит в `webpack-*.js`, который лэндингу нужен, поэтому перечень
+ # маршрутов из неё по-прежнему вычитывается. Полное разделение — split
+ # build (отдельный public-бандл), отдельная задача.
+ #
+ # `respond` внутри `handle` выполняется раньше `reverse_proxy` (порядок
+ # директив Caddy), поэтому вложенный матчер не зависит от сортировки
+ # handle-блоков между собой.
handle /trade-in/_next/static/* {
+ @foreignRouteChunk {
+ path_regexp ^/trade-in/_next/static/chunks/app/[^/]+/
+ not path /trade-in/_next/static/chunks/app/mera-public/*
+ }
+ respond @foreignRouteChunk 404
+
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
@@ -499,9 +546,12 @@ meraocenka.ru {
# strip_prefix — та же причина, что у B2B-хопа (:127): basePath Next'а не
# часть маршрута FastAPI.
#
- # X-Internal-Auth-Secret здесь НЕ подставляется (в отличие от :130):
- # публичные ручки его не проверяют, а инжектить внутренний секрет в хоп с
- # анонимного домена — расширять доверие без нужды.
+ # X-Internal-Auth-Secret здесь НЕ подставляется: публичные ручки его не
+ # проверяют, а инжектить внутренний секрет в хоп с анонимного домена —
+ # расширять доверие без нужды. С #3324 это уже не отличие от B2B-блока
+ # gendsgn.ru, а общее правило: инжекта не осталось нигде в Caddyfile —
+ # заголовок нужен только внутрисетевому dual-mode трафику, который ходит
+ # мимо прокси и несёт его сам.
handle /trade-in/api/public/* {
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {
diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh
index 478adcda..a0906279 100644
--- a/scripts/smoke-mera-perimeter.sh
+++ b/scripts/smoke-mera-perimeter.sh
@@ -119,6 +119,51 @@ check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API)
# Ловит расширение матчера обратно до `/trade-in/_next/*`.
check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404
+# 2c-bis. Внутри разрешённой статики закрыто поддерево ЧУЖИХ маршрутов (#3324):
+# App Router кладёт код постранично в chunks/app/<маршрут>/, и до этой
+# правки аноним скачивал с публичного домена бандлы /admin, /team,
+# /scrapers — с именами внутренних ручек внутри.
+#
+# КОД 404 ЗДЕСЬ НЕДОСТАТОЧЕН: несуществующий чанк Next тоже отдаёт 404,
+# поэтому проверка не отличила бы «Caddy отсёк» от «Caddy проксировал, а
+# файла нет» — и осталась бы зелёной после отката матчера. Отличаем по
+# ТЕЛУ: `respond 404` Caddy пустой (0 байт), 404 от Next — непустой
+# (замер на проде 02.09.2026: 9 байт).
+check_caddy_404() {
+ local desc="$1" url="$2"
+ local out code size
+ out=$(curl -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 15 "$url" 2>/dev/null)
+ code=${out%% *}
+ size=${out##* }
+ if [ "$code" = "404" ] && [ "$size" = "0" ]; then
+ echo "PASS: $desc ($url -> 404, пустое тело = отсёк Caddy)"
+ else
+ echo "FAIL: $desc ($url -> got '${out:-}', expected '404 0')"
+ fail=1
+ fi
+}
+
+check_caddy_404 "meraocenka.ru — чанки /admin не раздаются" \
+ "$BASE_MERA/trade-in/_next/static/chunks/app/admin/page-smoke.js"
+check_caddy_404 "meraocenka.ru — чанки /admin/analytics не раздаются" \
+ "$BASE_MERA/trade-in/_next/static/chunks/app/admin/analytics/page-smoke.js"
+check_caddy_404 "meraocenka.ru — чанки /team не раздаются" \
+ "$BASE_MERA/trade-in/_next/static/chunks/app/team/page-smoke.js"
+
+# Обратная сторона того же матчера: статика САМОГО лэндинга обязана остаться
+# живой. Без этой строки «починка» вида «404 на весь chunks/app/» выглядела бы
+# успешной, а публичный сайт молча остался бы без JS.
+layout_chunk=$(curl -s --max-time 15 "$BASE_MERA/" 2>/dev/null \
+ | grep -o '/trade-in/_next/static/chunks/app/layout-[^"]*\.js' | head -1)
+if [ -z "$layout_chunk" ]; then
+ # Пустая строка вместо пути дала бы запрос к корню и зелёную проверку ни о
+ # чём — поэтому это FAIL, а не «пропустим».
+ echo "FAIL: не нашёл layout-чанк в HTML лэндинга (сам лэндинг сломан?)"
+ fail=1
+else
+ check "meraocenka.ru — корневой layout-чанк лэндинга жив (200)" "$BASE_MERA$layout_chunk" 200
+fi
+
# 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/*
# доступны анонимно на обоих доменах; ВЕСЬ /api/v1/* на публичном домене
# по-прежнему 404.
From ef166eaf2191d399e22dc19482a32d1f350af936 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Wed, 2 Sep 2026 17:43:56 +0500
Subject: [PATCH 09/13] =?UTF-8?q?fix(mera-public):=20=D0=B7=D0=B0=D1=89?=
=?UTF-8?q?=D0=B8=D1=82=D1=8B=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20?=
=?UTF-8?q?=D1=82=D0=B0=D0=B2=D1=82=D0=BE=D0=BB=D0=BE=D0=B3=D0=B8=D0=B9=20?=
=?UTF-8?q?=D0=B2=20coverage-=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B5=20=D0=B8?=
=?UTF-8?q?=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=B5=20=D1=80=D0=B0=D1=83?=
=?UTF-8?q?=D0=BD=D0=B4=D0=BE=D0=B2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Проверка «уверенные + средние + пустые = 100» была верна по определению
средней доли и упасть не могла. Инвариант перенесён на сам замер: уверенные и
пустые обязаны оставлять место третьей доле. Если её всё-таки не останется
(перезамер 85 + 15), текст теперь двухчастный — «Ещё в 0 данных меньше»
отрендериться не может.
`street_name !== null` считал пустую строку названием улицы, и вторая такая
строка вылетала из игры как «повтор». Проверка на пустоту.
---
.../__tests__/coverage-copy.test.ts | 21 +++++++++++++-
.../__tests__/varied-rounds.test.ts | 8 ++++++
.../mera-public/_components/v3/deal-view.ts | 8 ++++--
.../src/app/mera-public/coverage-copy.ts | 28 +++++++++++++++----
4 files changed, 55 insertions(+), 10 deletions(-)
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts
index 57cb0f5f..e46e5232 100644
--- a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts
@@ -170,7 +170,12 @@ describe("describeCityExpectation", () => {
expect(nums, `${measure.city}: нет доли уверенных`).toContain(measure.confidentPct);
expect(nums, `${measure.city}: нет доли «посчитаем, но шире»`).toContain(thin);
expect(nums, `${measure.city}: нет доли пустых когорт`).toContain(measure.emptyPct);
- expect(measure.confidentPct + thin + measure.emptyPct).toBe(100);
+ // Инвариант ЗАМЕРА, а не арифметики: уверенные и пустые обязаны
+ // оставлять место третьей доле, иначе рендер печатает «Ещё в 0».
+ expect(
+ measure.confidentPct + measure.emptyPct,
+ `${measure.city}: средней доле не осталось места`,
+ ).toBeLessThan(100);
// Про пустые когорты сказано, что мы НЕ считаем, — иначе текст обещает
// расчёт там, где следующий же экран в нём откажет.
expect(said?.text, `${measure.city}: обещан расчёт на пустой когорте`).toMatch(
@@ -179,6 +184,20 @@ describe("describeCityExpectation", () => {
}
});
+ it("когда средней доли не осталось — предложения про неё нет вовсе", () => {
+ // Перезамер вида 85 уверенных + 15 пустых законен, и «Ещё в 0 данных
+ // меньше» на нём было бы сообщением о доле, которой не существует.
+ const said = describeCityExpectation("Ревда", {
+ city: "Ревда",
+ confidentPct: 85,
+ emptyPct: 15,
+ sampleN: 100,
+ });
+ expect(said?.text).not.toMatch(/Ещё в 0|в 0 /);
+ expect((said?.text.match(/\d+/g) ?? []).map(Number)).toEqual([85, 100, 15]);
+ expect(said?.text).toMatch(/считать не станем/);
+ });
+
it("неизвестный город не выдумывает величину", () => {
expect(describeCityExpectation("Москва")).toBeNull();
});
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts
index 88aa458a..8143d250 100644
--- a/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts
@@ -67,6 +67,14 @@ describe("pickVariedDeals", () => {
expect(pickVariedDeals(noStreet, 2).map((d) => d.area_m2)).toEqual([38, 58]);
});
+ it("пустая строка в street_name — не ключ улицы, а её отсутствие", () => {
+ // `!== null` считал "" настоящим названием, и вторая такая строка
+ // вылетала как «повтор улицы» — повтора здесь нет, есть два молчания.
+ // Третья строка — та, которую при этом брали ВМЕСТО второй.
+ const blank = [deal(1, "", 38), deal(2, "", 58), deal(2, "улица Мира", 60)];
+ expect(pickVariedDeals(blank, 2).map((d) => d.area_m2)).toEqual([38, 58]);
+ });
+
it("выбор детерминирован — страница рендерится на сервере", () => {
// Разный набор на сервере и в браузере — это разъехавшаяся гидрация,
// поэтому никакого случайного выбора здесь быть не может.
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
index 0a5bdcda..5bbd2f0e 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
@@ -151,11 +151,13 @@ export function pickVariedDeals(deals: readonly ShowcaseDeal[], n: number): Show
if (picked.length >= n) break;
if (rooms.has(deal.rooms)) continue;
// Улицы нет примерно у 8% строк — такая строка не «повторяет улицу», она
- // про неё молчит, и держать её из-за этого вне игры не за что.
- if (deal.street_name !== null && streets.has(deal.street_name)) continue;
+ // про неё молчит, и держать её из-за этого вне игры не за что. Проверка
+ // именно на пустоту, а не на `!== null`: пустая строка — то же самое
+ // молчание, и второй такой строке нельзя вылетать как «повтору улицы».
+ if (deal.street_name && streets.has(deal.street_name)) continue;
picked.push(deal);
rooms.add(deal.rooms);
- if (deal.street_name !== null) streets.add(deal.street_name);
+ if (deal.street_name) streets.add(deal.street_name);
}
for (const deal of deals) {
if (picked.length >= n) break;
diff --git a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts
index ffa884a0..442c0edb 100644
--- a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts
@@ -24,6 +24,7 @@
*/
import { CITY_COVERAGE, CITY_COVERAGE_SOURCE } from "./landing-facts";
+import type { CityCoverageMeasure } from "./landing-facts";
import type { CoverageProbe } from "./public-api";
export interface CoverageTile {
@@ -104,8 +105,13 @@ export interface CityExpectation {
* что выбрасывать города из списка не за что. Разница между ними
* количественная — её и показываем числом, а не отсутствием опции.
*/
-export function describeCityExpectation(city: string): CityExpectation | null {
- const measure = CITY_COVERAGE.find((m) => m.city === city);
+export function describeCityExpectation(
+ city: string,
+ // Замер вторым аргументом — чтобы ветку «средней доли не осталось» можно
+ // было проверить, не подделывая CITY_COVERAGE: сегодня такого города в
+ // замере нет, а появиться он может при первом же перезамере.
+ measure: CityCoverageMeasure | undefined = CITY_COVERAGE.find((m) => m.city === city),
+): CityExpectation | null {
if (!measure) return null;
// Остаток от уверенных — НЕ однородный: в него входят и пустые пробы, где
@@ -113,12 +119,22 @@ export function describeCityExpectation(city: string): CityExpectation | null {
// них тоже, а `describeCoverage` в той же ситуации отвечает «не считаем» —
// две наши же строки спорили друг с другом. Делим остаток надвое.
const thin = 100 - measure.confidentPct - measure.emptyPct;
+ const head =
+ `В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} ` +
+ "проверках из 100. ";
+ // Средней доли может не остаться вовсе (перезамер вида 85 уверенных + 15
+ // пустых) — тогда предложение про неё выпадает целиком. «Ещё в 0 данных
+ // меньше» было бы не округлением, а сообщением о доле, которой нет.
+ const empty = thin > 0 ? measure.emptyPct : 100 - measure.confidentPct;
+ const tail =
+ `В оставшихся ${empty} рядом не находится ни одной похожей квартиры — ` +
+ "так и скажем, а считать не станем.";
return {
text:
- `В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} проверках ` +
- `из 100. Ещё в ${thin} данных меньше — расчёт мы сделаем, но разброс будет шире, и в ` +
- `ответе это будет написано. В оставшихся ${measure.emptyPct} рядом не находится ни одной ` +
- "похожей квартиры — так и скажем, а считать не станем.",
+ thin > 0
+ ? `${head}Ещё в ${thin} данных меньше — расчёт мы сделаем, но разброс будет шире, и в ` +
+ `ответе это будет написано. ${tail}`
+ : head + tail,
source: `${CITY_COVERAGE_SOURCE}. Прогнано адресов: ${measure.sampleN}.`,
};
}
From 5d9ff13daea8d3b47ca8fea805497e5fee58c99d Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Wed, 2 Sep 2026 17:47:28 +0500
Subject: [PATCH 10/13] =?UTF-8?q?fix(caddy):=20=D0=B8=D1=81=D0=BA=D0=BB?=
=?UTF-8?q?=D1=8E=D1=87=D0=B8=D1=82=D1=8C=20=5Fnot-found=20=D0=B8=D0=B7=20?=
=?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=B0=20=D1=87=D0=B0=D0=BD?=
=?UTF-8?q?=D0=BA=D0=BE=D0=B2=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=BE?=
=?UTF-8?q?=D0=B3=D0=BE=20=D0=BA=D0=BE=D0=BD=D1=82=D1=83=D1=80=D0=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ревью #3340: `chunks/app/_not-found/` — внутренняя страница Next, а не
маршрут продукта; фильтр её глушил, и клиентская навигация по битому href
падала в error-boundary вместо честного «не найдено». Закрытого кода она
не несёт. Заодно в комментарии матчера явно зафиксировано, что
`static/css/app/<маршрут>/*` оставлен без фильтра сознательно — тот же
класс «имена, а не код», потолок уже задекларирован.
---
caddy/sites/apps.caddy | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/caddy/sites/apps.caddy b/caddy/sites/apps.caddy
index 83f6293e..8695088d 100644
--- a/caddy/sites/apps.caddy
+++ b/caddy/sites/apps.caddy
@@ -496,10 +496,17 @@ meraocenka.ru {
# ручек. Матчер отсекает ровно «есть подкаталог, и он не mera-public» —
# `not` вместо негативного lookahead, RE2 его не поддерживает.
#
+ # `_not-found` в исключениях — это не маршрут продукта, а внутренняя
+ # страница Next (404 при клиентской навигации по битому href). Закрытого
+ # кода не несёт, а без исключения клиентский переход падал бы в
+ # error-boundary вместо честного «не найдено».
+ #
# Ограничение честно: это убирает КОД, но не имена файлов — карта чанков
# лежит в `webpack-*.js`, который лэндингу нужен, поэтому перечень
- # маршрутов из неё по-прежнему вычитывается. Полное разделение — split
- # build (отдельный public-бандл), отдельная задача.
+ # маршрутов из неё по-прежнему вычитывается. По той же причине оставлены
+ # без фильтра и `static/css/app/<маршрут>/*`: это тот же класс «имена, а не
+ # код», и городить второй матчер ради него смысла нет. Полное разделение —
+ # split build (отдельный public-бандл), отдельная задача.
#
# `respond` внутри `handle` выполняется раньше `reverse_proxy` (порядок
# директив Caddy), поэтому вложенный матчер не зависит от сортировки
@@ -507,7 +514,7 @@ meraocenka.ru {
handle /trade-in/_next/static/* {
@foreignRouteChunk {
path_regexp ^/trade-in/_next/static/chunks/app/[^/]+/
- not path /trade-in/_next/static/chunks/app/mera-public/*
+ not path /trade-in/_next/static/chunks/app/mera-public/* /trade-in/_next/static/chunks/app/_not-found/*
}
respond @foreignRouteChunk 404
From 5541aa9d376621278819599288bc255c1d2cb661 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Thu, 3 Sep 2026 09:50:41 +0300
Subject: [PATCH 11/13] =?UTF-8?q?fix(mera-public):=20=D0=BF=D1=83=D0=B1?=
=?UTF-8?q?=D0=BB=D0=B8=D1=87=D0=BD=D0=B0=D1=8F=20=D0=BA=D0=BE=D0=BF=D0=B8?=
=?UTF-8?q?=D1=8F=20=E2=80=94=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=20?=
=?UTF-8?q?=D0=BD=D0=B5=20=D0=BD=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82?=
=?UTF-8?q?=20=D1=81=D0=B5=D0=B1=D1=8F=20=D0=BE=D1=86=D0=B5=D0=BD=D1=89?=
=?UTF-8?q?=D0=B8=D0=BA=D0=BE=D0=BC,=20=D0=BF=D0=BB=D0=BE=D1=89=D0=B0?=
=?UTF-8?q?=D0=B4=D0=BA=D0=B8-=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8?=
=?UTF-8?q?=D0=BA=D0=B8=20=D0=BD=D0=B5=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0?=
=?UTF-8?q?=D0=BD=D1=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Три юр-правки по документу владельца продукта «Сайт_МЕРА_v2» (31.08.2026), сделаны
локально 31.08, но не были закоммичены — на meraocenka.ru всё оставалось по-старому.
1. «Оценщик» про собственный алгоритм. Витрина сделок (`landing_showcase_deals.py`,
REJECTION_RULE/NOTE) и сноска статьи «Как оценить квартиру» теперь говорят
«расчёт МЕРЫ»: самоназвание обесценивало дисклеймер «не официальный отчёт оценщика».
Комментарии и докстринги не тронуты — посетитель их не видит.
2. «Путь 2»: «стоимость услуг фиксированная и известна заранее» — читалось как
фиксированная цена квартиры.
3. Названия площадок убраны из видимой копии лендинга и веб-отчёта. Канон —
`publicLabel` + `sourcePublicLabel()` в `source-registry.ts`: одна площадка = один
номер «Источник N» и один цвет точки (цвет остаётся опознавателем между блоками),
Росреестр под своим именем, неизвестный id → «Другой источник» (раньше fallback
отдавал сырой id). Три параллельные реализации `sourceLabel` сведены к одной;
`sourceLabel()` с реальными именами живёт для админки. Backend: якорь
confidence_explanation «по оценке Avito IMV» → «по оценочной модели площадки».
Подсказки геокодера «Yandex / Nominatim» и «по Яндексу» сняты из клиентских форм.
Гейт `public-copy-no-platform-names.test.ts` сканирует mera-public/** и
components/trade-in/** без комментариев, по Unicode-границе слова; список запретов
регистрозависимый намеренно (строчные id `"avito"` законны), поэтому капс-варианты
и словоформы перечислены явно — «6202 ОБЪЯВЛЕНИЯ ДОМКЛИК» в статье именно так
проходил первую версию гейта.
Не закрыто здесь: клиентский PDF (#3341) и ссылки на объявления на доменах площадок.
Текст витрины хранится в БД (`landing_showcase_runs.rejection_rule`,
`landing_showcase_deals.note`), планировщика у пересчёта нет — после деплоя нужен
ручной пересчёт или UPDATE трёх подстрок на проде.
---
tradein-mvp/backend/app/services/estimator.py | 10 +-
.../app/tasks/landing_showcase_deals.py | 6 +-
.../test_2677_house_anchor_repair_basis.py | 2 +-
.../public-copy-no-platform-names.test.ts | 173 ++++++++++++++++++
.../_components/v3/CostOfErrorV3.tsx | 2 +-
.../mera-public/_components/v3/SourcesV3.tsx | 9 +-
.../mera-public/_components/v3/TwoPathsV3.tsx | 2 +-
.../mera-public/articles/articles-content.ts | 4 +-
.../frontend/src/app/mera-public/content.ts | 26 ++-
.../src/app/mera-public/landing-v3.module.css | 30 +++
tradein-mvp/frontend/src/app/page.tsx | 5 +-
.../components/trade-in/CianValuationCard.tsx | 7 +-
.../src/components/trade-in/DealsCard.tsx | 6 +-
.../src/components/trade-in/EstimateForm.tsx | 2 +-
.../src/components/trade-in/HeroSummary.tsx | 18 +-
.../src/components/trade-in/IMVBenchmark.tsx | 25 +--
.../src/components/trade-in/ListingsCard.tsx | 6 +-
.../src/components/trade-in/MapPicker.tsx | 4 +-
.../components/trade-in/PriceHistoryChart.tsx | 25 ++-
.../components/trade-in/SourcesProgress.tsx | 2 +-
.../components/trade-in/StreetDealsCard.tsx | 5 +-
.../src/components/trade-in/offer-rates.ts | 4 +-
.../src/components/trade-in/saleShareUtils.ts | 21 +--
.../src/components/trade-in/trade-in.css | 27 ++-
.../components/trade-in/v2/AnalyticsView.tsx | 26 +--
.../components/trade-in/v2/LocationDrawer.tsx | 7 +-
.../components/trade-in/v2/ParamsPanel.tsx | 2 +-
.../src/components/trade-in/v2/mappers.ts | 33 ++--
.../frontend/src/lib/source-registry.ts | 67 +++++--
29 files changed, 430 insertions(+), 126 deletions(-)
create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts
diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py
index 06763e6f..a3915707 100644
--- a/tradein-mvp/backend/app/services/estimator.py
+++ b/tradein-mvp/backend/app/services/estimator.py
@@ -3347,7 +3347,10 @@ def _price_from_inputs(
anchor_higher = (
int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None
)
- anchor_label = "оценке Avito IMV"
+ # Публичная копия: клиент видит эту строку в confidence_explanation
+ # (HeroSummary), название площадки туда не должно утекать —
+ # та же норма, что publicLabel в source-registry.ts (решение 31.08.2026).
+ anchor_label = "оценочной модели площадки"
_imv_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None
avito_imv_summary = AvitoImvSummary(
recommended_price=anchor_total,
@@ -3379,7 +3382,10 @@ def _price_from_inputs(
elif imv_eval is not None and imv_eval.recommended_price:
anchor_total = int(imv_eval.recommended_price)
anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None
- anchor_label = "оценке Avito IMV"
+ # Публичная копия: клиент видит эту строку в confidence_explanation
+ # (HeroSummary), название площадки туда не должно утекать —
+ # та же норма, что publicLabel в source-registry.ts (решение 31.08.2026).
+ anchor_label = "оценочной модели площадки"
avito_imv_summary = AvitoImvSummary(
recommended_price=anchor_total,
lower_price=(int(imv_eval.lower_price) if imv_eval.lower_price else None),
diff --git a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py
index 881f3728..d36fb741 100644
--- a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py
+++ b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py
@@ -110,10 +110,10 @@ logger = logging.getLogger(__name__)
# диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут
# значило бы завести проверку, которая в проде не срабатывает никогда.
REJECTION_RULE = (
- "Строка не попадает на витрину, только если данных нет: оценщик не дал "
+ "Строка не попадает на витрину, только если данных нет: расчёт МЕРЫ не дал "
"ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или "
"площадь. Величина отклонения на отбор и отбраковку не влияет — иначе "
- "витрина показывала бы лучший хвост, а не работу оценщика. Санитарный "
+ "витрина показывала бы лучший хвост, а не работу расчёта. Санитарный "
"диапазон цены сделки (30 000–600 000 ₽/м² для Екатеринбурга) применён "
"к выборке до расчёта, по цене самой сделки."
)
@@ -123,7 +123,7 @@ NOTE = (
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
"Факт — цена ДКП из договора (поле price_rub Росреестра, не пересчёт из ₽/м²): "
"она бывает занижена сторонами, и тогда "
- "строка выглядит как промах оценщика, хотя врёт документ. "
+ "строка выглядит как промах расчёта, хотя врёт документ. "
"Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома "
"есть у 2.7% строк, поэтому дом не показан и показан быть не может."
"Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная "
diff --git a/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py b/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py
index 6a4ce8be..65c56b3c 100644
--- a/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py
+++ b/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py
@@ -194,7 +194,7 @@ def test_needs_repair_client_not_lifted_by_cosmetic_anchor() -> None:
"""
est = _run_estimate(_anchor(6_700_000, "cosmetic"), "needs_repair")
assert est.median_price_rub == int(_BASE_MEDIAN * 0.94)
- assert "скорректирована по оценке Avito IMV" not in (est.confidence_explanation or "")
+ assert "скорректирована по оценочной модели площадки" not in (est.confidence_explanation or "")
def test_euro_anchor_not_applied_raw_to_unknown_repair_client() -> None:
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts
new file mode 100644
index 00000000..60c12c89
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts
@@ -0,0 +1,173 @@
+/**
+ * Реальные названия площадок-источников не должны попадать в видимый
+ * клиентский UI (лендинг «Мера» + компоненты отчёта trade-in).
+ *
+ * Канон: `src/lib/source-registry.ts` — `publicLabel`/`sourcePublicLabel()`.
+ * Одна площадка = один и тот же нейтральный номер («Источник N»), Росреестр —
+ * исключение (не листинговая площадка, юр-риска в названии нет). Причина —
+ * тот же юр-риск, что закрывал #2211/#3264-scope: конкретное название
+ * площадки в клиентском отчёте — коммерческая информация, которую посетитель
+ * видеть не должен.
+ *
+ * ОТЛИЧИЕ от соседнего `public-copy-no-infra.test.ts`: тот гейт специально
+ * сканирует ВЕСЬ текст файла, включая комментарии («на боевой базе» в
+ * комментарии так же вредно, как в строке — комментарий может стать
+ * заготовкой будущей копии). Здесь наоборот: название площадки в комментарии
+ * ЗАКОННО и нужно разработчику — оно объясняет происхождение данных
+ * (`// avito_imv — реальная Avito IMV-оценка`), а `sourceLabel`/`SOURCES`
+ * в `source-registry.ts` обязаны хранить реальные лейблы для админки. Поэтому
+ * перед проверкой из текста вырезаются `//`- и `/* *\/`-комментарии, и гейт
+ * смотрит только на то, что реально дойдёт до посетителя в виде строки.
+ *
+ * Ограничение вырезки комментариев: `//` не режется, если ему предшествует
+ * `:` (т.е. это `https://`/`http://` внутри строки, а не начало комментария).
+ * Полноценный JS/TS-парсер тут избыточен — в скоупе сканирования нет `//`
+ * иначе как в URL или в начале комментария (проверено вручную).
+ *
+ * Второе отличие от инфра-гейта: тот matches подстрокой (`includes`) —
+ * инфраструктурные имена никогда не встречаются внутри TS-идентификаторов.
+ * Названия площадок встречаются: тип `CianValuationSummary`, хук
+ * `useEstimateCianPriceChanges`, переменная `cianId` — это ЗАКОННЫЙ код
+ * (правило задачи: имена переменных/типов/хуков не трогаем, меняется только
+ * то, что видит человек). Поэтому здесь матчинг по границе слова
+ * (Unicode-aware `\p{L}`/`\p{N}`, не ASCII `\b`, иначе он вообще не сработает
+ * на кириллице), а не голый `includes`.
+ */
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+const MERA_PUBLIC_ROOT = join(__dirname, "..");
+const TRADE_IN_COMPONENTS_ROOT = join(__dirname, "../../../components/trade-in");
+
+/**
+ * Реальные названия площадок. Список ЯВНЫЙ (не эвристика по паттерну) —
+ * см. докстринг соседнего инфра-гейта про тот же выбор: эвристика ловила бы
+ * законные вещи (напр. домены listing-ссылок avito.ru/cian.ru, которые
+ * остаются в href по правилу «URL объявлений не трогаем»).
+ *
+ * Матчинг НАМЕРЕННО регистрозависимый: строчные `"avito"` / `"cian"` /
+ * `"yandex"` — это id источников (`keys: ["avito", "avito_imv"]`,
+ * `sourcePublicLabel("cian")`), и они законны. Поэтому капс-варианты
+ * (плитки, сноски статей — «6202 ОБЪЯВЛЕНИЯ ДОМКЛИК» жил в статье и
+ * проходил гейт) и словоформы («Домклике», «Яндексу») перечислены явно.
+ * Яндекс/Yandex здесь и как площадка (Я.Недвижимость), и как геокодер
+ * («Yandex / Nominatim» в подсказке адреса): посетителю не нужен ни тот,
+ * ни другой.
+ */
+const BANNED = [
+ "Avito",
+ "AVITO",
+ "Авито",
+ "АВИТО",
+ "Циан",
+ "ЦИАН",
+ "Циана",
+ "Циане",
+ "Cian",
+ "CIAN",
+ "Я.Недвижимость",
+ "Я.НЕДВИЖИМОСТЬ",
+ "ДомКлик",
+ "Домклик",
+ "ДОМКЛИК",
+ "Домклике",
+ "Домклика",
+ "Domclick",
+ "DOMCLICK",
+ "Яндекс",
+ "ЯНДЕКС",
+ "Яндексу",
+ "Яндекса",
+ "Яндекс.Недвижимость",
+ "Yandex",
+ "YANDEX",
+ "Этажи",
+ "ЭТАЖИ",
+ "Avito Оценка",
+ "Я.Оценка",
+ "Циан Оценка",
+];
+
+/**
+ * Файлы копии в скоупе: лендинг «Мера» + компоненты отчёта trade-in.
+ * `src/app/scrapers/**` и `src/components/scrapers/**` (admin-only, отдельные
+ * top-level каталоги) вне обоих корней сканирования — их сюда не заносит.
+ */
+function copySources(dir: string, acc: string[] = []): string[] {
+ for (const name of readdirSync(dir)) {
+ if (name === "__tests__" || name === "node_modules") continue;
+ const full = join(dir, name);
+ if (statSync(full).isDirectory()) {
+ copySources(full, acc);
+ } else if (name.endsWith(".ts") || name.endsWith(".tsx")) {
+ acc.push(full);
+ }
+ }
+ return acc;
+}
+
+/** Вырезает `/* *\/`- и `//`-комментарии; `//` внутри `https://` не режет. */
+function stripComments(text: string): string {
+ // Newlines внутри блочного комментария сохраняем (заменяем на "\n" по числу
+ // строк), иначе после вырезки съедутся номера строк в отчёте о находке.
+ const noBlock = text.replace(/\/\*[\s\S]*?\*\//g, (m) =>
+ "\n".repeat((m.match(/\n/g) ?? []).length),
+ );
+ return noBlock
+ .split("\n")
+ .map((line) => {
+ // Ищем `//`, которому НЕ предшествует `:` (т.е. не часть `http(s)://`).
+ const idx = line.search(/(? {
+ const files = [
+ ...copySources(MERA_PUBLIC_ROOT),
+ ...copySources(TRADE_IN_COMPONENTS_ROOT),
+ ];
+
+ it("файлы копии вообще найдены — иначе гейт зелёный по построению", () => {
+ expect(files.length).toBeGreaterThan(20);
+ expect(files.some((f) => f.endsWith("HeroSummary.tsx"))).toBe(true);
+ expect(files.some((f) => f.endsWith("IMVBenchmark.tsx"))).toBe(true);
+ });
+
+ it.each(BANNED)("нигде в видимой копии не встречается «%s»", (needle) => {
+ const hits: string[] = [];
+ for (const file of files) {
+ const raw = readFileSync(file, "utf8");
+ const text = stripComments(raw);
+ text.split("\n").forEach((line, i) => {
+ if (hasWholeWord(line, needle)) {
+ hits.push(`${file}:${i + 1}`);
+ }
+ });
+ }
+ expect(
+ hits,
+ `«${needle}» — реальное название площадки в видимой клиентской копии. ` +
+ `Используй sourcePublicLabel() из src/lib/source-registry.ts вместо ` +
+ `хардкода: посетитель отчёта не должен видеть, какие площадки реально ` +
+ `участвовали в оценке (юр-риск, #3264-scope). В комментариях это имя ` +
+ `законно — гейт сюда не смотрит.`,
+ ).toEqual([]);
+ });
+});
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx
index 1d700573..69132cb7 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx
@@ -66,7 +66,7 @@ export function CostOfErrorV3({ stats }: { stats: LandingStats }) {
tiles.push({
key: "share",
value: share.text,
- title: "столько объявлений на Домклике снижали цену",
+ title: "столько объявлений на одной из площадок снижали цену",
text: [share.note, share.sample].filter(Boolean).join(" · "),
});
}
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/SourcesV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/SourcesV3.tsx
index ea0a9a75..2aebb587 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/SourcesV3.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/SourcesV3.tsx
@@ -51,9 +51,12 @@ export function SourcesV3() {
{group.title}
{group.items.map((item) => (
-
-
- {item}
+
+
+ {item.label}
))}
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx
index 2642138b..ae38d5b1 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx
@@ -63,7 +63,7 @@ export function TwoPathsV3() {
Отчёта достаточно, чтобы справиться самому. Но если нет времени или
не хочется вести показы и торг — передайте продажу нашим
специалистам: объявление, фото, показы, переговоры, сделка. Работаем
- по цене из вашего отчёта, стоимость фиксированная и известна
+ по цене из вашего отчёта, стоимость услуг фиксированная и известна
заранее. Ни к чему не обязывает — просто узнайте условия.