223 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2220df8741 |
fix(mera): версия согласия ПДн отстала от новой редакции политики + смоук ждал не тот код
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m3s
ДВА ПОСЛЕДСТВИЯ #3436, обнаруженные на прогоне против прода. 1. ДЕПЛОЙ МЕРЫ БЫЛ ЗАБЛОКИРОВАН. В #3436 политика конфиденциальности получила раздел про cookie, то есть новую редакцию, и `PRIVACY_APPROVAL` во фронте стал «№ 2 от 10 сентября 2026 г.». Бэкендовая `_CONSENT_POLICY_VERSION` осталась на «2026-08-13», а между ними стоит гейт `test_consent_text_frontend_sync.py` — он и упал. Job `test` в deploy-tradein.yml падает → `deploy` пропускается по своему `needs.test.result != 'failure'` → прод остался на старом образе фронта, при том что Caddy обновился отдельным пайплайном. Внешне это выглядело как «задеплоилось наполовину»: UTM на редиректе со слэшем починился, а noindex и robots.txt — нет. Гейт сработал ровно как задуман: версия согласия обязана указывать на ту редакцию документа, которую человек реально видел, иначе снимок согласия в trade_in_leads.consent_policy_version подписан не тем документом. Правим версию, а не тест. Согласия, собранные до 10.09, остаются с "2026-08-13" — в этом и смысл хранить версию per-row. 2. СМОУК ЖДАЛ 404 ТАМ, ГДЕ ПРОД ОТВЕЧАЕТ 401. Проверка «карта сайта МЕРЫ не просачивается через B2B-домен» ожидала 404 от allowlist'а site-блока, но корень gendsgn.ru закрыт пилотным basic_auth, и гейт отвечает 401 РАНЬШЕ, чем запрос доходит до allowlist'а. Проверка была написана без прогона против прода — это честно отмечено в её же комментарии — и упала на первом же запуске. Заведён `check_any`: PASS на любом из перечисленных кодов. Здесь допустимы 401 и 404 — оба означают проверяемое («наружу этого адреса нет»), а какой рубеж ответил первым, к предмету проверки отношения не имеет. Жёсткое ожидание к тому же сломалось бы при снятии пилотного гейта. Красная строка осталась там, где ей место: 200 означал бы реальную течь. Проверено: `pytest tests/test_consent_text_frontend_sync.py` — 6 passed; полный сьют бэкенда МЕРЫ локально 5737 passed; `bash -n` на смоуке чист; `check_any` прогнан против живого gendsgn.ru — PASS на фактическом 401. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CiUFZ3rmTNpp3DRajUo8KQ |
||
|
|
9898b6bc02 |
feat(tradein/geocoder): регион-параметризация геокодера — region_code в geocode()/known_city_hint, --region-code у скрипта сделок, region_code у admin geocode-missing (#3051)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m6s
Геокодер был жёстко привязан к Свердловской области: viewbox 66 +
bounded=1, accept только при state ~ 'свердловск' и точке в bbox 66,
known_city_hint знал лишь города области → для 212 937 московских сделок
(address 'Москва, <улица>') геокод давал None либо ложный хит по
одноимённой улице области, а cache-ключ без города смешивал регионы.
Теперь регион приходит от вызывающего (deals.region_code): viewbox и
bbox из REGIONS[code], state-маркер per region ('свердловск'/'москва'),
ЕКБ-тиры (geoportal/cadastral/local houses) только при 66, city-хинт
через словарь региона → cache-ключ '|city=москва'. Дефолт 66 везде —
для существующих вызовов поведение байт-идентично (ревью двумя
линзами). Побочно: geocode-missing по умолчанию больше не берёт
listings с region_code NULL (16 930 неактивных чужих городов, которые
и раньше геокодились впустую).
|
||
|
|
fbe85fcc75 |
feat(tradein/estimator): регион-скоуп ДКП-коридора — фильтр по d.region_code (#3051 PR-A)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m9s
target_city резолвится ТОЛЬКО для городов Свердловской области (_resolve_target_city матчит SVERDLOVSK_OBLAST_CITIES) — для Москвы/любого нового региона city=None, и street ILIKE оставался единственным скоупом сделки: одноимённая улица чужого региона утекала в коридор. Добавлен d.region_code = CAST(:region_code AS int) в оба ДКП-запроса (_fetch_dkp_corridor) + region_code передаётся из обоих вызывающих (POST /estimate через geo.lat/lon, GET-rehydrate через row.lat/lon) с гарантией «не резолвится → DEFAULT_REGION_CODE (66)», не NULL (NULL в SQL-параметре обнулил бы фильтр целиком). Дефолт региона и джойн deal_city_price_bands не трогаются — следующие PR (B, F). Regression: 5622 passed, 37 skipped (полный прогон tests/). |
||
| f4d174283a |
chore(#3197): cian-login — без холостой аренды и ложного отказа (/login сайдкара override не берёт); честные докстринги
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m7s
Code-review хвоста #3197: на `cian-login` аренда из пула не доходила до сайдкара. `BrowserFetcher._post_login` не кладёт `payload["proxy"]` (это делают только `fetch`/`fetch_json`), а на приёме `login_handler` (browser/server.py:2814-2817) зовёт `_no_live_proxy(provider, None)` и `_ensure_browser(provider)` без override — `/login` proxy-override не принимает вовсе. Lease брался в `__aenter__` и освобождался в `__aexit__` без пользы и без health-вердикта по узлу. Хуже холостого хода: при пустом пуле в production `_acquire_lease` поднимает `NoProxyAvailableError` ДО POST, `cian_auto_login` ловит любое `Exception` → `502 Browser login failed`. То есть единственная ручка ВОССТАНОВЛЕНИЯ cian-сессии отказывала ровно во время инцидента с пулом. Комментарий в admin.py при этом утверждал, что фикс закрывает InvalidIP на логине — неправда. Убран `proxy_provider=` (фабрика остаётся ради endpoint/environment из одного места). `use_pool` без провайдера фетчер игнорирует сам — `_acquire_lease`: `use_pool AND provider is not None` — поэтому ни аренды, ни прод-отказа. `domclick-detail-debug` не тронут: он ходит через `fetch`, где override реально кладётся в тело и читается сайдкаром — там #3197 остаётся настоящим фиксом. Тесты для cian обратные по значению и падают на HEAD ветки: `test_cian_auto_login_does_not_lease_from_pool` (провайдер не передан, `acquire` не вызван) и `test_cian_auto_login_survives_empty_pool_in_production` (пустой пул на проде не отдаёт 502). Стаб cian — подкласс настоящего `BrowserFetcher`, чтобы второе утверждение шло через реальный `_acquire_lease`, а не через заглушку. Follow-up (отдельной задачей): сайдкар `/login` не принимает proxy override → логин cian всегда с env-узла (сейчас выключенный узел 9). |
|||
| 8761602e9b |
chore(tradein/proxy): последние две ручки admin.py — через фабрику фетчера; снят форс pool-режима у cian-history (#3197, #3386)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m5s
Два хвоста одной темы — проводка пула прокси в контейнере backend. #3197: `cian-login` и `domclick-detail-debug` были последними прямыми конструкциями `BrowserFetcher(source=, endpoint=)` мимо `build_browser_fetcher`. Без `proxy_provider`/`use_pool`/`environment` сайдкар брал свой env-узел `SCRAPER_PROXY_URL` (на проде выключенный узел 9: 407 → camoufox `InvalidIP`), а прод-отказ «пул пуст» (#2616) на этих путях был мёртв — он смотрит на `environment`, который до конструктора не доезжал. Соседи по эпику уже переведены (#3382 cian, #3389 yandex). Прямых конструкций без провайдера вне тестов больше не осталось: остальные (backfill-задачи, pipeline) пул получают своими kwargs, а `endpoint=None`-ветки providers — это документированный `config=None` для офлайн-тестов. #3386: `_PoolCurlConfig` в `cian_price_history` форсил `use_proxy_pool_curl=True`, потому что у контейнера `backend` не было переменной. #3387 задал `USE_PROXY_POOL_CURL: "true"` сервису `backend` в compose — зашитая константа стала лишней и делала рубильник неотключаемым ровно на этом пути (докстринг при этом описывал уже неверную причину). Теперь `RealScraperConfig()` напрямую. Тесты меряют значения, а не наличие kwarg'а: на откате исходников красные 4 параметризации нового `test_3197_admin_debug_browser_pool_wiring` (`assert None is not None` — провайдер не передан) и `test_price_history_honours_flag_off` (`assert ['cian'] == []` — пул дёргался при выключенном флаге). |
|||
| 33cad4f82e |
fix(#3386): curl_proxy_url — health=False и на отмене (BaseException); admin IMV через пул; тест без create=True
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m57s
|
|||
| cb5714fff0 |
fix(#3197): yandex newbuilding — обе точки в пул прокси + стоп на пустом пуле
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m56s
`YandexNewbuildingScraper.fetch_jk` и `resolve_yandex_jk_slug` строили `BrowserFetcher(source="yandex", endpoint=...)` без proxy_provider/use_pool/ environment — сайдкар брал env-узел SCRAPER_PROXY_URL, на проде выключенный (407 → camoufox InvalidIP → /fetch 503, факт #3386), то есть путь шёл мимо пула целиком, а прод-отказ «пул пуст» (#2616) был мёртв: он смотрит на environment, который до конструктора не доезжал. Обе точки собраны через build_browser_fetcher(config, "yandex", proxy_provider=...), как yandex/serp.py. Второе: общий `except Exception` в обеих функциях глотал NoProxyAvailableError и возвращал None — прогон, не ходивший к площадке, перебирал все ЖК и уходил в 'done'. Теперь «пул пуст» пробрасывается наружу (caused_by_no_proxy), sweep обрывается на первом доме с no_proxy_stop, а прогон финализируется как failed (образец дефекта — #3382, cian/detail.py). |
|||
| ec8213c269 |
fix(payments): обрыв соединения и Init без PaymentURL перестают запирать покупателя
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m11s
Три дыры в одном замке (строка без payment_url невидима для _find_live_payment, но видима предикату UNIQUE 279 → ложный 409 на 30 минут): - tbank_client ловил пару (TimeoutException, NetworkError): RemoteProtocolError, ProxyError и UnsupportedProtocol летели наружу голым httpx-типом мимо `except TBankApiError` в checkout. Ловим родителя — httpx.TransportError. - Ветка «Success:true без PaymentURL» отвечала 502, не трогая статус, — здесь банк заказ ПРИНЯЛ. Тот же терминальный статус, error_code=no_payment_url; UPDATE вынесен в общий _mark_init_failed. - _FakeDb в тестах применял статус по наличию ключа в params, а не по тексту SQL: мутант без `SET status = :status` оставался зелёным. Гейт по SQL — мутант краснит все три теста про замок. Комментарий про «возможный холд» на ветке отказа Init поправлен: Init холд не создаёт, авторизация идёт с оплаты формы, а форму покупателю не выдавали. |
|||
| 5bd9c586b7 |
fix(payments): отказ банка на Init больше не запирает покупателя на 30 минут
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m52s
Строка после провального Init оставалась в NEW и без payment_url: для _find_live_payment (фильтр payment_url IS NOT NULL) её нет, а для предиката UNIQUE миграции 279 — есть. Следующий checkout ловил конфликт и получал 409 'retry shortly' до истечения _ABANDONED_AFTER_MINUTES — из-за сбоя банка, а не своего действия. Переводим такую строку в терминальный DEADLINE_EXPIRED (тот же, которым checkout уже помечает брошенные попытки) в том же UPDATE, что пишет error_code/error_message: статус вне предиката 279, пара (estimate_id, product_code) освобождается сразу. Новый статус в CHECK 233 не заводим — миграция ради ярлыка не нужна, причина и так в error_*. Refs #3323 |
|||
| b8084f6afe |
Merge pull request 'fix(tradein): секрет вебхука GlitchTip не попадает в access-log — скруббер query-секретов + приём из заголовка' (#3353) from fix/3154-glitchtip-secret-log-leak into main
Some checks are pending
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 24s
|
|||
| ed94a03f73 |
fix(tradein): не логировать секрет вебхука GlitchTip в access-log (#3154)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 14s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m9s
uvicorn печатает в access-log полный путь вместе с query, поэтому секрет вебхука (`?secret=`, он же TRADEIN_INTERNAL_AUTH_SECRET, второй рубеж rbac) уезжал в Loki открытым текстом. Скруббер #3115 в Alloy ловит только форму `user:pass@host` (DSN postgres_exporter) и такую строку не закрывает. Два слоя: - хендлер принимает секрет из заголовка X-GlitchTip-Secret; query-параметр остаётся fallback'ом, т.к. сам GlitchTip 6.1.6 (`send_webhook()`) заголовков не шлёт вовсе — убрать query можно только когда заголовок начнёт подставлять кто-то перед нами (Caddy header_up) или сменится отправитель; - app/core/log_scrub.py: logging-фильтр маскирует значения чувствительных query-параметров (secret/token/api_key/…) на uvicorn.access и на обработчиках корневого логгера — секрета нет уже в `docker logs`. Сравнение секрета и было constant-time (`secrets.compare_digest`). |
|||
| aaf8119408 |
Merge remote-tracking branch 'origin/main' into HEAD
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m1s
# Conflicts: # tradein-mvp/backend/app/services/estimator.py |
|||
| 4f4345e27c |
fix(tradein): завершить thin-market гейт IMV — Guard-1b и GET-путь (#3323)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m23s
MEDIUM-1: imv_anchor_present ставился по anchor_total МИМО гейта — на тонком рынке якорь отброшен, а Guard-1b (#764) продолжал глушить квартальную поправку «потому что якорь есть»: headline не получал ни одной поправки, отброшенный якорь двигал деньги вычитанием. Теперь present = not thin_market. MEDIUM-2: trade_in.py (GET ?id= — расшаренная ссылка/PDF) — третья точка сборки карточки: market_count=0 читался как «неизвестно», thin_market не передавался вовсе → одна оценка показывала thin_market=True в POST и False при переоткрытии. |
|||
| 4feb61c006 |
fix(tradein): резолвить роль из реестра, roles.yaml — только fallback (#3316)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 5m27s
Роль жила в двух местах сразу: люди заводятся в БД (`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 роль.
|
|||
|
|
d708f15019 |
fix(tradein/support): один повтор терял каждое одиннадцатое сообщение в поддержку
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m50s
Замер прода 01.09.2026 из контейнера бота: канал до api.telegram.org рвётся всплесками, доля отказов на попытку 15-38% (пять проб: 3/8, 15/40, 5/20, 3/20, 1/25), в логе long-polling'а 353 ConnectTimeout за сутки. Транспорт ни при чём — httpx и сырой сокет отваливаются одинаково (25% против 35% в чередующемся замере), и прокси не помогает, а мешает: через SCRAPER_PROXY_URL 0 из 20. Ручка веб-поддержки ходила с max_retries=1, то есть двумя попытками. При 30% отказов на попытку до пользователя доходило ~9% отказов — каждое одиннадцатое сообщение возвращало 502 «сервис недоступен». Два других числа из того же замера задают конструкцию. Успешный запрос отвечает за 0.13с (максимум из 25 проб — 0.18с), а неудачный НИКОГДА не отваливается быстро: все отказы упираются в таймаут целиком (10.02с при timeout=10.0). Значит десятисекундный таймаут не покупал ничего, кроме цены за неудачу, — снижен до 5с, это ~28-кратный запас к измеренному максимуму. И экспоненциальная пауза 2→4→8с здесь бессмысленна: отказ — неустановленное соединение, а не троттлинг, пережидать нечего; она лишь добавляла 14с к ожиданию. Правка: бюджет ручки — 3 повтора, таймаут 5с, потолок паузы 1с. Худший случай 4 попытки × 5с + 3 паузы × 1с = 23с и требует четырёх отказов подряд; типичный случай не меняется (0.13с). Расчётная потеря падает с ~9% до ~0.8%. В TelegramClient добавлен необязательный max_backoff. Воркерная политика НЕ меняется: без явного потолка откат прежний экспоненциальный до 30с, а retry_after из 429 уважается целиком — эту границу держит отдельный тест, потому что первая версия правки её сломала (капала 60с до 30с и для воркера тоже). Потолок на retry_after применяется только когда его передали явно: интерактивному пути нельзя ждать Telegram-овские 30-60с, за ним стоит открытый запрос от браузера. Тесты: 8 новых (потолок на network/429/5xx, неизменность воркерного пути, арифметика «max_retries=N → N+1 попыток», границы бюджета ручки). |
||
| 1d45ac0747 |
feat(mera/estimate): ручка фактов дома для предзаполнения формы + гейт «этаж не выше дома» (#3257)
Some checks failed
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m2s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Has been cancelled
|
|||
| df9dd52996 |
fix(mera/public): Infinity/NaN во входе — 422, и бюджет считает такие запросы
Аудит живого сайта 30.08.2026: POST /api/public/mera/coverage с
{"lat":56.8,"lon":1e400,...} отвечал 500, и двенадцать таких запросов подряд
дали двенадцать пятисоток и ни одного 429. Две независимые поломки в одном
месте, обе воспроизведены локально до правки.
1. 500 вместо 422. json.loads принимает Infinity/-Infinity/NaN, а 1e400 даёт
inf переполнением. Pydantic отбивает такое поле по границам и кладёт
значение в input ошибки, а ответ об ошибке сериализуется
json.dumps(allow_nan=False) и падает уже после входа в ответ. Ломается не
поле, а сборка ответа об ошибке — одна на всё приложение, поэтому и
обработчик один (app/core/http_errors.py), а не валидатор на lon.
2. Лимитер мимо. _enforce стоял первой строкой тела хендлера, а FastAPI
валидирует тело позже зависимостей, но раньше тела — до проверки просто не
доходило. Та же поправка места, что уже сделана сегодня у
_require_public_estimate_enabled: перенос в dependencies. Сделано для всех
ручек файла, не только coverage. У /estimate и /estimate/read флаг остаётся
первой зависимостью — 429 на выключенной ручке подтверждал бы её
существование.
Тесты двусторонние: снятие обработчика роняет 4 проверки 422, возврат лимитера
в тело роняет проверку бюджета (проверено).
|
|||
| 2390eec740 |
fix(mera/b2c): вернуть координаты сделки, снесённые моим же коммитом про студию
Коммит |
|||
| ca3f073b0e |
feat(mera/b2c): улица сделки доезжает до витрины готовой схемой, а не геометрией
Карта в карточке игры показывала полигон района — единственную геометрию, до которой у tradein был доступ. Улицы живут в базе gendesign, foreign table и гранта на них не было. Мост по образцу соседей (v_tradein_cad_buildings / v_tradein_osm_poi_ekb): gendesign 195 — вьюха v_tradein_osm_roads_ekb (highway + water) с GRANT В ТОМ ЖЕ ФАЙЛЕ (после #3227 грант отдельной миграцией теряется при пересоздании); tradein 281 — foreign table gendesign_osm_roads_ekb плюс колонки street_name / street_scheme в landing_showcase_deals. Пересчёт витрины кладёт в строку УЖЕ СПРОЕЦИРОВАННЫЕ SVG-пути окна 840x840 м вокруг центра улицы. Проекция — та же равнопромежуточная с cos(широты), что в export_ekb_districts_svg.py; второй в проекте нет. Замер 2026-08-29: GeoJSON того же окна 7-8 КБ на строку, схема — 2.8-2.9 КБ. ЧТО ДАННЫЕ ВЫДЕРЖИВАЮТ, И НИ СЛОВОМ БОЛЬШЕ · Это УЛИЦА, а не дом: deals.address уровня улицы, номер дома у 2.7% сделок. В схеме намеренно НЕТ координат окна и констант проекции — точку дома по ней нельзя поставить даже случайно. Это замок, а не забывчивость. · Зданий нет: cad_buildings — 18 307 контуров на город, в плотном центре 51 здание на радиус 450 м, где их в разы больше. Нарисованная застройка заявляла бы полноту, которой в данных нет. · Улицы — фильтрованная выгрузка «источников шума»: именованные покрыты хорошо, дворовые и служебные проезды отсутствуют. · Улица сматчилась у 550 названий из 654 — 31 410 сделок из 34 021 (92.3%). Остальным street_scheme = NULL, и это штатно: фронт показывает район, который для этого и оставлен. · Перекрёсток («Челюскинцев/Шейнкмана») берём первой улицей: таких адресов три на 34 021 сделку, и обе улицы одинаково верны на уровне улицы. · Наличие схемы НА ОТБОР СТРОК НЕ ВЛИЯЕТ — то же правило, что запрещает отбор по величине ошибки: иначе витрина показывала бы не работу оценщика, а те 92% адресов, что удобно легли на OSM. Тесты (каждый сломан вручную и покраснел): нормализация на реальных адресах включая ё/е и «8 Марта»; недоступная вьюха и упавший запрос дают None, а не исключение; схема не раздувается — потолок в байтах на реальной плотности плюс прямая проверка округления до 0.1. |
|||
| 4e3279d448 |
fix(mera/b2c): студия подписывалась как «0-к» на витрине сделок
Увидел на скриншоте карточки игры: «0-к, 25,9 м²». Замер на проде — 3 строки витрины из 20 имеют rooms = 0, то есть каждая шестая карточка так и выглядит. «0-к» читается как ошибка выгрузки, а не как тип квартиры. Студией её называет и наш собственный бэктест (per_rooms.label в scripts/backtest_estimator.py), и рынок. Тест двусторонний и фальсифицирован: возврат «0-к» для rooms=0 красит его по значению, обратная правка — снова зелено. |
|||
| f3401badd0 |
feat(mera/b2c): координата сделки доезжает до витрины — с записанной границей честности
Карта лэндинга не может показать точку, пока её нет в витрине: район, комнаты, площадь и квартал в `landing_showcase_deals` есть, координаты не было. Что сделано: миграция 280 добавляет lat/lon (double precision, NULLABLE), задача пересчёта переносит их из `deals`, ручка /showcase отдаёт их как Optional[float]. ГРАНИЦА ЧЕСТНОСТИ, записанная в трёх местах (COMMENT колонок, `note` каждой строки, докстринг задачи), а не только в голове автора: это ЦЕНТРОИД УЛИЦЫ, а не дом. Замер на проде 2026-08-29 по той самой выборке, из которой набирается витрина (deals, city='Екатеринбург', deal_date >= '2025-01-01'): 34 021 сделка, 34 017 с координатой, но РАЗЛИЧНЫХ точек всего 991 — ≈34 сделки в одной точке, при 2.7% известных номеров дома. Точка верна на масштабе района и улицы и неверна на масштабе дома; `note` едет на фронт вместе с числами, поэтому следующий, кто возьмётся зумить карту, об этом споткнётся. NULLABLE и без отбраковки: строка без координаты остаётся на витрине с lat=lon=None. Выбрасывать сделку за отсутствие точки — отбор по признаку, не связанному с качеством оценки, то есть та же порча витрины, которую здесь уже чинили (порог по величине ошибки). Тесты двусторонние, каждый проверен фальсификацией — краснеет по ЗНАЧЕНИЮ: * перестановка lat/lon в build_row → 60.6055 == 56.8386 * `if lat is None: return None` → None is not None * перестановка lat/lon в ручке → 60.6055 == 56.8386 * фильтр строк без координат в ручке → len([]) == 1 Перестановку широты и долготы не ловит ни схема, ни тип (обе float), поэтому в фикстурах намеренно непохожие величины: 56.8386 против 60.6055. Фронтенд не тронут — его делает следующий шаг. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 6ab13649f6 |
fix(mera/b2c): выключенная ручка расчёта подтверждала своё существование и печатала схему
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m10s
Докстринг обещал: «Отвечаем 404, а не 403: выключенная ручка не должна
подтверждать, что она существует». Замер на проде 29.08.2026 (флаг выключен):
POST /api/public/mera/estimate {} → 422 + address/area_m2/rooms/consent
POST /api/public/mera/estimate {валидное} → 404
POST /api/public/mera/nosuchthing → 401 (rbac)
422 отличается и от 404, и от 401 — то есть подтверждает, что ручка есть, и
заодно выдаёт её схему.
Причина не в логике гейта, а в его МЕСТЕ: проверка стояла первой строкой тела
хендлера, а FastAPI валидирует тело раньше, чем доходит до кода. Гейт перенесён
в dependencies=[Depends(...)] обеих ручек — зависимости решаются до разбора тела,
и выключенная ручка неотличима от отсутствующей при любом входе.
Тест двусторонний и фальсифицирован: возврат вызова в тело красит три теста
(включая уже существовавший про 404), обратная правка — снова зелено.
|
|||
| a48070dd89 |
fix(tradein): миграция платежей 277 → 279 (столкновение номеров) + lock_timeout
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m54s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
Два параллельных агента взяли ОДИН номер: 277_landing_showcase_runs.sql в ветке витрины и 277_payments_live_checkout_uidx.sql здесь. Обе ветки по отдельности зелёные, но на main второй файл встал бы конфликтом — ровно ловушка из шапки tests/test_migration_numbering.py: номер сверяется с origin/main, а не с чужими открытыми ветками. Заняты сейчас: 275 (метрики), 276+277 (витрина), 278 (публичный токен) → этот 279. Ссылки на номер обновлены в payments.py и test_payments_router.py, включая путь, по которому тест читает предикат частичного UNIQUE. Плюс CREATE UNIQUE INDEX на существующей таблице payments обёрнут в SET LOCAL lock_timeout = '5s' — гейт #2752. |
|||
| c5315539fa |
fix(payments): один живой платёж на оценку — гарантия БД, а не порядка выполнения
Идемпотентность checkout держалась на «SELECT, потом INSERT» — ровно на том, что шапка модуля называет дефектом. Двойной клик по кнопке оплаты давал два параллельных запроса, два INSERT, два Init и два холда на карте покупателя. - миграция 277: частичный UNIQUE (estimate_id, product_code) по живым статусам + ON CONFLICT DO NOTHING в INSERT. Проигравший гонку не идёт в банк: отдаёт ссылку соперника, если та уже готова, иначе 409; - граница по времени для брошенных попыток: NEW/FORM_SHOWED старше 30 минут переводятся в DEADLINE_EXPIRED. Без неё зависший платёж (нотификации по нему может не прийти вовсе) навсегда отдавал покупателю одну и ту же протухшую PaymentURL. Окно НЕ распространяется на AUTHORIZED и прочие карточные статусы — там деньги уже в игре, разгребать их — работа реконсиляции; - IDOR: checkout читал оценку без _assert_estimate_access. По чужому estimate_id возвращался order_id чужого живого платежа, а order_id — право доступа для /payments/status/<order_id>, отдающего capability-ссылку на отчёт. Проверка ставится только для оценок с владельцем: у анонимной покупки идентичности нет, правом там работает сам estimate_id. Тесты двусторонние, фальсификация прогнана: снятие ON CONFLICT / границы по времени / IDOR-гварда красит ровно один тест каждый раз, два из трёх — по значению ответа. |
|||
| 28e13d5841 |
feat(payments): роутер checkout/notify, статус-машина и выдача по capability-ссылке
Не хватало ровно проводки: сервисный слой Т-Банка (PR-C) и схема (PR-B, 233)
уже были, HTTP-ручек и статус-машины — нет, как и доставки купленного.
Всё за kill-switch PAYMENTS_ENABLED (дефолт false): при выключенном контуре
каждая ручка отвечает 503 и не трогает ни банк, ни платёжные таблицы, поэтому
merge на проде не меняет поведения.
Идемпотентность целиком отдана БД (UNIQUE миграции 233 + ON CONFLICT DO
NOTHING), а не паре «проверить-потом-вставить»: между проверкой и вставкой
проходит параллельный ретрай банка, и товар выдаётся дважды. Признаком
«выдача состоялась» служит payment_notifications.processed_at, а не сам факт
строки — иначе падение процесса между записью нотификации и выдачей оставило
бы клиента без отчёта при списанных деньгах.
Доставка — capability-ссылка /api/v1/trade-in/r/<token>: токен лежит в
payment_entitlements.subject (ref_id остаётся estimate_id, на нём держится
UNIQUE «выдали один раз»), режется из GlitchTip-событий и открыт в rbac
отдельным узким префиксом. Тело GET /estimate/{id} вынесено в load_estimate,
чтобы у второго права доступа был тот же загрузчик, а не третья копия
гейта читаемости.
|
|||
| 2d4daceb2f |
feat(mera): анонимный расчёт и капабилити-ссылка на его бесплатную часть
Публичный контур умел только подсказки и пробу покрытия: полный расчёт закрыт RBAC, а результат анонима нельзя было прочитать повторно — _assert_estimate_access отдаёт 404 на строку с created_by IS NULL всем, кроме админа, то есть расчёт жил ровно в теле POST-ответа и не переживал перезагрузку страницы. POST /api/public/mera/estimate делегирует в app.api.v1.trade_in.estimate (копии логики нет — иначе публичная когорта разъедется с платной) и отдаёт наружу только бесплатную часть: число аналогов и вердикт покрытия из той же coverage_probe. Цены, прогнозы и списки аналогов остаются в БД для платного контура. Согласие 152-ФЗ обязательно и строго True на уровне схемы, поэтому отказ происходит до входа в хендлер — раньше, чем адрес физлица дошёл бы до БД. POST /api/public/mera/estimate/read читает бесплатную часть по токену (secrets.token_urlsafe(32), в БД только sha256, срок жизни 7 дней, миграция 278). Токен едет телом: access-лог Caddy пишет URI целиком, и капабилити-ссылка в пути легла бы в файл рядом с IP посетителя — тот же довод, по которому POST'ом сделан /suggest. Постоянный путь заодно не требует префиксной ветки в rbac._PUBLIC_PATHS. Всё закрыто флагом public_estimate_enabled (дефолт false → 404): включение открывает запись ПДн и требует решения владельца вместе с правкой политики. |
|||
| abe559cf8f |
fix(mera): витрина больше не отсеивает промахи оценщика, счётчики едут на фронт
Ревью MAJOR по честности, два пункта. 1. Убран MAX_ABS_ERR_PCT = 40 из build_row. Докстринг модуля сам запрещает отбор по величине ошибки, но запрет был реализован только в _sort_key, а фильтр — тот же отбор ступенькой раньше, и злее: строка не попадала даже в кандидаты. Обоснование «отклонение >40% — почти всегда занижение ДКП ради налога» не держится: _load_sample уже режет выборку санитарным диапазоном ₽/м² (для ЕКБ это глобальные PPM2_MIN=30k / PPM2_MAX=600k — город намеренно не заведён в deal_city_price_bands), то есть грубые занижения вырезаны выше по потоку и ПО СВОЙСТВУ САМОЙ СДЕЛКИ. Всё, что после этого дало большую ошибку, — работа оценщика, и посетитель обязан её видеть. Честность про заниженные ДКП перенесена в note каждой строки. Заодно убраны MIN_FACT_PPM2=30k (дублировал уже применённый фильтр) и MAX_FACT_PPM2=1.2M (недостижим при потолке выборки 600k): из трёх отбраковок в проде срабатывала ровно одна — та, что льстила витрине, а два мёртвых порога читались как работающие. Осталась только структурная отбраковка «нет прогноза / квартала / площади». 2. Счётчики прогона выведены в ответ ручки. Итог пересчёта пишется в landing_showcase_runs (миграция 277) и уезжает в ShowcaseResponse.stats вместе с правилом отбраковки: показано 20 из N годных, рассмотрено M сделок. Отдельная таблица, а не колонки в строках, — иначе в самом важном случае (показывать нечего) счётчики исчезли бы вместе со строками. Ручка теперь берёт и строки, и числа ИЗ ОДНОГО прогона: иначе пустой прогон показал бы вчерашние строки под сегодняшними счётчиками. Тесты двусторонние и проверены на сломанном коде: возврат любого порога по ошибке → красный с величиной отклонения в сообщении; возврат любой границы ₽/м² → красная своя половина; stats=None при живом прогоне → красный. |
|||
| b72dbc5da3 |
feat(mera): витрина лэндинга на реальных ДКП-сделках вместо выдуманных
Лента «МЕРА сказала X — продали за Y» жила на константах в marketing-v3.ts. Здесь появляется её настоящий источник: сделки Росреестра по ЕКБ, прогнанные через тот же спайн оценщика, что и боевой расчёт (backtest_estimator). Отбор строк идёт по полноте данных и свежести квартала и НЕ смотрит на величину ошибки: отбор по малой ошибке дал бы формально работающий код и врущую витрину — показанные строки перестали бы быть выборкой из работы оценщика. Свойство закреплено двусторонним тестом. Витрина не показывает адреса (номер дома есть у 2.7% сделок) и не показывает дня сделки (deal_date — первое число квартала). Каждая строка несёт note о том, что замер не point-in-time. Заниженные ради налога ДКП отбрасываются по |отклонению| > 40% и ₽/м² вне [30k; 1.2M], счётчик отброшенного — в лог. |
|||
| b5645ec1bc |
feat(mera/b2c): витринные метрики лэндинга считаются по проду
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m13s
Числа на публичном лэндинге лежали литералами во фронте
(mera-public/marketing-v3.ts) — то есть были выдуманы и не имели срока
годности. Теперь их считает ночная задача и отдаёт публичная ручка,
вместе с размером выборки и описанием того, что именно измерено.
Что считается: число расчётов и период работы, медиана аналогов на
расчёт, медианная ЭКСПОЗИЦИЯ активного объявления по ЕКБ (не срок
продажи — так и написано в note), доля снижавших цену и медианное
снижение за 30 дней, сделки Росреестра по ЕКБ за 12 месяцев.
Ценовые метрики берут ТОЛЬКО domklik: у avito/yandex триггер не пишет
стартовую цену, а yandex вдобавок сеет синтетическую пару со сдвигом в
сутки — на такой смеси «снизил» и «не снижал» неразличимы. Знаменатель
доли — все объявления, наблюдавшиеся от 14 дней, включая не менявшие
цену; считая только по менявшим, получили бы 85% вместо честных 48%.
Метрика без входных данных строку НЕ пишет: подставленный ноль читался
бы как измеренный ноль. Пустая таблица — валидные {} и 200, а не 500.
«Точность прогноза» и «срок продажи» здесь не считаются намеренно —
таких величин в данных нет.
|
|||
| 5be64c6688 |
fix(tradein/scrapers): хранилище авторизованной сессии Яндекс.Недвижимости (#3195)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m18s
Deploy Trade-In / test (push) Successful in 4m4s
Deploy Trade-In / build-backend (push) Successful in 1m36s
Deploy Trade-In / deploy (push) Successful in 2m0s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
|
|||
|
|
dd3a79b475 |
style(tradein): уложить перенесённые строки в лимит 100 символов
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m48s
Ruff E501 на трёх строках, которые удлинились от замены литерала на `DEFAULT_IMPERSONATE` в докстроках. Абзацы перевёрстаны целиком, а не разорваны по месту переполнения — рваный перенос читался бы как опечатка. Прогон: `ruff check app tests` — All checks passed. Refs #3148 |
||
|
|
cf3d4850e2 |
fix(tradein/scrapers): три прод-пути остались на chrome120, пока kit ушёл на chrome146
Some checks failed
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 1m3s
#3034 свёл impersonate к единственной константе внутри scraper_kit и поднял профиль до chrome146. Сторож литерала сканирует только пакет, а его докстрока объявила остальное «отдельным периметром вне scope», сославшись на #2361 F4a. Периметр не спящий — он ходит в сеть каждый день, а #2361 к тому моменту был закрыт, то есть отсылка вела в никуда. На chrome120 оставались: app/services/cian_session.py:164 верификация куки Циана app/services/yandex_address_backfill.py:153 бэкфилл адресов app/tasks/yandex_detail_backfill.py:303 detail-бэкфилл Разрыв в 31 мажорную версию живёт в TLS-отпечатке (JA3/JA4), а не в строке User-Agent, поэтому сменой прокси он не лечится. ПРО ЦИАН ОТДЕЛЬНО. По #2673 оценка Циана мертва с 29 июня — «куки протухли, ни одной новой строки 37 дней». Путь, которым проверяется живость этих куки, всё это время представлялся площадке браузером двухлетней давности. Причину этим не объявляю: утверждаю, что при таком отпечатке отличить «куки протухли» от «нас узнали по рукопожатию» нечем. ТЕСТ ЗАКРЕПЛЯЛ ДЕФЕКТ. test_cian_session прибивал chrome120 гвоздём: подъём профиля в kit ронял бы этот тест, а «починкой» выглядел бы возврат к устаревшему профилю. Теперь тест сверяется с DEFAULT_IMPERSONATE. Сторож литерала расширен на backend/app — без этого периметр возвращается молча, что уже один раз и произошло. Намеренно НЕ входят tests/fixtures/** (номер профиля там — часть записи о том, чем снят фикстур-HTML) и scripts/** (разовые инструменты, в прод-путях не участвуют). Исторические замеры в комментариях сохранены как замеры: «curl_cffi с kit-профилем (на момент замера — Chrome 120)» вместо переписывания истории. Проверено: сканер сторожа на дереве даёт ноль нарушителей, на подсаженном литерале краснеет; все изменённые модули компилируются. Refs #3148 |
||
| 4b245b6605 |
feat(tradein/estimate): потолок одновременных оценок — 4 слота, быстрый 429 (#3082)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m41s
Рейт-лимит меряет частоту (300/60с вправе стартовать в одну секунду), квота — счётная и помесячная: параллелизм /estimate не ограничивал никто. Оценка 0.8–2.4с держит соединение общего пула SQLAlchemy (5+10) и внешние тиры — пила одновременных оценок выедала пул и тормозила весь /api/v1/*. Семафор по образцу public/mera.py::_suggest_slots: acquire после дешёвых отказов (рейт-лимит, квота) с ожиданием 5с ≈ две длительности оценки, timeout → 429 с Retry-After; release в finally сразу после дорогой части. 4+4 слота (estimate+suggest) = 8 удерживаемых соединений из 15 пула. Семафор в памяти процесса — при переходе на несколько воркеров (#3083) лимит умножится на их число; задачи согласовывать (о чём комментарий на месте). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| d409e31f00 |
feat(mera/b2c): анти-абуз для анонимного трафика — этап 2 из 8 (#2546)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m50s
Deploy Trade-In / build-backend (push) Successful in 4m23s
Deploy Trade-In / deploy (push) Successful in 2m32s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
|
|||
| cdf493f345 |
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
|
|||
| a003fdd37c |
feat(tradein): ручка «дома, уехавшие от собственных объявлений» (#2996)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m27s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 2m10s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
Часть Находки 3 из #2996. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| fab0bc84c1 |
feat(tradein): ручка «дома, уехавшие от собственных объявлений» (#2996)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m49s
Находка 3 задачи («23 дома лежат вне области 66, гарда на приёме нет») подтвердилась, и к ней добавились масштаб и причина. Масштаб: к этим домам привязано 591 объявление. Адреса екатеринбургские («Ул. 8 Марта», «Крауля», «Амундсена»), координаты — Варшава, Белград, Таллин, Владивосток, Ижевск. Причина: 22 из 23 несут в raw_payload след разового бэкфилла `full_backfill_2026-05-27`, который звал Yandex-геокодер напрямую, без резолва города. Живой путь при этом ЧИСТ — 0 записей вне региона в geocode_cache из 10 448 и 2 из 88 544 у listings; скрипта в репозитории нет. Это исторический осадок, а не текущая утечка, и гард нужен не столько живому пути, сколько следующему разовому скрипту. 17 из 22 помечены геокодером `precision: "exact"`. Точность отвечает на «нашёлся ли номер дома», а не «в том ли городе», и критерием приёмки быть не может — поэтому проверяется ПРИНАДЛЕЖНОСТЬ. Инвариант нарочно не географический: «дом рядом со своими объявлениями», а не «дом внутри рамки области». Рамка сломалась бы при выходе в Москву — ровно то, ради чего заведена #2996. Он же строго сильнее: ловит 25 домов против 23 у рамки, и оба лишних проверены («Ул. Белинского/Фурманова» за 207 км, 34 объявления). Порог 100 км не подобран на глаз. Замер по 9 052 домам: ближе 1 км — 8 914 (98.5 %), 25-100 км — 9 настоящих пригородов (Сарапулка, Кедровка, Чусовское Озеро, Ревда, Первоуральск; самый дальний 46.7 км), дальше 100 км — 25 (ближайший 119.2, максимум 5 079). Между 46.7 и 119.2 км нет НИ ОДНОГО дома: порог лежит в середине пустого промежутка. Первая редакция клала счётчик полем в /scraper/data-quality. Замер это остановил: запрос стоит ~445 мс на тёплом кэше, а обе существующие выборки той ручки вместе — 27 мс, при опросе фронтом каждые 120 с. То есть 17-кратное удорожание ради числа, которое меняется раз в месяцы. Проверка вынесена в отдельную ручку по требованию, и на возврат в горячий путь поставлен контроль-тест. Ручка отдаёт не только счётчик, но и масштаб (список домов + сколько объявлений привязано) — по одному числу «25» решение об очистке не принять. Двусторонне: против origin/main три теста красные, краснота везде по значению — ни одного ImportError/AttributeError. Контроль test_check_stays_out_of_the_polled_endpoint зелёный с обеих сторон. pytest tradein-mvp/backend — 4642 passed, 23 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| b9cfdaa040 |
feat(tradein): secondary_only — параметр расписания, выброшенное считается (#1781)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m27s
`run_cian_full_load` передавал `secondary_only=True` жёстко, поэтому включить новостройки в полный обход можно было только деплоем. Теперь это параметр с ТЕМ ЖЕ дефолтом `True` — поведение прода не меняется ни на строку, но решение становится правкой одной ячейки `scrape_schedules.default_params`, а не выкаткой кода. Откат — тем же движением. Почему это важно именно здесь. Новостройки НЕ пропускаются при запросе: они скачиваются, разбираются и выбрасываются последним шагом (`cian/serp.py`), потому что SERP-параметр `object_type=1` у Cian ненадёжен (~5 % выдачи) и фильтруют по authoritative `listing_segment` после парсинга. Проба бакета берёт `totalOffers` из Redux-состояния SERP и считает `pages_needed = ceil(totalOffers / offers_per_page)`, а `totalOffers` включает ОБЕ категории — то есть страницы с новостройками уже скачаны, лимит страниц и антибан-бюджет за них уже заплачены. Включение стоит ноль дополнительных запросов. Заодно `dropped_novostroyki` сохраняется в counters прогона. Счётчик логировался (`dropped_nb=`), но не персистился, и ответить «сколько инвентаря выбрасывает полный обход» задним числом было нечем: логи за 17.08 уже ротировались — `docker logs --since 120h` не находит ни строки «cian:» ни в одном контейнере. Тот же довод, по которому рядом заведён `partial_buckets`. Копится в атрибуте инстанса, а не аргументом `on_bucket`: у колбэка есть внешние реализации, менять его сигнатуру ради счётчика нельзя. Сброс на каждый прогон — инстанс переиспользуется. Замер, ради которого это делается (прод 21.08): месячный охват свипа cian/novostroyki — 11.7 % против 100 % у cian/vtorichka и avito/novostroyki; 11 993 активные строки, медианный возраст 81 сутки, 10 585 старше 30 суток. Подробности и оговорки — в #1781 и #2994. Двусторонне: против origin/main пять тестов красные, и краснота везде по значению, а не по отсутствию символа — ни одного KeyError. Сообщения перечисляют фактическое состояние («параметра нет в сигнатуре; параметры: [...]», «поля нет в запросе; поля: [...]»). Контроли зелёные с обеих сторон: дефолт остаётся True (иначе правка тихо включила бы сбор новостроек на проде — это отдельное решение с замером); фильтр при `secondary_only=True` остаётся на месте и по-прежнему зависит от флага. pytest tradein-mvp/backend — 4644 passed, 23 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| cf48e6d6c8 |
feat(tradein): позиция квартиры внутри когорты аналогов — перцентиль (#2899) (#2926)
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m54s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Successful in 2m6s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
|
|||
|
|
73c4c487ed |
fix(mera/b2c): житель Серова получал «вы вне области», а подсказки игнорировали выбранный город
All checks were successful
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m6s
CI Trade-In / backend-tests (pull_request) Successful in 4m50s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Successful in 48s
Два дефекта, найденных прогоном сценария глазами посетителя на живом домене. ## 1. Город предлагали выбрать, но отвечать по нему не умели Дропдаун на сайте (`OBLAST_CITIES`, city-registry.ts) и списки покрытия (`COVERAGE_GREEN/YELLOW_CITIES`, trade_in.py) — одно множество, записанное в двух местах. Они разошлись в обе стороны: предлагали, но не отвечали: Серов отвечали, но не предлагали: Берёзовский, Среднеуральск, Ревда Житель Серова выбирал СВОЙ город из НАШЕГО дропдауна и получал: «Этот адрес вне области, по которой мы собираем данные. Сейчас это Свердловская область: Екатеринбург целиком и ещё несколько городов вокруг.» Про город в той же самой области. Серов при этом покрыт данными: 363 активных объявления в радиусе 15 км, все свежие (замер по проде). Поэтому добавлен в жёлтый тир, а не убран из дропдаунa; три недостающих города добавлены на фронт. Шапка city-registry.ts этот риск прямо предсказывала — «перед добавлением 7-го города сверить оба списка вручную, теста на это пока нет». Теперь тест есть: бэкендовый сьют читает TS-реестр и требует РАВЕНСТВА множеств. Плюс проверка, что у каждого города с порогом есть центроид, — иначе порог мёртвый, город по координатам не резолвится. ## 2. Подсказки не слушались выбранного города `city_hint` доезжает до геокодера, но на выдачу не влияет: его смотрит только екатеринбургский кадастровый тир (как признак «речь не про ЕКБ, тир пропускаем»), а DaData-тир ограничен регионом целиком и хинта не принимает. Замер: выбран Серов, введено «Ленина 1» → первой подсказкой «Невьянский р-н, пгт Верх-Нейвинский». Человек выбирает верхний вариант и считает чужой дом — ровно баг #2576, ради которого город и спрашивают. Публичная ручка теперь подставляет город в саму строку запроса. Проверено на проде: «Серов Ленина 1» даёт серовскую выдачу целиком. Для Екатеринбурга подстановка безвредна — три разных адреса дали тот же результат с префиксом и без, поэтому правило одно на все города, без исключения для основного трафика. Чинится в публичной ручке, а не в геокодере: там от `city_hint` зависит поведение закрытого контура (`target_city_ambiguous`). ## Фикстура теста `_FAR_AWAY_CITY` стояла в 21 км от центра Серова и работала как «далеко от всех» лишь потому, что Серов не был поддержан. Переехала в Тавду — 271 км до ближайшего центроида. ## Мутации убрать Серов из покрытия (состояние прода) → падает сверка списков не подставлять город в строку → падает проверка ручки откат → 21 passed Плюс backend 75 passed, vitest 56 passed, tsc, lint, build, isolation guard. `city-registry.ts` добавлен в paths-фильтр БЭКЕНДОВОГО лэйна: сверку списков делает бэкендовый тест, и без этой строки правка одного лишь дропдауна её бы не запускала — то есть ровно тот путь, которым списки и разошлись. |
||
|
|
56194c606a |
fix(mera/b2c): семь дефектов публичного периметра, найденных состязательным ревью
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Failing after 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 46s
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 4m42s
Ревью четырьмя независимыми линзами (периметр, семантика Caddy, политика ПДн
против кода, фронт) + по два проверяющих на каждую находку. Ниже — то, что
пережило проверку и воспроизведено на живом коде, а не выведено из чтения.
## Caddy: открытый редирект и потерянные ссылки
Захват хвоста регекспом (`^/trade-in/mera-public/(.+)$` → `redir /{re…1}`) —
открытый редирект. Захват берётся из РАСКОДИРОВАННОГО пути, поэтому
`/trade-in/mera-public/%5Cevil.example/pay` даёт цель `/\evil.example/pay`, а
браузеры трактуют `/\` как `//` — Location уводит на чужой хост. Готовая
фишинговая заготовка с домена, который напечатан внутри оферты и уходит
модератору эквайера. Заменено поимённым списком путей: такой адрес просто не
матчится.
Адреса со слэшем на конце (`/oferta/`, и длинные `…/oferta/`) отдавали 404 —
ровно те ссылки, ради сохранности которых редирект и делался. Добавлена
нормализация, цепочка замкнута (проверено: 2 перехода → 200).
Query-строка терялась: размещённые ссылки с UTM приходили бы в аналитику как
прямой заход. `uri strip_prefix` + `{uri}` переносит её. Обёртка `route`
обязательна — без неё `redir` выполняется раньше `uri` и Location равен
исходному адресу (бесконечный цикл, поймано на стенде).
`/v3` — черновое превью с маркетинговыми плейсхолдерами — было открыто на
боевом домене молча. Теперь названо вслух и запинено тестом.
## Гейты, которых не было
`caddy validate` не звал НИ ОДИН workflow, а deploy применяет конфиг не через
`reload` (тот отказался бы принять битый), а через `up -d --force-recreate` —
опечатка уводит контейнер в crash-loop и роняет ВСЕ домены. Добавлен гейт в
ci.yml, тем же образом caddy:2, что и на проде.
Проверка «роут ↔ Caddy» была односторонней и пропускала обратную ошибку —
путь, открытый наружу, о котором приложение не знает. Так и уехал `/v3`.
Теперь двусторонняя, плюс проверка, что для каждой страницы есть 301.
## Бюджет внешнего геокодера
Per-IP окна ограничивают одного клиента, но не сумму: 40/мин с адреса — это
57 600 в сутки при бесплатном тире DaData в 10 000, ОБЩЕМ с закрытым контуром.
Подтверждено на проде: достаточно упомянуть не-екатеринбургский город, чтобы
локальный тир отключился и запрос гарантированно ушёл во внешний сервис. То
есть один скрипт оставлял без подсказок платящих пилотов.
Per-IP снижен до 20/мин, добавлен общий суточный потолок 2000 и потолок
одновременных подсказок (4): кадастровый тир уходит в FDW-скан чужой базы,
держит соединение около секунды, а пул общий с B2B — полтора десятка
параллельных публичных запросов клали бы закрытый контур.
## «Адрес нигде не сохраняется» — теперь правда целиком
Две утечки, обе воспроизведены:
1. ЖУРНАЛЫ. Геокодер печатает введённую строку открытым текстом на каждый
вызов, прод пишет stdout в persistent journald — адрес ложился на диск
рядом с IP того же запроса в access-логе Caddy. Закрыто фильтром логов на
время публичного запроса (contextvar, переживает await и to_thread).
Закрытый контур логи сохраняет: они нужны для разбора жалоб пилотов.
2. МОНИТОРИНГ. sentry_sdk кладёт в событие ПОЛНОЕ тело запроса — а тело
публичной ручки это ровно `{"q": "<адрес>"}`; `send_default_pii=False` тут
не гейт, он про куки. Плюс брэдкрамб httpx несёт адрес в query геокодера.
Закрыто `scrub_public_address`.
Текст п. 5.4 политики расширен до «ни в журналы веб-сервера, ни в технические
журналы, ни в мониторинг» — ровно то, что теперь обеспечено кодом.
## Фронт
- Отмена запроса подсказок откладывалась внутрь следующего debounce-такта и
не наступала вовсе, если человек переставал печатать: ответ по старой строке
долетал и ложился в список. Контроллер создаётся сразу, отменяется в cleanup.
- Список схлопывался на каждое нажатие — клик по намеченному пункту
промахивался. Старая выдача висит, пока не пришла новая.
- «Комнат» с лэндинга — свободный текст: «студия» не совпадала ни с одним
option, селект показывал пустоту, parseInt давал NaN, на сервер уходил
rooms: null → 422 с текстом «сломалось на нашей стороне». Нормализация
вынесена чистой функцией и покрыта тестами.
- У пробы покрытия не было ни таймаута, ни отмены: оборванное соединение
оставляло кнопку в «Смотрим данные…» навсегда. 15 с + понятный текст.
- Ошибка подсказок глушилась в пустой список — тупик без объяснения.
- Комбобокс: Tab проваливался в кнопки подсказок, список не закрывался по
уходу фокуса и перекрывал поля, Escape оставлял висячий aria-activedescendant.
## Проверено
Локальный стенд (реальный site-блок Caddy + заглушка): 18 маршрутов, включая
`%5C`, `//`, `%2F` — все три теперь 404. vitest 55 passed, backend 17 passed по
публичному API, tsc, lint, build, isolation guard 41 файл, caddy validate.
Мутации: снять редакцию логов → падает тест журналов; не вырезать тело запроса
→ падает тест мониторинга; убрать /estimate из Caddy → падает тест маршрутов.
|
||
|
|
b1fb7bb055 |
Merge remote-tracking branch 'forgejo/main' into feat/mera-public-api
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Successful in 46s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m7s
CI Trade-In / backend-tests (pull_request) Successful in 4m45s
# Conflicts: # tradein-mvp/backend/app/core/rbac.py |
||
|
|
7424c283d5 |
feat(mera/b2c): отдельный экран оценки на meraocenka.ru и короткие адреса
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 44s
CI Trade-In / frontend-checks (pull_request) Successful in 1m2s
CI Trade-In / backend-tests (pull_request) Successful in 4m41s
## Экран проверки — /estimate Проверка квартиры вынесена на собственный адрес: там у автокомплита есть место под список подсказок, а у результата — место рядом с полями. Форма в герое лэндинга осталась входной точкой и уводит сюда, донося набранное через sessionStorage (НЕ через query — адрес в URL попал бы в access-лог Caddy рядом с IP посетителя, а мы на той же странице обещаем ничего не хранить). Показывает живую пробу покрытия: сколько похожих квартир продаётся рядом и сколько в среднем висят их объявления. Ни одной рублёвой цифры — цену продаёт платный шаг. Тексты вердикта вынесены чистой функцией (coverage-copy.ts) и покрыты тестами: подпись под возрастом обязана говорить «объявление», а не «продаётся» (выборка цензурирована), при неизвестном возрасте плитки нет вообще, а пустая когорта объясняется как факт о рынке с подсказкой, что поменять, — директива «никогда не блокировать вывод». ## Короткие адреса Человек больше не видит /trade-in/mera-public/... — только /, /estimate, /oferta, /refund, /privacy. Длинные адреса отдают 301 на короткие: у страницы один канонический адрес, старые ссылки живы. Цена решения: ссылки работают только на meraocenka.ru (короткие пути раздаёт этот хост). Открывать лэндинг для проверки нужно там же, а не с gendsgn.ru. Ссылки эмитятся обычным <a> (PublicLink) — next/link подставляет basePath, и href="/estimate" уехал бы на несуществующий /trade-in/estimate. ## Три дефекта, найденных на живом сайте 1. Палитра v3 никуда не доезжала. b2c-tokens.ts не импортировал НИКТО, ни одна --b2c-* переменная не объявлялась, каскад молча пропускал такие декларации — лэндинг отдавал 200 бесцветным. Добавлен мост b2cVars, гейтом стал тест: каждая использованная в CSS переменная обязана быть объявлена. 2. Голый /trade-in/mera-public падал в 404 — матчер был со слэшем и звёздочкой. Ровно туда вела «Главная» в подвале. 3. «Для бизнеса» вела на «/» — то есть на сам лэндинг. Теперь абсолютный адрес B2B-контура. Пункты «Проверьте себя» и «Продажа под ключ» вели на якоря, которых нет нигде: приведены к виду «Статьи» — видны, но не кликабельны. ## Периметр и приватность Подсказки переведены на POST: access-лог публичного домена пишет URI целиком, то есть GET с ?q= сохранял бы адрес квартиры в файл. Тело в лог не попадает. Метод запинен тестом — это часть обещания, а не стиль. П. 5.4 политики ПДн переписан ВМЕСТЕ с кодом: прежний текст утверждал, что адрес не покидает браузер, и это перестало быть правдой. Новый говорит точно — передаётся, используется однократно, в базах не сохраняется. Последнее проверено по коду: suggest() работает без кэша, проба — один SELECT, аудит пишет строку только при наличии username. PUBLIC_ESTIMATE_ENABLED сузился до платного шага (бесплатная проба не хранит ничего, платный расчёт хранит). ## Проверено vitest 47 passed (9 файлов), tsc, next lint, next build, isolation guard 40 файлов, backend 75 passed, caddy validate = Valid configuration. Мутации структурных гейтов: убрать --b2c-accent-text из b2cVars → падает тест палитры убрать /estimate из @meraPages → падает тест маршрутов добавить импорт next/link → падает тест basePath откат → 14 passed Caddyfile добавлен в paths-filter фронтового лэйна: его читает тест маршрутов, и без этой строки правка одного лишь Caddyfile не запускала бы ни один гейт. Refs #2894, #2895 |
||
|
|
b649caec2d |
feat(tradein/ops): приёмник GlitchTip webhook-алертов в Telegram-тему
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m50s
Мониторинг GlitchTip сейчас нем (alerts_projectalert/alerts_alertrecipient пусты, EMAIL_URL=consolemail:// печатает письма в stdout, аудит на проде 2026-08-15). GlitchTip умеет получателя типа webhook, но шлёт свой Slack- совместимый JSON без каких-либо заголовков — Telegram Bot API его не понимает, нужен адаптер. - app/api/v1/glitchtip.py: POST /api/v1/trade-in/ops/glitchtip-webhook — принимает issue- и uptime-алерты (структурно одинаковый payload у GlitchTip 6.1.6, см. docstring), форматирует короткое сообщение (проект/заголовок/ссылка/время получения) и шлёт через существующий TelegramClient в отдельную тему алертов. Обрезка под лимит Telegram (4096 симв.), неизвестная форма payload пересылается как есть с пометкой вместо 500. - Auth: GlitchTip не может слать кастомные заголовки (aiohttp.post без headers=) — переиспользуем TRADEIN_INTERNAL_AUTH_SECRET (#2213) как query-параметр `secret`, constant-time compare. В отличие от rbac.py пустой секрет здесь fail-CLOSED (503), это единственный auth-рубеж пути. - config.py: TELEGRAM_ALERTS_CHAT_ID / TELEGRAM_ALERTS_TOPIC_ID — намеренно отдельные от TELEGRAM_SUPPORT_*, чтобы алерты не лились в топик клиентов. - rbac.py: путь добавлен в _PUBLIC_PATHS (фиксированный, без секрета в самом пути — секрет только в query). - docker-compose.prod.yml: glitchtip-worker (реально шлёт вебхуки, не glitchtip-web) переведён на networks: [default, shared] — без этого tradein-backend не резолвится с его стороны (общей сети не было вообще). |
||
|
|
d046eaada2 |
feat(mera/b2c): публичный API МЕРЫ — /api/public/mera/{suggest,coverage}
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m49s
Первый шаг к отдельному B2C-интерфейсу оценки на meraocenka.ru: домен получает собственную поверхность бэкенда вместо того, чтобы тянуть куски закрытого контура. ## Отдельный префикс, а не проброс кусков /api/v1/* На meraocenka.ru действует allowlist-by-default. Открыть там API можно было двумя способами: перечислить нужные v1-пути поимённо — или завести префикс, под которым по определению не лежит ничего закрытого. Выбран второй: при первом одна опечатка в матчере (`/trade-in/api/*` вместо точного пути) открывает наружу весь v1 — ~20 ручек, включая PDF расчётов, фотографии и админку. Цена ошибки, а не удобство. Добавить сюда приватную ручку теперь нужно СПЕЦИАЛЬНО — положив файл в app/api/public/. Случайно нельзя. ## Ноль записей в БД Обе ручки только читают: /coverage — один SELECT, /suggest — прокси автокомплита. Это условие, при котором публичная форма работает ДО контура согласия 152-ФЗ (#2895: сегодня адрес физлица попадает в trade_in_estimates раньше согласия, а пути удаления в бэкенде нет). Платный расчёт, который писать будет, открывается только вместе с ним. ## Делегирование, а не копии Обе ручки вызывают те же функции, что обслуживают закрытый контур (v1.geocode.suggest_addresses, v1.trade_in.coverage_probe). Разбор #2894 показал, чем кончается вторая копия когорты: проба отвечает «данные есть» там, где платный расчёт видит ноль. Публичный ответ переиспользует CoverageProbeResponse — на нём уже стоит гейт «ни одного price-подобного поля». ## Бюджеты Общего 300/60с мало: /suggest через DaData-тир — платный внешний вызов, абуз стоит денег. Свои per-IP окна: 40/мин на подсказки (человек с debounce'ом тратит единицы на адрес), 15/мин на пробу. ## Проверено 11 тестов, из них структурные: набор ручек под /api/public проверяется на РАВЕНСТВО (третья, добавленная без правки теста, роняет сборку) и сверяется с rbac._PUBLIC_PATHS в обе стороны — чтобы не осталось открытого пути-призрака. Рядом висит закрытый маршрут-двойник: без него «аноним получает 200» одинаково зелёный и когда исключение точечное, и когда auth-гейт снят целиком. Мутации: убрать пути из rbac._PUBLIC_PATHS → 7 failed / 4 passed снять бюджет с /coverage → 2 failed / 9 passed откат → 11 passed Плюс 72 passed на связке rbac + coverage + version, `caddy validate` = Valid configuration, ruff чист. Смоук периметра дополнен парой, которую нельзя разделять: публичные ручки отвечают 200 анонимно И /trade-in/api/v1/* на этом домене по-прежнему 404. Зелёная только первая проверка = API открыт целиком, а тест этого не заметил. Refs #2894, #2895 |
||
|
|
37e738c802 |
fix(tradein/coverage): resolve city by coordinates, not sweep-context city_hint
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m34s
Повторная проверка /coverage закрыла оба MAJOR из #2894, но выявила три новых дефекта: 1. Город больше не резолвится из моды listings.city найденной когорты — эта колонка хранит город SWEEP-контекста скрейпера (миграция 196), не геокод адреса объявления. Замер на проде: 90/90 строк в радиусе 1000м вокруг Берёзовского имеют city='Екатеринбург', 74/74 вокруг Ревды — city='Первоуральск'. Города-спутники из COVERAGE_GREEN/YELLOW_CITIES были физически недостижимы. Город теперь резолвится детерминированно по lat/lon запроса — ближайший центроид из статичной константы (8 городов, рядом с ручкой, не в БД — comment объясняет почему) в пределах 25 км. city_hint остаётся в схеме (фронт его шлёт для соседних ручек), но чисто информационный — на порог/статус не влияет. 2. test_max_age_outlier_days_passed_to_sql проверял подстроку, которая встречается в SQL дважды (count и percentile_cont) — мутация «убрать FILTER у percentile_cont, оставив у count» проходила зелёной. Добавлен живой поведенческий тест (вставляет когорту + выброс days_on_market=4000, проверяет что медиана не сдвигается) — ловит эту мутацию (подтверждено: median 8→9 при мутации). 3. _live_session() вызывался в pytest.mark.skipif на этапе сбора тестов и создавал никогда не закрываемый Session, плюс дублировался в теле теста. Заменено на _live_db_available() (open+close голого connection) для skipif и pytest-фикстуру live_session с гарантированным close/dispose. 4. Nit: пустая когорта в поддерживаемом городе отдавала status=not_covered вместе с ненулевым threshold — противоречило докстрингу CoverageProbeResponse.threshold ("0, когда порог неприменим"). threshold теперь всегда 0 при not_covered, независимо от причины. |
||
|
|
3e9af2fdef |
fix(tradein/coverage): sync cohort with paid estimator, honest age medians
Independent review found two MAJOR defects in POST /api/v1/trade-in/coverage: MAJOR-1: the probe cohort WHERE clause was missing three predicates present in estimator._COMMON_WHERE / Tier W (novostroyki guard, geo_precision != 'city', price_rub > 0) — the free probe could answer "ok" at points where the paid estimator's own 1000m radius tier sees zero real analogs. Prod example: 56.868904/60.837955, 2 rooms, 50 m2 gave n_listings=22/status=ok while the estimator's cohort at the same radius was 0 (all 54 rows were novostroyki). Added the three predicates verbatim from estimator.py, plus both a static SQL-text regression test and a real-Postgres integration test (skip_allowlist.txt, same _live_session() pattern as test_gar_flats_loader) that inserts novostroyka/geo_precision=city/price=0 rows and asserts they are not counted. MAJOR-2: median_listing_age_days was computed from days_on_market, which on prod is populated almost exclusively by one source (yandex) — thin cohorts produced a "median" over 1-2 listings. Added n_with_age to the response (honest count of listings the median is based on); median is now null below COVERAGE_MIN_AGE_SAMPLES=5, and values above COVERAGE_MAX_AGE_DAYS=365 (near -certainly dead listings, per prod: 15% of fresh yandex rows exceed 365d, max 4261d) are excluded as outliers before the percentile is computed. MINOR: city_hint was trusted at face value and echoed back verbatim — a client could pass city_hint="Екатеринбург" with coordinates in Серов and get threshold=8/status=ok. _resolve_coverage_city now prioritizes the SQL cohort's mode city (ground truth) over the client hint, falling back to hint only when the cohort is empty (where status is forced not_covered anyway). Unmatched cities no longer echo the raw client string in the city field. |
||
|
|
d0105470f4 |
feat(tradein/coverage): бесплатная проба покрытия для лендинга МЕРА (#2894)
POST /api/v1/trade-in/coverage — до оплаты пользователь видит только n похожих объявлений в радиусе 1000м и медианный возраст листинга, без единой цены. Один SQL (радиус GIST + rooms + area ±15% + freshness 14д + тот же дедуп/cap- канон, что у estimator._fetch_analogs), ноль внешних вызовов, ноль записей. Пороги ok/thin/not_covered — константы рядом с ручкой (зелёные города >=8, жёлтые >=12, остальные всегда not_covered). Поле median_listing_age_days (не "срок продажи" — возраст активного объявления, цензурированная выборка). RBAC не тронут — путь остаётся закрытым, открытие анонимного периметра вынесено в #2895. |
||
|
|
74e7969ef9 |
fix(tradein/lead): кликабельная ссылка на политику ПДн в чекбоксе согласия
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m6s
CI Trade-In / backend-tests (pull_request) Successful in 4m31s
РКН/владелец: рядом с чекбоксом согласия должна быть ссылка на сам документ
политики обработки ПДн, а не упоминание закона. Чекбокс в LeadForm.tsx
(v2, живой /trade-in/v2) теперь линкует "Политикой обработки персональных
данных" на /mera-public/privacy (target=_blank, чтобы не терять заполненную
форму). Путь вынесен в новый src/lib/legal-copy.ts (модуль без импортов) —
content.ts ре-экспортирует оттуда, чтобы B2B-виджет не тянул B2C-лэндинг-модуль
целиком.
_CONSENT_TEXT_SNAPSHOT/_CONSENT_POLICY_VERSION в lead.py обновлены под новый
плоский текст и дату утверждения политики (PRIVACY_APPROVAL: 2026-08-13).
test_consent_text_frontend_sync.py: экстрактор теперь снимает JSX-теги/{" "}
спейсеры перед сравнением (иначе сломался бы на разметке ссылки) + новый тест
держит _CONSENT_POLICY_VERSION в синхроне с PRIVACY_APPROVAL из content.ts,
чтобы версия не расходилась молча с редакцией документа.
Легаси-дубль в HeroTransparency.tsx (недостижим с живого роута) — текст
приведён в соответствие без ссылки: компонент не смонтирован нигде, и нет
теста, который держал бы там ссылку в актуальном состоянии.
|
||
| c927b77777 |
fix(tradein/imv): «временная» ошибка снова временная — 1390 домов возвращаются в очередь (#2843)
Some checks failed
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Failing after 3m29s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
|