Commit graph

2751 commits

Author SHA1 Message Date
bot-backend
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
2026-08-16 09:28:41 +03:00
bot-backend
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
2026-08-16 09:27:04 +03:00
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
2026-08-15 20:04:16 +00:00
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
2026-08-15 19:56:40 +00:00
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
2026-08-15 19:48:18 +00:00
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
2026-08-15 19:47:36 +00:00
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
2026-08-15 19:45:46 +00:00
bot-backend
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_ между двумя независимыми ветками).
2026-08-15 22:44:16 +03:00
bot-backend
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 не резолвится с его стороны (общей сети не было вообще).
2026-08-15 22:39:44 +03:00
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
2026-08-15 19:39:05 +00:00
bot-backend
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.
2026-08-15 22:38:53 +03:00
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
2026-08-15 19:37:16 +00:00
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
2026-08-15 19:34:34 +00:00
bot-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
2026-08-15 22:32:53 +03:00
bot-backend
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 сосуществуют в одном файле.
2026-08-15 22:32:28 +03:00
bot-backend
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).
Разрешено сохранением обоих путей, без потери ни одного триггера.
2026-08-15 22:30:47 +03:00
bot-backend
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.
2026-08-15 22:28:05 +03:00
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
2026-08-15 19:27:50 +00:00
bot-backend
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 до правки.
2026-08-15 22:27:48 +03:00
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
2026-08-15 19:17:09 +00:00
bot-backend
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 файл.
2026-08-15 22:06:57 +03:00
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
2026-08-15 18:28:08 +00:00
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
2026-08-15 18:22:12 +00:00
bot-backend
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, независимо от причины.
2026-08-15 21:20:43 +03:00
bot-backend
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.
2026-08-15 21:13:41 +03:00
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
2026-08-15 18:06:55 +00:00
bot-backend
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 деактиваций
замерено на всех четырёх джобах), а защищает от разгона пола и от опечатки в
расписании. Настоящий раздутый срез — строки с пустым сегментом, они чинятся
отдельной джобой.
2026-08-15 21:00:42 +03:00
bot-backend
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.
2026-08-15 20:47:23 +03:00
bot-backend
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.
2026-08-15 20:44:36 +03:00
bot-backend
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 чист на изменённых
файлах.
2026-08-15 20:42:00 +03:00
bot-backend
8fff174337 Merge remote-tracking branch 'forgejo/main' into fix/tradein-ttl-effective-cap 2026-08-15 20:26:40 +03:00
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
2026-08-15 17:24:02 +00:00
bot-backend
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.
2026-08-15 20:16:11 +03:00
bot-backend
349494a9df fix(tradein/observability): close RetryError-half of GlitchTip noise fix (round 2)
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 Trade-In / backend-tests (pull_request) Successful in 4m30s
CI / openapi-codegen-check (pull_request) Has been skipped
Ревью round 1 подтвердил basic_auth-часть, но нашёл 4 факта в RetryError-части:

1. reraise=True в geocoder.py не убирает шум, а переименовывает: наружу летит
   httpx.HTTPStatusError, чей str() содержит ПОЛНЫЙ request URL с query string
   (`for url '...search?q=<адрес>&...'`) — воспроизведено эмпирически. Тот же
   per-address issue-explosion, просто под другим типом исключения.
   Фикс: _HTTPX_ERROR_URL_QUERY_RE в sentry_scrub.scrub_pii_event режет query
   string из httpx-style "for url '...'" сообщений — стабилизирует ТЕКСТ, не
   только тип, независимо от того, уважает ли GlitchTip fingerprint-поле.

2. stabilize_retry_error_fingerprint затирал fingerprint целиком по (типу
   причины) — RetryError из НЕСВЯЗАННЫХ подсистем с одинаковым типом причины
   схлопнулись бы в один issue (geocoder vs scraper_kit оба ловят httpx-типы).
   Фикс: culprit = event["logger"] (LoggingIntegration ставит его = имя
   модуля-источника logger.exception) идёт первым компонентом fingerprint —
   разные подсистемы больше не сливаются.

3. Второй живой источник RetryError, пропущенный round 1 (грепали литерал
   "RetryError", не producers): BaseScraper._http_get в packages/scraper-kit —
   @retry БЕЗ reraise=True, живой путь через YandexDetailScraper.fetch_detail
   (yandex/serp.py и valuation.py переопределяют _http_get без retry — не
   затронуты). Оставлен на fingerprint-хук намеренно: detail-URL варьируются
   в ПУТИ (offer id), не в query — _HTTPX_ERROR_URL_QUERY_RE их не покрывает,
   а добавление reraise=True туда воспроизвело бы ту же проблему через
   HTTPStatusError с variable path вместо query.

4. type(exc).__name__ == "RetryError" (string-compare) → isinstance(exc,
   RetryError) с прямым импортом tenacity.RetryError — не матчит посторонние
   классы с тем же __name__, не промахивается мимо подклассов.

Полный backend suite (4479 passed, 21 skipped) + geocoder/scheduler/alerts
подмножества — без регрессий (reraise=True уже влит в main).

Не тронуто (вне scope round 2, подтверждено ревьюером как верное):
ops/glitchtip-auth-forwarder/* (basic_auth 401 дроп), массовая чистка
накопленных issue.
2026-08-15 20:02:44 +03:00
ce9353fd48 feat(mera/b2c): лэндинг v3 по макету — превью на /mera-public/v3 (#2905)
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 2m28s
Deploy Trade-In / deploy (push) Successful in 1m4s
Deploy Trade-In / deploy-status (push) Successful in 1s
2026-08-15 16:58:27 +00:00
bot-backend
772ae116b5 fix(tradein/deactivate): validate cap_mult, calibrate avito, document scope gap
Round-2 review (MAJOR) left three items open:

1. cap_mult was threaded through as a jsonb default_params parameter but never
   validated, reproducing the exact ttl_days<=0 hole the earlier guard closed.
   Verified live: cap_mult=0 -> effective_ttl=0 -> whole active pool of the
   source would deactivate; cap_mult=0.5 pushes the ceiling BELOW the operator-
   configured ttl_days. Added `if cap_mult < 1: raise ValueError` next to the
   ttl_days guard (same fail-fast contract, before any SQL). Non-numeric values
   (e.g. a stringly-typed "6" from a typo in default_params) already fail safe
   via TypeError on the comparison, caught by the same except-block -> mark_failed.
   Covered with 5 new tests (zero/negative/<1/non-numeric/mark_failed routing).

2. The mechanical part of cap_mult (parameter + wiring) was merged but never
   calibrated for avito on prod -- no migration shipped, so prod default_params
   for deactivate_stale_avito still lacked "cap_mult" and ran with the module
   default (CAP_MULT=2, ceiling=20d), which is BELOW avito's own p99 revisit gap
   (42.1d) and below the observed prod peak (floor=52, three runs 08-10..08-12).
   Added data/sql/264_deactivate_stale_avito_cap_mult.sql (idempotent, same
   pattern as 219) setting cap_mult=6 for deactivate_stale_avito only (ceiling
   60d, matching the order of magnitude already used for cian/yandex). cian/
   yandex/domklik keep the CAP_MULT=2 default -- their p99 gaps (26.6/43.0/3.1)
   sit comfortably under their default ceilings (60/60/28), no override needed.
   Pinned the calibration with a dedicated test
   (test_avito_prod_floor_is_capped_by_calibrated_cap_mult) instead of leaving
   the avito slice skipped in the false-kill coverage test.

3. Confirmed (SSH read-only, prod counts): active rows aged >60d that this PR
   cannot touch regardless of cap_mult -- cian/novostroyki 9483, cian/NULL
   211, yandex/NULL 523 (0 inside the jobs' actual scope: cian/vtorichka,
   yandex/vtorichka). deactivate_stale_cian/_yandex are scoped to
   segments=['vtorichka'] by a deliberate, documented DECISION (blanket TTL on
   novostroyki risks killing live inventory cian/yandex don't fully sweep).
   Widening that scope is a separate, riskier investigation and is out of
   scope here -- documented the gap directly in the module docstring next to
   the existing DECISION so it isn't lost.

Verification (SSH read-only against prod, 2026-08-15): recomputed the exact
per-source formula the next scheduled run will use. In-scope next-run
deactivation is currently 0 for all four sources -- the active pool has
already self-corrected to be consistent with each source's own recent
effective TTL (yesterday's yandex run used effective=54, so no active row is
older than that yet). This matches the round-2 reviewer's own conclusion: the
cap is a preventative guardrail, not a retroactive cleanup, and isn't expected
to fire on the exact day it's calibrated. It is not idle, though -- live
recompute of yandex/vtorichka's raw (uncapped) floor right now is 78.2d,
already above its 60d ceiling; the trailing 6-day counters show the identical
loop (floor=75, deactivated=0, three days straight) already recurred twice
without this cap in place. The mechanism will bind the moment the pool ages
past the ceiling, which is exactly the recurrence it exists to stop.

Tests: 106 passed (test_deactivate_stale_ttl_cap.py,
test_deactivate_stale_revisit_floor.py, test_deactivate_stale_health_gate.py,
test_deactivate_stale_listings.py, test_migrations_manifest.py). ruff clean.
scripts/check-migration-lock-timeout.py: pass (UPDATE-only migration, no
blocking DDL, no SET LOCAL needed).
2026-08-15 19:58:19 +03:00
bot-backend
662720e1c8 Merge remote-tracking branch 'forgejo/main' into _cont-tradein-glitchtip-noise 2026-08-15 19:43:34 +03:00
bot-backend
bef2a05f60 Merge remote-tracking branch 'forgejo/main' into local/fix-ttl-effective-cap-review2 2026-08-15 19:43:03 +03:00
1a391caae2 Merge pull request 'fix(health): HEAD на /health в обоих бэкендах — аптайм проверял то, что всегда отвечает 405' (#2893) from fix/tradein-uptime-honest-green into main
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
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 / build-backend (push) Successful in 3m4s
Deploy Trade-In / test (push) Successful in 3m51s
Deploy / build-worker (push) Successful in 5m3s
Deploy Trade-In / build-backend (push) Successful in 1m9s
Deploy / deploy (push) Successful in 1m31s
Deploy / deploy-status (push) Successful in 1s
Deploy Trade-In / deploy (push) Successful in 8m1s
Deploy Trade-In / deploy-status (push) Successful in 1s
2026-08-15 16:40:15 +00:00
bot-backend
8f954fb98c feat(mera/b2c): лэндинг v3 по макету — превью на /mera-public/v3
All checks were successful
CI Trade-In / changes (pull_request) Successful in 16s
CI / changes (pull_request) Successful in 14s
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 1m13s
Дизайн-макет владельца (15.08.2026). Собрано рядом с живым лэндингом, а не
вместо него: /mera-public открыт на meraocenka.ru, менять его посреди
согласования дизайна нельзя.

- b2c-tokens.ts — палитра макета отдельно от v2/tokens.ts. Красить теми же
  токенами B2B-экран нельзя, это другой продукт; hex-литералы остаются в
  одном файле.
- marketing-v3.ts — числа из макета в ОДНОМ месте, с таблицей расхождений
  с нашими замерами и гейтом: заменить вычисляемыми до снятия noindex.
- _components/v3/ — шапка с селектором города, подвал, липкая CTA, герой
  с карточкой проверки, три шага, «Точность», «Цена ошибки».
- layout.tsx больше не рендерит шапку/подвал: вложенный layout в Next
  ДОБАВЛЯЕТСЯ к родительскому, поэтому v3 со своим хромом получал бы две
  шапки. Теперь хром выбирает страница — v1 берёт SiteHeader/SiteFooter,
  v3 свой.

Отступления от макета (все задокументированы в коде):
- реквизиты и документы берутся из LEGAL_ENTITY, а не из маски
  «ООО «МЕРА» · ИНН 66700XXXXX» в макете;
- в подвал добавлены Политика возврата и почта поддержки, которых в макете
  нет, — обязательные элементы платного контура;
- бейджи МИР/СБП/VISA не рендерятся: приём платежей не подключён;
- форма не выдаёт результат — анонимного расчёта нет, а одинаковые
  «14 объявлений» на любой адрес это обман конкретного человека, а не
  продающая формулировка. Ветка результата свёрстана и ждёт /coverage;
- замыленная рублёвая цифра не перенесена: в макете она лежит в разметке
  текстом под filter:blur и достаётся из исходника;
- акцент #16A9BC не используется как цвет текста (2,6:1 на фоне страницы) —
  для текста затемнённый #0B6B79, 5,8:1;
- города и объёмы в селекторе — из нашего реестра, а не 42 города РФ.

Секции макета, требующие отсутствующих данных и контуров (лента
прогноз→факт, игра, продажа под ключ, статьи, модалка, оплата), не
верстались — заведены задачами #2894-#2904.

Проверено: tsc --noEmit, next lint, isolation guard (31 файл),
next build — /mera-public/v3 пререндерится статикой.
2026-08-15 19:24:32 +03:00
bot-backend
cb0f42d1b1 fix(health): не тащить HEAD-пробу в OpenAPI-схему
All checks were successful
CI Trade-In / changes (pull_request) Successful in 17s
CI / changes (pull_request) Successful in 16s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m53s
CI Trade-In / backend-tests (pull_request) Successful in 5m16s
CI / backend-tests (pull_request) Successful in 17m1s
Джоба openapi-codegen-check покраснела на этой ветке: она дампит app.openapi(),
регенерирует frontend/src/types/api-types.ts и падает на расхождении. Добавленный
HEAD /health попал в схему и потребовал правки сгенерированного файла.

Регенерировать типы ради маршрута, который фронт никогда не вызывает, — лишний
шум в generated-коде. HEAD-проба это инфраструктура для uptime-монитора, а не
часть контракта, по которому фронт строит типы, поэтому include_in_schema=False
здесь и по смыслу верно, а не только удобно.

Флаг ставим в обоих бэкендах симметрично: у trade-in codegen-джобы пока нет, но
расхождение схем между двумя бэкендами потом само станет источником вопросов.
2026-08-15 19:22:22 +03:00
85414dabd2 Merge pull request 'fix(tradein/scrapers): прогон больше не рапортует «done» поверх провала и нуля' (#2892) from fix/tradein-honest-run-status into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-frontend (push) Successful in 3m0s
Deploy Trade-In / test (push) Successful in 4m22s
Deploy Trade-In / build-browser (push) Successful in 4m50s
Deploy Trade-In / build-backend (push) Successful in 1m51s
Deploy Trade-In / deploy (push) Successful in 2m40s
Deploy Trade-In / deploy-status (push) Successful in 1s
2026-08-15 16:20:30 +00:00
8c7d2d4d10 Merge pull request 'fix(tradein/geocoder): фолбэк на локальные houses — часть адресов ЕКБ давала гарантированный ноль аналогов' (#2891) from fix/tradein-geocoder-local-house-fallback into main
Some checks failed
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 / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-08-15 16:20:24 +00:00
e9abf1cd31 Merge pull request 'chore(ci): не проглатывать падение сборки + починить самолечение buildcache' (#2890) from chore/ci-gates-runner-and-deploy into main
Some checks failed
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-status (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
Deploy / changes (push) Successful in 11s
Deploy / build-frontend (push) Successful in 48s
Deploy / build-worker (push) Successful in 50s
Deploy / build-backend (push) Successful in 51s
Deploy / deploy (push) Successful in 1m31s
Deploy / deploy-status (push) Successful in 2s
2026-08-15 16:20:19 +00:00
bot-backend
fa84705ec7 fix(tradein/geocoder): stop apt number leaking into house + houses bbox/sibling guard
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 5m0s
Review round 2 on #2626 (local houses fallback) found two HIGH-severity bugs
verified live against prod data:

1. _extract_local_house_token took the LAST digit-like token in the raw
   address, so "...Педагогическая, д 15, кв 11" resolved house=11 (apartment
   number) instead of 15 -- confidently returning a stranger's building with
   confidence='exact', written to geocode_cache. Fixed by stripping the
   apartment/office/floor/entrance tail (кв/оф/пом/подъезд/этаж -- NOT
   корп/к, which is part of the house number) before extracting the token.
   Fixes the exact prod case from the review plus the corpus+apartment
   combo ("д 26 к 1, кв 41" -> 26к1, not 41).

2. houses is not an EKB-only table (21% of rows with coords are outside the
   metro, some as far as another city) -- "улица Маяковского, 7" in houses
   resolves to Серов, not Екатеринбург, and use_local_ekb only gates the
   user's query text, not the source row. Added an is_within_ekb_bbox_wide
   check on every candidate row before it can become a match.

Also addressed two MEDIUM findings from the same review:

3. The "<номер> -> <номер>к1" corpus guess only checked uniqueness among
   к1-labelled rows, so real multi-building addresses (Онуфриева 24: к1/к2/к3,
   250-400m apart) resolved confidently to к1 anyway. Guess is now skipped
   when any other corpus/slash variant of the same base number exists among
   the street's candidates.

4. Houses-fallback results are no longer cached in geocode_cache -- the
   source (scraped listings) is less reliable than geoportal/cadastral/
   Nominatim, and the lookup is cheap/local, so caching only extended the
   lifetime of a possible bad match. Side benefit: address_refined now
   survives every repeat request of the same raw address, not just the
   first.

Also added ORDER BY address, id to the underlying query so the coordinate
dedup picks a deterministic row (LOW finding #5).

14 new/updated tests in test_geocoder_local_houses_fallback.py cover all
five findings against real prod address/houses-row fixtures. Full geocoder
+ dadata + estimator/pdf regression suite (402 tests) green.
2026-08-15 18:50:17 +03:00
bot-backend
e9ca744e85 fix(tradein/scrapers): не путать rows_inserted/processed с честным результатным ключом
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / 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 5m4s
Ревью честного run-status нашло, что _RESULT_COUNTER_KEYS ловил не только целевой
yandex_newbuilding_sweep, но и rosreestr_dkp_import (rows_inserted, 66 из 67 прод-
прогонов = здоровый ноль догнавшего инкрементального импорта) и newbuilding_enrich
(processed — счётчик попыток, ==limit даже при частичном провале). Первое завело бы
практически непрерываемый ложный zero-стрик у здорового источника, второе маскировало
бы реальные отказы под measured-N.

Проверено по прод-БД (2026-08-15): "succeeded" пишут ТОЛЬКО yandex_newbuilding_sweep
(42 прогона/90д) и newbuilding_enrich (65/90д) — ни разу rosreestr_dkp_import; у
yandex_newbuilding_sweep succeeded численно совпадает с rows_inserted на всех 42/42
прогонах. Заменил "rows_inserted"+"processed" на "succeeded" в _RESULT_COUNTER_KEYS
(app-копия и byte-эквивалентная kit-копия) — цель (b) исходной правки сохранена, ложный
стрик у rosreestr_dkp_import снят, попутно newbuilding_enrich получает честное
измерение вместо счётчика попыток.

Также поправлены докстринги test_backfill_honest_status.py — два кейса (76%/72%
отказов -> 'done') проверяют только выбор финализатора mark_backfill_finished
(mark_done там замокан); реальный mark_done с honest-run-status переквалифицирует их
в 'failed' через _failed_ratio_too_high — это не документировалось явно.
2026-08-15 18:49:37 +03:00
bot-backend
cb79c67bfc fix(tradein/deactivate): make TTL-cap multiplier configurable per source
Review of 3a1e29a7 found CAP_MULT=2 is uniform across sources with wildly
different ttl_days, so it produces a different ABSOLUTE ceiling per source:
cian/yandex (ttl=30) -> 60d, avito (ttl=10) -> 20d, domklik (ttl=14) -> 28d.
That breaks exactly where the crawl's revisit tail doesn't scale with
ttl_days: avito's measured p99 revisit gap is 42.1d (_REVISIT_TAIL) --
above its own default cap of 20d -- so a legitimately slow-but-alive avito
crawl cycle would get its floor cut below the very tail the floor exists
to protect (the false-kill scenario #2659 was filed for). cian/yandex/
domklik aren't affected: their default ceilings (60/60/28) already sit
comfortably above their own measured tails (26.6/43.0/3.1).

Fix: cap_mult is now a function parameter (same pattern as
revisit_floor_quantile/min_confirmations) with the module constant CAP_MULT
as its default, wired through product_handlers via default_params["cap_mult"]
so a schedule can override it without touching the shared default. Also
closes the ttl_days<=0 edge case flagged in the same review: before the cap,
max(ttl_days, floor) tolerated a misconfigured ttl_days<=0 as long as the
floor was positive; with the cap, min(floor, ttl_days*cap_mult<=0) would
silently defeat that protection and match nearly the whole active pool.
ttl_days<=0 now raises ValueError before any SQL, same contract as the
existing staleness_column whitelist check.

Also verified (read-only, postgres-tradein) the review's core "no-op"
claim: false. scrape_runs.counters for the 6 days since the revisit-floor
went live (08-10..08-15) show the cap DID bind on 3 of 6 runs for avito
(floor 52 vs cap 20) and 3 of 6 for yandex (floor 75 vs cap 60) -- the
reviewer's "no source hits the cap" read a single-day trough right after
a natural recovery, not the whole observation window. See PR discussion
for the full counter history and refutation detail.

Refs #2659
2026-08-15 18:47:49 +03:00
bot-backend
8bce8cf5ae fix(ci): fail-safe registry verification + real cache self-heal + honest health-check (#2841 R2)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
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 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
Ревью R2 нашёл, что вся безопасность предыдущего фикса держалась на
недоказанной поддержке act_runner'ом steps.<id>.outcome: если раннер его
не заполняет, retry-шаг молча не бежит, continue-on-error проглатывает
падение сборки, job зелёный — а деплой тянет старый :latest на прод.

- Добавлен engine-agnostic verify-шаг после каждого retry (6 мест,
  deploy.yml + deploy-tradein.yml): `docker buildx imagetools inspect
  <image>:<sha>` без continue-on-error. Не зависит от того, поддерживает
  ли раннер outcome — проверяет реальное состояние registry напрямую.
  Если ни build, ни retry реально не запушили образ — шаг падает и job
  честно FAILURE независимо от семантики outcome.
- Вернул `cache-to` в retry-шаги (6 мест): без него битый buildcache-тег
  никогда не перезаписывался — retry всегда собирал без cache-to, значит
  cache-to не выполнялся НИКОГДА, и каждый следующий прогон снова падал
  на том же cache-from. Заявленное самолечение не работало ни разу.
- Health-check в deploy.yml (main-стек) под `set -e` не мог упасть:
  `curl ... && break` — curl не последняя команда &&-списка, POSIX
  освобождает такие команды от errexit, цикл дохаживал до sleep (exit 0)
  даже если curl ни разу не отдал 200. Приведено к паттерну
  deploy-tradein.yml: явный флаг healthy + `exit 1` после цикла.
  Подтверждено локальным bash-репро (mock curl, всегда failure): старая
  версия — exit 0, новая — exit 1; позитивный сценарий не сломан.

docker rm -f без -v в SSH-скриптах деплоя не тронут.
2026-08-15 18:44:48 +03:00
bot-backend
3f5f099392 fix(health): HEAD /health на верном бэкенде (Site Finder) + честные заголовки
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
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 / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Failing after 2m34s
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
CI / backend-tests (pull_request) Successful in 16m49s
Review-разбор ветки fix/tradein-uptime-honest-green:

1. [HIGH] Прод-симптом `HEAD gendsgn.ru/health -> 405` обслуживает Site
   Finder (Caddyfile:60 `handle /health { reverse_proxy backend:8000 }`),
   а предыдущий коммит правил только tradein-mvp/backend, чей /health наружу
   не проксируется вообще. Добавлен @app.head("/health") в backend/app/main.py
   рядом с существующим @app.get — эмпирически подтверждено (uv run pytest):
   HEAD было 405, стало 200. tradein-mvp фикс не откачен (безвреден, годится
   для будущего internal-caller), но обвязан комментарием, что реальный
   прод-путь чинится не там.

2. [LOW] Response(status_code=200) без media_type отдавал HEAD без
   Content-Type, тогда как GET отдаёт application/json — расходится с
   заявленным в комментарии RFC 9110 §9.3.2. Добавлен media_type в обоих
   бэкендах; Content-Length сознательно не подгоняем под байты GET-ответа
   (payload header field, RFC разрешает опускать для HEAD) — не дублируем
   сборку payload ради байт-в-байт соответствия.

Тесты: test_health_head_ok_no_body добавлен в backend/tests/test_health.py
(Site Finder) — RED-check (git stash app/main.py) воспроизводит прод-баг
1:1: assert 405 == 200. tradein-mvp/backend/tests/test_health_endpoint.py
дополнен проверкой Content-Type. uv run pytest — все зелёные.
2026-08-15 18:44:31 +03:00
bot-backend
c75206c348 fix(tradein/geocoder): local houses fallback + downgrade DaData CLEAN-disabled noise
28/1084 прод-оценок имели lat IS NULL — гарантированный ноль аналогов, клиент
не получал оценку вовсе. Дом уже был в houses (скрейпленные листинги), но не
резолвился ни geoportal/cad_buildings, ни Nominatim: разговорное/усечённое имя
улицы («Онуфриева» вместо ГАР-каноничного «Начдива Онуфриева») или отсутствующий
в вводе корпус («49» вместо реального «49к1»). Добавлен последний тир geocode()
с двумя defensive-допущениями (суффиксный матч улицы + опциональная догадка
«номер+к1») — при любой неоднозначности возвращает None, а не гадает; проверено
живыми прод-адресами (Онуфриева/Хрустальногорская резолвятся, Крестинского
корректно остаётся неоднозначным — два разных дома в houses под одним номером).

Отдельно: HTTP 403 «услуга CLEAN выключена на аккаунте» логировался как ERROR
на каждый /estimate (164 события) — это статичная конфигурация аккаунта, а не
сбой; понижено до WARNING (первый раз за процесс) + DEBUG на повторы, чтобы
ERROR продолжал значить настоящую проблему.
2026-08-15 18:08:09 +03:00