2770 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 2c852bd13b |
feat(tradein): позиция квартиры внутри когорты аналогов (перцентиль)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Failing after 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m31s
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
Макет показывает бейдж «Верх рынка» — место квартиры в распределении аналогов. Поля не было ни в схеме, ни в расчёте. ГДЕ СЧИТАЕТСЯ В estimate_quality, в блоке display_pool — по той же когорте, что дала headline (anchor_comps_used либо ценовые listings_clean), и по УЖЕ финальной median_ppm2. Внутри _price_from_inputs считать нельзя: там цена ещё не финальная (восемь мутаторов — repair_coef, anchor override, IMV-blend, quarter index, corridor clamp, radius floor, deals fallback, segment multiplier — отрабатывают позже), а n_analogs после точки расчёта перезаписывается трижды. Перцентиль разошёлся бы с показанной ценой у двух третей оценок: 733 из 1086 прод-строк идут якорным путём. Пул берётся ДО _dedup_display_lots: дедуп режет выборку под показ (на проде 15 карточек из 528 усечены именно им), а позиция должна считаться по популяции, из которой взята цена. Формула — перцентиль РАНГА (midrank: доля дешевле плюс половина равных), а не _percentile: тот решает обратную задачу «какая цена стоит на заданной доле». Края зажаты в 1..99 — «0-й перцентиль» читался бы как «дешевле всех на свете», хотя означает лишь край выборки из двадцати лотов. ПОРОГ 15, А НЕ 5 Замер 19.08 (Монте-Карло на 54 прод-когортах размера 62..125, с поправкой на конечную популяцию): k=5 → ошибка ранга 13.4 пп и ярлык перепутан в 26.4% случаев; k=15 → 7.6 пп; k=20 → 6.7 пп ценой ещё 10 пп покрытия. Второй сигнал: шаг перцентиля 100/n против ценового разрыва между соседями — при n≈7 один соседний лот двигает ярлык на целую категорию. HEADLINE_LISTINGS_MIN_N=5 переиспользовать нельзя: он отвечает на другой вопрос — устойчива ли МЕДИАНА, а она устойчива там, где ранг ещё пляшет. Ниже порога поле остаётся None и UI не рисует ничего. Отдельную надпись «мало данных» не добавляю — это уже говорят reliability и relaxations. ПЕРСИСТ ОБЯЗАТЕЛЕН Пересчитать на GET нельзя: когорты в БД нет, в analogs лежит top-10 (прод: max(jsonb_array_length(analogs))=10 при max(n_analogs)=294). Отсюда миграция 267 и проброс через ОБА SELECT'а — у GET и PDF списки колонок разные и живут в разных функциях. getattr в конструкторах — тот же defensive-идиом, что у relaxations/reliability рядом: строка без колонки деградирует в None, а не роняет ответ. ЧЕМ ЭТО НЕ ЯВЛЯЕТСЯ Не location_index_pct (тот про район против медианы города, живёт на другой ручке и в цену не идёт), не квантили ДКП-коридора, не price_premium_label. ТЕСТЫ Двусторонние: против main падают импорт нового модуля тестов (константы нет) и test_get_estimate_surfaces_market_percentile. Контроли зелёные с обеих сторон: nullable-поле проходит как null, лоты без цены не засчитываются в порог. Хунк форматирования — не мой: pre-commit ruff v0.7.4 против 0.15.12 (#2864). Refs #2899 |
|||
| d25ff668f7 |
ci: правка конфига прокси больше не тянет полный деплой Site Finder (#2916) (#2925)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / perimeter-smoke (push) Successful in 9s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-worker (push) Successful in 45s
Deploy / build-frontend (push) Successful in 44s
Deploy / build-backend (push) Successful in 45s
Deploy / deploy (push) Successful in 1m8s
Deploy / deploy-status (push) Successful in 1s
|
|||
| d0071c57bc |
fix(tradein): фильтр выдачи Яндекса мёртв — slug ЖК ищем по странице объекта (#2860) (#2923)
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m50s
Deploy Trade-In / build-backend (push) Successful in 1m41s
Deploy Trade-In / deploy (push) Successful in 1m42s
|
|||
| e86f0782da |
ci: смоук периметра МЕРЫ запускается сразу после деплоя (#2917) (#2922)
All checks were successful
Deploy / changes (push) Successful in 10s
perimeter-smoke-mera / smoke (push) Successful in 12s
Deploy Trade-In / changes (push) Successful in 14s
Deploy / build-frontend (push) Successful in 51s
Deploy / build-backend (push) Successful in 53s
Deploy / build-worker (push) Successful in 53s
Deploy Trade-In / build-browser (push) Successful in 40s
Deploy / deploy (push) Successful in 1m33s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Successful in 2m50s
Deploy Trade-In / test (push) Successful in 4m8s
Deploy Trade-In / build-backend (push) Successful in 35s
Deploy Trade-In / deploy (push) Successful in 1m39s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
|
|||
| b1106916a9 |
Merge pull request 'fix(infra/caddy): site-блоки с секретами переживают деплой — import caddy/local/*.caddy' (#2921) from fix/caddy-garmin-import into main
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-backend (push) Successful in 45s
Deploy / build-worker (push) Successful in 46s
Deploy / build-frontend (push) Successful in 46s
Deploy / deploy (push) Successful in 1m25s
Deploy / deploy-status (push) Successful in 1s
|
|||
|
|
f30820bb9a |
fix(infra/caddy): site-блоки с секретами переживают деплой — import caddy/local/*.caddy
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
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 / frontend-checks (pull_request) Successful in 59s
Деплой делает `git reset --hard origin/main`, поэтому любой site-блок, вписанный руками в /opt/gendesign/Caddyfile, живёт до первого пуша в main. 16.08 так и вышло с garmin.gendsgn.ru: контейнер остался healthy, а хост исчез из конфига — TLS-хендшейк стал падать `alert internal error`, потому что сертификат для него Caddy больше не держит. Класть такой блок в git нельзя: у него единственный рубеж аутентификации — секрет в пути URL (апстрим-MCP аутентификации не имеет, а claude.ai connector кастомные заголовки не шлёт). Развязка: в репозитории только `import caddy/local/*.caddy` и сам каталог (держится .gitignore'ом), файлы с секретами лежат на VPS untracked — reset --hard их не трогает. Пустой glob для Caddy не ошибка, `caddy validate` проходит, на машинах без локальных блоков import — no-op. Проверено `caddy validate` 2.11.4 на VPS: и с пустым каталогом, и с реальным блоком garmin — Valid configuration. |
||
| 215b78a348 |
Merge pull request 'fix(tradein/estimate): залипший anchor_tier больше не глушит четыре механизма разом' (#2920) from fix/2656-anchor-tier-reset into main
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 3m45s
Deploy Trade-In / build-backend (push) Successful in 1m10s
Deploy Trade-In / deploy (push) Successful in 1m57s
Deploy Trade-In / deploy-status (push) Successful in 1s
|
|||
|
|
42b0ebe338 |
fix(tradein/estimate): залипший anchor_tier молча глушил IMV-blend и quarter-index
All checks were successful
CI Trade-In / browser-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 / changes (pull_request) Successful in 9s
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 Trade-In / backend-tests (pull_request) Successful in 4m32s
Флаг anchor_tier оставался равным anchor_tier_fetched ("A"/"C"), когда якорь
фактически НЕ строился — сброс делал только low-conf гейт (#audit-1), но не
Tier C corridor-гейт (#1795) и не сама _compute_same_building_anchor, когда
она отклоняет кандидата (комплов меньше estimate_sb_min_comps). Дальше по коду
залипший флаг читается как «headline построил якорь» и молча глушит IMV/Yandex
blend (#651, гейт `anchor_tier is None`) и quarter-index correction (#764
Guard-1a) — притом что радиусный headline их не получал.
Замер: 154 из 996 сделок теряют tier-флаг этой правкой, и у всех 154 изменение
цены ровно 0.000% — чинится именно залипший ФЛАГ, не ценообразование (баланс
метрик бэктеста подтверждает: единственная дельта в baseline — новая канарейка
unrecorded_lookup_calls, все остальные метрики побитово те же).
- estimator.py: сброс `anchor_tier = None` единой веткой `if anchor is None`
после всех трёх гейтов (Tier C / low-conf / _compute_same_building_anchor);
display-only IMV-карточка больше не гейтится по `anchor_tier is not None`
(иначе терялась в щели «тир добыт, якорь не построен, headline подавлен»).
- backtest_estimator.py: quarter_index_lookup/quarter_indexes_lookup в реплее
отвечают «промах» (None/{}), если сброс флага открыл путь, которого не было
в замороженной фикстуре, вместо падения с RuntimeError; счётчик таких промахов
уходит в baseline как unrecorded_lookup_calls (точное целое, канарейка на
расхождение реплея с захватом). Заодно пиннится estimate_dedup_analogs_enabled
= False внутри replay_fixture (было только в самом гейте) — иначе штатная
регенерация baseline (--from-fixture --update-baseline) писала baseline,
который тест не совпадал бы никогда.
- backtest_baseline.json: перегенерирован штатным путём, unrecorded_lookup_calls=0.
Выделено из #2656/PR #2661 — фильтры свежести (scraped_at) в якоре дома и в
знаменателе коэффициента выкупа остаются в исходном PR как отдельная, более
спорная правка (двигает деньги: знаменатель просаживается на ~1.3% по бакетам).
|
||
| db4fb0a975 |
Merge pull request 'fix(tradein/estimate): нет индекса у аналогов — нет квартальной поправки (#2583 C3)' (#2836) from fix/2583-c3-quarter-index-fallback into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
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 3m49s
Deploy Trade-In / build-backend (push) Successful in 1m51s
Deploy Trade-In / deploy (push) Successful in 1m42s
Deploy Trade-In / deploy-status (push) Successful in 1s
|
|||
|
|
c66047683c |
Merge remote-tracking branch 'forgejo/main' into local/pr2836
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 4m36s
|
||
| b474a5f44e |
Merge pull request 'fix(mera/b2c): житель Серова получал «вы вне области», а подсказки игнорировали выбранный город' (#2919) from fix/mera-city-parity into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m34s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Successful in 1m50s
Deploy Trade-In / deploy-status (push) Successful in 1s
|
|||
|
|
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-фильтр БЭКЕНДОВОГО лэйна: сверку списков делает бэкендовый тест, и без этой строки правка одного лишь дропдауна её бы не запускала — то есть ровно тот путь, которым списки и разошлись. |
||
| d38e83df82 |
Merge pull request 'fix(mera/b2c): /estimate снова закрывался гвардом — короткий адрес завели в Caddy, но не в RouteGuard' (#2918) from fix/mera-estimate-guard-bypass into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m47s
Deploy Trade-In / deploy (push) Successful in 1m2s
Deploy Trade-In / deploy-status (push) Successful in 1s
|
|||
|
|
e74e9106b3 |
fix(mera/b2c): /estimate снова закрывался гвардом — короткий адрес завели в Caddy, но не в RouteGuard
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (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 / frontend-checks (pull_request) Successful in 55s
Замер на проде сразу после выкатки #2913: страница `/estimate` отдаёт 200 и выглядит правильно, но после гидратации монтируется `GuardedRoute` — 404 /trade-in/api/v1/me ×3 404 /trade-in/api/v1/trade-in/support/anon/unread плюс виджет чата поддержки из закрытого контура и экран «Не удалось проверить доступ» внизу страницы. Форма при этом переставала работать ВОВСЕ: разметка с сервера видна, поле принимает текст, а обработчики не подключены — человек печатает адрес и не получает ни подсказок, ни ошибки. Проверено, что дело не в API: ручной `fetch` из той же страницы отдаёт 200 и восемь подсказок. Причина ровно та же, что чинил PR #2910 для `/`: список публичных путей в гварде и матчер `@meraPages` в Caddyfile — одно множество, записанное в двух местах. Тогда разошлись на `/`, теперь на `/estimate`. Оба раза страница отдавала 200, ломалась только в браузере, и ни один тест этого не видел. Комментарий в шапке гварда инцидент не предотвратил (он там был), поэтому совпадение теперь запинено ТЕСТОМ: он читает Caddyfile и требует, чтобы каждый путь из `@meraPages` проходил `isPublicPath`. Сверяемся с источником, а не дублируем список третий раз. Мутация — вернуть список к состоянию, которое уехало на прод: 2 failed / 3 passed × пропускает короткие адреса публичного домена × совпадает с тем, что публичный домен реально раздаёт (Caddyfile) откат → 5 passed. Плюс vitest 56 passed, tsc, lint, isolation guard 41 файл. `Caddyfile` уже в paths-фильтре фронтового лэйна (#2913), так что правка одного лишь конфига теперь тоже гоняет этот гейт. |
||
| 51a2d7db84 |
Merge pull request 'feat(mera/b2c): публичный периметр МЕРЫ — свой API, отдельный экран оценки, короткие адреса' (#2913) from feat/mera-public-api into main
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-frontend (push) Successful in 50s
Deploy / build-backend (push) Successful in 53s
Deploy / build-worker (push) Successful in 53s
Deploy / deploy (push) Successful in 1m29s
Deploy / deploy-status (push) Successful in 2s
Deploy Trade-In / build-frontend (push) Successful in 2m40s
Deploy Trade-In / test (push) Successful in 4m0s
Deploy Trade-In / build-backend (push) Successful in 1m6s
Deploy Trade-In / deploy (push) Successful in 1m55s
Deploy Trade-In / deploy-status (push) Successful in 1s
|
|||
|
|
eb7ac3d326 |
fix(ci): гейт Caddyfile не мог прочитать конфиг — -v $PWD из job-контейнера
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) Successful in 52s
CI Trade-In / frontend-checks (pull_request) Successful in 1m32s
CI / frontend-tests (pull_request) Successful in 1m36s
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI Trade-In / backend-tests (pull_request) Successful in 5m10s
CI / backend-tests (pull_request) Successful in 16m46s
Первая версия шага смонтировала `$PWD` внутрь caddy-контейнера и упала на `open /etc/caddy/Caddyfile: no such file or directory`. Причина: job сам исполняется внутри контейнера, а `docker run` создаёт КОНТЕЙНЕР-БРАТ на том же демоне. Путь в `-v` резолвится на ХОСТЕ, тогда как `$PWD` — путь внутри job-контейнера, которого на хосте нет. Классическая ловушка docker-in-docker, и она не зависит от содержимого конфига — смонтируй так что угодно, монтирования просто не произойдёт. Заменено на `docker create -w /work` + `docker cp` + `docker start -a`: копия не зависит от того, как смонтирован workspace. Копируется и каталог `caddy/` — Caddyfile делает `import caddy/users.caddy.snippet`, без него validate падает на импорте. Проверено локально обе стороны: валидный конфиг → `Valid configuration`, exit 0; конфиг с незакрытой скобкой → `unexpected EOF`, exit 1. Без второй проверки гейт мог бы оказаться вечно-зелёным. |
||
|
|
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 → падает тест маршрутов.
|
||
|
|
208173d816 |
fix(mera/b2c): длинные адреса реально редиректят — redir без матчера не срабатывал
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
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 59s
CI Trade-In / backend-tests (pull_request) Successful in 4m38s
Проверка на живом Caddy (локальный стенд: реальный site-блок + заглушка вместо фронта) показала, что 301 не работал вовсе: длинные адреса отдавали пустой 200. Причина — разбор Caddyfile: первый аргумент директивы, начинающийся со слэша, читается как inline path-matcher. То есть `redir / permanent` означает «для пути / редиректить на permanent», а не «редиректить на /»; матчер не совпадал, директива молча не срабатывала, наружу уходило пустое тело. Пустая страница с кодом 200 хуже 404: выглядит рабочей и не ловится ни смоуком по коду ответа, ни глазом. Замер после правки (`redir * <куда>`): /trade-in/mera-public 301 → / /trade-in/mera-public/ 301 → / /trade-in/mera-public/oferta 301 → /oferta /trade-in/mera-public/v3 301 → /v3 / 200 upstream /trade-in/mera-public /estimate 200 upstream /trade-in/mera-public/estimate /trade-in/api/public/mera/coverage 200 upstream /api/public/mera/coverage /trade-in/api/v1/me 404 /trade-in/v2 404 Заодно захват сужен до `([^/].*)` — страховка от протокол-относительной цели (`//evil.example` резолвится браузером как чужой хост). Тем же замером проверено, что сегодня это недостижимо и без ограничения: Caddy нормализует путь до матчинга, и `//evil.example` / `/%2Fevil.example` приезжают одним слэшем. Ограничение оставлено как второй барьер, комментарий говорит ровно это — а не то, что чинилась живая дыра. |
||
|
|
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 |
||
| a617ec7939 |
Merge pull request 'fix(tradein): гейт номеров миграций берёт эталон из git, ручной манифест удалён' (#2786) from fix/2683-manifest-drift into main
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Successful in 46s
Deploy / build-worker (push) Successful in 53s
Deploy / build-backend (push) Successful in 54s
Deploy / deploy (push) Successful in 1m14s
Deploy / deploy-status (push) Successful in 1s
Deploy Trade-In / build-frontend (push) Successful in 2m41s
Deploy Trade-In / test (push) Successful in 4m0s
Deploy Trade-In / build-backend (push) Successful in 32s
Deploy Trade-In / deploy (push) Successful in 1m31s
Deploy Trade-In / deploy-status (push) Successful in 1s
|
|||
| 22a1b3aff4 |
Merge pull request 'feat(tradein/ops): алерты GlitchTip доезжают в Telegram' (#2915) from feat/tradein-glitchtip-telegram-alerts into main
All checks were successful
Deploy / changes (push) Successful in 8s
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 / build-worker (push) Successful in 43s
Deploy / build-frontend (push) Successful in 42s
Deploy / build-backend (push) Successful in 44s
Deploy / deploy (push) Successful in 1m11s
Deploy / deploy-status (push) Successful in 1s
Deploy Trade-In / test (push) Successful in 3m48s
Deploy Trade-In / build-backend (push) Successful in 1m26s
Deploy Trade-In / deploy (push) Successful in 1m38s
Deploy Trade-In / deploy-status (push) Successful in 1s
|
|||
| 58f04087bb |
Merge pull request 'fix(tradein/payments): тело нотификации не течёт в мониторинг и аудит, повторы банка не отбиваются лимитом' (#2794) from feat/tradein-payments-perimeter-hardening into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 19s
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 3m46s
Deploy Trade-In / build-backend (push) Successful in 1m24s
Deploy Trade-In / deploy (push) Successful in 2m4s
Deploy Trade-In / deploy-status (push) Successful in 1s
|
|||
| 677edd5f1b |
Merge pull request 'chore(ops): почта GlitchTip настраивается из окружения и на worker'е тоже' (#2914) from chore/glitchtip-email-alerts into main
All checks were successful
Deploy / changes (push) Successful in 10s
Deploy / build-backend (push) Successful in 40s
Deploy / build-frontend (push) Successful in 47s
Deploy / build-worker (push) Successful in 49s
Deploy / deploy (push) Successful in 1m20s
Deploy / deploy-status (push) Successful in 1s
|
|||
| 1fe0e90f66 |
Merge pull request 'chore(ops): зависшие job-контейнеры, лимит журнала, мёртвый блок status в Caddy' (#2912) from chore/ops-prune-logs-caddy into main
Some checks failed
Deploy / deploy-status (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 13s
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
|
|||
|
|
90133c4c2e |
Слияние main в fix/2683-manifest-drift
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Successful in 1m0s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m41s
CI / openapi-codegen-check (pull_request) Successful in 2m41s
CI Trade-In / backend-tests (pull_request) Successful in 5m31s
CI / backend-tests (pull_request) Successful in 17m12s
main принёс PR #2751 (ruff-шаг в ci-tradein.yml/backend-tests) параллельно с fetch-depth: 0 из этой ветки в том же job'е — не противоречат друг другу, слились автоматически. Единственное ручное разрешение — _manifest_applied.txt (modify/delete): main дописал файл, ветка его удаляет. Разрешение — удаление, это и есть предмет PR: гейт номеров миграций берёт эталон из git (origin/main), а не из ручного манифеста, который отставал и по построению не мог покраснеть (#2683, живой инцидент 15.08 — коллизия 264_ между двумя независимыми ветками). |
||
|
|
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 не резолвится с его стороны (общей сети не было вообще). |
||
| 48e75534bb |
Merge pull request 'chore(ci): деплой на изменение ролей, ruff-гейт для МЕРЫ, удаление мёртвого workflow' (#2751) from chore/ci-config-cleanup-v2 into main
All checks were successful
Deploy / changes (push) Successful in 8s
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 / build-backend (push) Successful in 43s
Deploy / build-worker (push) Successful in 44s
Deploy / build-frontend (push) Successful in 45s
Deploy / deploy (push) Successful in 1m8s
Deploy / deploy-status (push) Successful in 1s
Deploy Trade-In / test (push) Successful in 3m45s
Deploy Trade-In / build-backend (push) Successful in 2m3s
Deploy Trade-In / deploy (push) Successful in 2m37s
Deploy Trade-In / deploy-status (push) Successful in 2s
|
|||
|
|
db68a15cc4 |
chore(ops): почта GlitchTip настраивается из окружения и на worker'е тоже
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
CI Trade-In / backend-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 / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
EMAIL_URL был захардкожен как consolemail:// — письма печатались в stdout и никуда не уходили. Теперь адрес приходит из файла окружения на сервере (GLITCHTIP_EMAIL_URL), пароль почтового ящика в репозиторий не попадает. Главное здесь не web, а worker: письма и веб-хуки шлёт celery, а у glitchtip-worker почтовых переменных не было вовсе. Настройка одного web-сервиса не дала бы ни одного отправленного письма. Схема DSN зафиксирована комментарием: для 465 нужен smtp+ssl://, а не smtps:// — вторая помечена deprecated и включает STARTTLS, с которым Beget рвёт соединение на 465. |
||
| d0153f657b |
Merge pull request 'fix(tradein/scraper): строка с пустым сегментом самочинится при пересборе' (#2911) from fix/tradein-upsert-segment-selfheal into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (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) Has been cancelled
|
|||
| 9848f0b803 |
Merge pull request 'fix(db): /dev/shm боевого postgres — 64 МБ умолчания Docker → 1 ГБ' (#2819) from fix/2812-postgres-shm into main
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-worker (push) Successful in 52s
Deploy / build-backend (push) Successful in 53s
Deploy / build-frontend (push) Successful in 53s
Deploy / deploy (push) Successful in 1m17s
Deploy / deploy-status (push) Successful in 1s
|
|||
|
|
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 |
||
|
|
a8fa7364ae |
merge(tradein/payments): влить main в feat/tradein-payments-perimeter-hardening
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
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 4m56s
Слияние main принесло собственные Sentry-скрубберы (redact_telegram_bot_token + stabilize_retry_error_fingerprint) в app/main.py и app/scheduler_main.py — конфликт разрешён композицией, а не выбором стороны: обработчик перед отправкой в GlitchTip теперь прогоняет событие через всю цепочку в указанном порядке: scrub_payment_request_body → scrub_pii_event → redact_telegram_bot_token → stabilize_retry_error_fingerprint (main.py), и без redact_telegram_bot_token в scheduler_main.py (тот процесс не держит TelegramClient) — оба канала, before_send и before_send_transaction, используют один и тот же обработчик. tests/test_sentry_scrub.py: тесты обеих сторон объединены без потерь — PR-D2 платёжный composed-тест (body-wipe + PII-scrub + token-redaction) и весь блок RetryError fingerprint-стабилизации из main сосуществуют в одном файле. |
||
|
|
87258075a2 |
Merge main в chore/ci-config-cleanup-v2
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 52s
CI Trade-In / frontend-checks (pull_request) Successful in 1m19s
CI Trade-In / backend-tests (pull_request) Successful in 5m7s
Слить актуальный main (билд-раннер #2841/#2869, невалидные индексы #2752, честный health-check и deploy-status #2841) в ветку очистки CI. Один конфликт в .forgejo/workflows/deploy.yml: список triggers.paths — main добавил ops/docker-prune.sh (#2887), ветка добавила auth/** (RBAC roles config). Разрешено сохранением обоих путей, без потери ни одного триггера. |
||
|
|
aee0d36e7a |
fix(tradein/scraper): listing_segment самочинится при upsert вместо вечного NULL
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
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 5m1s
Раньше listing_segment писался ТОЛЬКО при INSERT в save_listings — строка,
единожды родившаяся с NULL-сегментом (классификатор не сработал на первом
скрейпе), никогда не получала сегмент на повторных сборах, даже когда он
становился определим. Симптом лечила отдельная джоба деактивации
(deactivate_stale_{cian,yandex}_null_segment, миграция 266, PR #2908) — чистит
мусор, но не саму запись.
Добавлен listing_segment = COALESCE(EXCLUDED.listing_segment,
listings.listing_segment) в ON CONFLICT DO UPDATE, и тот же идиом в
reconcile-UPDATE (dedup_hash-drift fallback path) — иначе self-heal был бы
неполным для строк, прошедших через этот путь. COALESCE, а не голый EXCLUDED,
чтобы повторный скрейп без определённого сегмента не затирал уже известное
значение пустым (тот же паттерн, что уже применён для city/kitchen/ceiling).
Тесты: tests/test_listing_segment_upsert_selfheal.py — INSERT-путь,
ON CONFLICT COALESCE (обе стороны), reconcile-UPDATE COALESCE.
|
||
| 67b98605e9 |
Merge pull request 'fix(ptica): загрузка ЦП больше не пишется в категориальную колонку (#2464-B)' (#2882) from fix/2464-eesk-load-index into main
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m35s
Deploy / build-worker (push) Successful in 3m15s
Deploy / deploy (push) Successful in 2m17s
Deploy / deploy-status (push) Successful in 2s
|
|||
|
|
09c815930a |
chore(ops): снятие зависших job-контейнеров раннера + лимит journald + мёртвый Caddy-блок
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 12s
CI Trade-In / backend-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 / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Три независимые правки инфраструктурной гигиены прод-VPS, замеренные живьём 2026-08-15 (ssh gendesign, read-only). 1. ops/docker-prune.sh: новая секция снимает зависшие (running, но брошенные) job-контейнеры Forgejo Actions раннера — три штуки Up 4-8 недель на проде, docker container prune их не видит (фильтрует только status=exited). Фильтр по имени — якорь ^FORGEJO-ACTIONS-TASK- (regex, не substring), сервисные контейнеры (forgejo/forgejo-runner*/gendesign-*/tradein-*/couchdb) под него не подпадают + explicit-skip как страховка. Возраст — из docker inspect .State.StartedAt, порог JOB_CONTAINER_MAX_AGE_HOURS (default 24 — CI job столько никогда не идёт). DRY_RUN уже существующий флаг, переиспользован. Идемпотентно на пустом списке (mapfile + `|| true`). 2. ops/journald-gendesign.conf.example: SystemMaxUse=500M для journald. Замер: journalctl --disk-usage без прав группы systemd-journal/adm недосчитывает (показал 174M) — реальный du -sh /var/log/journal = 2.5G, ~80% всех 3.1G /var/log. journald.conf на проде сейчас без лимита вообще. Установка — руками на сервере (systemd-конфиг вне /opt/gendesign, deploy.yml его не синкает): инструкция в шапке файла. 3. Caddyfile: убран мёртвый блок status.gendsgn.ru (указывал на несуществующий uptime-kuma, дёргал ACME раз в 6 часов на staging-эндпоинте). Заодно убрана dangling forward-ссылка "описан для status.gendsgn.ru ниже" в комментарии у meraocenka.ru. grep подтвердил: других ссылок на этот хост в Caddyfile/compose/скриптах не осталось (docker-compose.uptime.yml и README упоминают — вне scope, стек сам по себе не трогается). caddy validate через prod-контейнер (stdin, без записи файлов на диск) — конфиг валиден, как и baseline до правки. |
||
| 7537b54dbd |
Merge pull request 'fix(mera/b2c): публичный лэндинг больше не показывает «Не удалось проверить доступ»' (#2910) from fix/mera-public-host-bypass into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m38s
Deploy Trade-In / deploy (push) Successful in 1m12s
Deploy Trade-In / deploy-status (push) Successful in 1s
|
|||
|
|
6348f51ff4 |
fix(mera/b2c): публичный лэндинг больше не показывает «Не удалось проверить доступ»
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 54s
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
На meraocenka.ru посетитель видел внизу страницы экран проверки доступа из
закрытого контура. Замер на проде: три вызова GET /trade-in/api/v1/me → 404
плюс подтянутый следом клиент чата поддержки.
Причина. Caddy на коротком домене переписывает путь ВНУТРЕННЕ (rewrite, не
redir): посетитель остаётся на meraocenka.ru/, а Next получает
/trade-in/mera-public. Поэтому SSR видит /mera-public и bypass срабатывает, а
после гидратации usePathname() возвращает «/» — путь из адресной строки — и
bypass отваливается. Монтируется GuardedRoute, летит запрос к /me, а на этом
хосте Caddy намеренно не проксирует /trade-in/api/* → 404 → NoAccessScreen.
В шапке RouteGuard прямо стояло допущение «домен/rewrite настраиваются
отдельным PR — на поведение гварда это не влияет, он смотрит на путь». Оно
сломалось ровно в тот момент, когда домен появился; заменено разбором факта.
Правка: четыре коротких адреса публичного домена (/, /oferta, /refund,
/privacy) добавлены в bypass ТОЧНЫМ совпадением, отдельной веткой. Префиксную
проверку для «/» применять нельзя — startsWith("/") истинно для любого пути,
то есть один такой элемент сделал бы публичным всё приложение. Эти адреса
существуют только на публичном домене: на gendsgn.ru их ловит catch-all 404, а
«/» отдаёт server-side redirect на /v2 из next.config, то есть приложение по
этому пути не рендерится вовсе.
Тест на оба инварианта: короткие адреса публичны И закрытый контур (/v2,
/admin/*, /history, /scrapers/*, /login) — нет. Мутация «точное совпадение →
префиксное» роняет два теста из четырёх, проверено прогоном.
Проверено: vitest 33 passed (7 файлов), tsc --noEmit, isolation guard 31 файл.
|
||
| 12007f8615 |
feat(tradein): бесплатная проба покрытия POST /coverage (#2894) (#2909)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
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 3m41s
Deploy Trade-In / build-backend (push) Successful in 1m32s
Deploy Trade-In / deploy (push) Successful in 1m21s
Deploy Trade-In / deploy-status (push) Successful in 0s
|
|||
| 8ab720d826 |
Merge pull request 'fix(tradein/deactivate): протухшие объявления с пустым сегментом больше не якорят оценку' (#2908) from fix/tradein-deactivate-null-segment into main
Some checks failed
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m42s
Deploy Trade-In / build-backend (push) Successful in 1m15s
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been cancelled
|
|||
|
|
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, независимо от причины. |
||
|
|
e00dcac177 |
fix(tradein/deactivate): resolve migration 264 renumber collision on merge with main
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / 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 / 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
fix/tradein-ttl-effective-cap (PR #2907, merged into main while this branch was in flight) already claimed 264/265 for deactivate_stale_avito_cap_mult / deactivate_stale_yandex_cap_mult. Renumbers this branch's 264_seed_deactivate_stale_null_segment_yandex_cian.sql -> 266_seed_... (git mv + _manifest_applied.txt entry moved after 264/265 + self-references in the migration header and in test_deactivate_stale_listings.py's _MIGRATION_264 constant/test names). Merges main's bool-guard (ttl_days/cap_mult reject bool) + CAP_MULT ceiling + per-source cap_mult calibration with this branch's null_segment_only kwarg (explicit `listing_segment IS NULL` predicate, since ANY(:segments) never matches NULL) -- both features apply to the same deactivate_stale_listings() call site in product_handlers.py and the same function signature/docstring in deactivate_stale_avito.py, so every conflict was signature/docstring-level, not logic-level (git already auto-merged the function body correctly since the two features touch disjoint lines below the signature). Also reconciled the module docstring's "known gap" note (main) to reflect that the NULL-segment slice it measured (cian 211 / yandex 523 rows >60d) is now closed by this migration -- novostroyki stays open, unrelated to this branch. |
||
| 5843d63d22 |
Merge pull request 'fix(tradein/deactivate): потолок эффективного TTL — защита от разгона пола и опечатки в расписании' (#2907) from fix/tradein-ttl-effective-cap into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
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 3m41s
Deploy Trade-In / build-backend (push) Successful in 1m30s
Deploy Trade-In / deploy (push) Successful in 1m23s
Deploy Trade-In / deploy-status (push) Successful in 1s
|
|||
|
|
08cff706ec |
docs(tradein/deactivate): убрать неверное число из обоснования потолка
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / 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 / 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 4m33s
В шапке модуля, в комментарии миграции 264 и в докстринге теста стояло «23 687 из 44 744 avito-объявлений не подтверждались >7 суток» под заголовком «ЗАМЕР НА ПРОДЕ». Число реальное, но приписано не тому. Перепроверено запросом 2026-08-15: это ВСЕ источники вместе, и две трети — новостройки, которых оценщик не берёт (он фильтрует listing_segment IS NULL OR = 'vtorichka'). У самого avito просроченных строк ноль: 8 663 активных, максимальный возраст 10 суток. Оставлять это в коде нельзя: следующий человек прочитает «avito раздут вдвое», проверит и не найдёт — а заодно потеряет доверие к остальным числам в том же абзаце, которые верны и сверены с scrape_runs.counters. Заодно явно записано, чего потолок НЕ делает: он не сжимает пул (0 деактиваций замерено на всех четырёх джобах), а защищает от разгона пола и от опечатки в расписании. Настоящий раздутый срез — строки с пустым сегментом, они чинятся отдельной джобой. |
||
|
|
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. |
||
|
|
cfb4c159ab |
fix(tradein/scraper): deactivate stale yandex/cian listings with NULL segment
544 yandex + 224 cian active rows carry listing_segment=NULL (legacy rows predating migration 011, plus a small trickle that can never self-heal since the upsert ON CONFLICT never rewrites listing_segment on re-scrape). 94-97% of them are frozen at ~86 days old, yet the estimator's Tier A (same-building) and Tier C (micro-radius) anchor queries filter only is_active=true -- no freshness column -- so these stale asking prices anchor live valuations. deactivate_stale_yandex/_cian (migration 115) already run at TTL=30 but scope segments=['vtorichka'] only: `= ANY(CAST(:segments AS text[]))` never matches NULL, so the NULL bucket was invisible to both existing jobs and to avito/domklik/n1 (which are source-blanket or vtorichka-only respectively). Adds null_segment_only kwarg to deactivate_stale_listings() building an explicit `listing_segment IS NULL` predicate (confirmations/revisit-floor builders extended in parallel for correctness, though both gates are kept off for this slice -- population too small for thresholds calibrated on a full vtorichka sweep, would permanently skip as unhealthy). Two new scrape_schedules rows (migration 264) run it per source, untouched novostroyki/vtorichka jobs unaffected. TTL=60d (vs 30d for vtorichka): revisit-floor is not computable here (rows out of dedicated sweep scope have no revisit-gap history), so the margin is folded into the TTL directly -- 2.26x/1.4x over the measured p99 revisit gaps already on file for cian/yandex vtorichka (26.6d/43.0d). Bimodal age distribution means this costs almost nothing in coverage (30d vs 60d: 744 vs 734 deactivated). First run: 734 of 768 NULL rows deactivated (211 cian, 523 yandex), 34 remain (fresher than 60d, still incidentally re-touched). Comparable pool (is_active AND segment IN (NULL, vtorichka)) after: cian -2.7% (7739->7528), yandex -10.2% (5133->4610) -- yandex crosses the 10% flag threshold. All 734 removed rows already had scraped_at frozen >60d, i.e. already excluded from Tier S/H (which do filter freshness, 14-60d window) -- the drop is real for is_active headcount but zero-impact there; it only prunes Tier A/C, where it removes stale prices rather than live comps. Separate finding (not fixed here): base.py's ON CONFLICT DO UPDATE omits listing_segment from SET entirely, so a legacy NULL row can never heal even though cian/yandex SERP always compute segment deterministically on re-scrape. |
||
|
|
19c9da8119 |
fix(tradein/deactivate): bool guard hole + unpinned test + yandex cap_mult gap (TTL-CAP round 3)
Три остатка ревью TTL-CAP: 1. cap_mult < 1 пропускал bool: jsonb true -> True < 1 ложно -> потолок = ttl_days*True = ttl_days -> пол молча отключается без ValueError. Тот же класс дыры возможен и через ttl_days=true (TTL молча = 1). Оба параметра теперь явно отклоняют bool ДО числового сравнения; воспроизведено на HEAD и закрыто тестами (True/False на обоих параметрах). 2. test_avito_prod_floor_is_capped_by_calibrated_cap_mult хардкодил cap_mult=6 как вход -- мутация миграции 264 (6 -> 2) оставляла набор зелёным. Тест теперь читает cap_mult ИЗ ФАЙЛА миграции regex'ом, ожидаемый результат (потолок 60) остаётся зафиксированным числом -- дрейф калибровки в SQL теперь ломает тест. 3. Текст миграции 264 утверждал "yandex 43.0 -> потолок 60, запас есть" по статическому p99. Живые полы из scrape_runs.counters (08-10..08-15: 75/75/75/39/52/54) и live-замер сегодня (79.2, n=1961) выше потолка 60 -- тот же false-kill класс, что у avito. Откалибровал yandex отдельной миграцией 265 (cap_mult=3 -> потолок 90, тот же запас ~14%, что у avito), поправил таблицу в 264 на живые числа и пиннящий тест по образцу avito. Численный эффект (live-замер 2026-08-15, до и после): next-run deactivated=0 на всех четырёх джобах что до, что после -- ветка по-прежнему НЕ сжимает пул (avito/cian: живой пол уже ниже потолка, cap не участвует; yandex: 0 активных строк старше 39 суток вообще, калибровка убирает будущий риск, не текущее число; domklik: блокирован гейтом здоровья, confirmations 94 < 200). Ветка остаётся тем, чем и была: защита от опечатки в расписании + калибровка, не сжатие пула. 4508 backend-тестов зелёные (uv run pytest tests/), ruff чист на изменённых файлах. |
||
|
|
8fff174337 | Merge remote-tracking branch 'forgejo/main' into fix/tradein-ttl-effective-cap | ||
| 7def4973bd |
Merge pull request 'fix(observability): убрать 83% мусора из трекера ошибок' (#2906) from fix/tradein-glitchtip-noise into main
All checks were successful
Deploy / changes (push) Successful in 10s
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy / build-backend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Has been skipped
Deploy / deploy (push) Successful in 1m7s
Deploy / deploy-status (push) Successful in 1s
Deploy Trade-In / test (push) Successful in 3m41s
Deploy Trade-In / build-backend (push) Successful in 1m17s
Deploy Trade-In / deploy (push) Successful in 6m53s
Deploy Trade-In / deploy-status (push) Successful in 1s
|