H1 (critical): reorder send_support_message — get_or_create_thread now runs
AFTER a successful Telegram send, not before. Prod runs a single uvicorn
process with no --workers on a sync SQLAlchemy engine sharing one event
loop; holding a row-lock across the Telegram call (which can legally take
minutes on 429/5xx worker-grade retries) risked freezing the entire API on
a second concurrent request from the same user. send_message now also
accepts explicit timeout/max_retries so the interactive endpoint uses a
bounded budget instead of inheriting the long-polling worker's retry policy.
M1: web_support_messages and tg_support_messages both gain a support_chat_id
column (188 migration for the already-applied tg_support_messages table;
187 edited in place since it hasn't shipped yet). bridge._handle_group_reply
now resolves BOTH the Telegram and web candidate under the CURRENT
support_chat_id and refuses delivery loudly (logger.error) if both match,
instead of silently preferring the Telegram path — closing a cross-table
misroute risk that would surface if the support group is ever recreated.
M2: a media reply to a web-mirrored message (including photos with a
caption) is now refused in full with an explicit notice back in the topic,
instead of silently delivering just the caption text or logging a WARNING
nobody sees.
M3: list_support_messages/get_support_unread/mark_support_read switched
from async def to def — their bodies are pure sync psycopg calls; running
them as async def executed blocking DB work directly on the event loop.
M5: list_messages now takes a bounded LIMIT (last N, chronological) so a
long-lived thread doesn't return its entire history on every poll.
L1-L4: warn when Telegram doesn't return a message_id (routing dead-end),
rate limit is peeked before send and only recorded on success (failed
attempts no longer burn the budget), SlidingWindowLimiter gained the same
empty-bucket cleanup as RateLimitMiddleware, and _require_username now
strips whitespace so a proxy-injected space can't fork a second thread.
L5: removed 187 from _manifest_applied.txt — the deploy pipeline tracks
applied migrations via _schema_migrations, not this file, and keeping an
unmerged migration name out of it preserves the option to rename before
merge without tripping the "can't rename applied migrations" test.
Сайт закрыт Caddy basic_auth, тред привязывается к X-Authenticated-User (нет
анонимов). Новые web_support_threads/web_support_messages (миграция 187) —
отдельно от tg_support_* (186): у веб-клиента нет Telegram chat_id, смешение
identity-схем в одной таблице потребовало бы NULLABLE chat_id/username и XOR
CHECK-ограничений без реальной выгоды (обоснование в самой миграции).
API (app/api/v1/support.py, /api/v1/trade-in/support/*):
POST /messages — отправка (sendMessage-зеркало в топик, "[С САЙТА] user: ...")
GET /messages — polling своего треда (?since=id)
GET /unread — счётчик непрочитанного
POST /read — отметить прочитанным
Тред резолвится ИСКЛЮЧИТЕЛЬНО по username — нет параметра, которым можно
адресовать чужой тред (структурная защита от IDOR, не только access-check).
bridge.py: _handle_group_reply получил ветку резолва reply в web-тред (после
существующего tg-резолва, без изменения Telegram-пути) — оператор отвечает
одинаково, вне зависимости от канала клиента.
Rate-limit: новый SlidingWindowLimiter (ratelimit.py) — 12 msg/60s per user,
жёстче общего RateLimitMiddleware (общий бот-токен, флуд одного клиента иначе
бьёт по доставке всем).
Security: этот процесс (app/main.py) теперь тоже зовёт Telegram Bot API
напрямую (раньше — только изолированный tgbot_main.py) — реплицированы обе
защиты токена: httpx-INFO подавлен, include_local_variables=False +
redact_telegram_bot_token в sentry before_send.
Бот не сконфигурирован (пустой TELEGRAM_BOT_TOKEN/chat_id) → 503, не 500.
Клиент пишет боту в личку → воркер зеркалит сообщение через copyMessage
в топик супергруппы-форума → оператор отвечает реплаем на зеркало → бот
доставляет ответ клиенту. Полный лог переписки в Postgres.
Отдельный контейнер на long-polling, а не webhook в tradein-backend:
не нужно пробивать дырку в auth-middleware (_PUBLIC_PATHS, #2213) и
маршрут в Caddy, нулевая внешняя поверхность, падение бота не задевает API.
Без aiogram — httpx уже в зависимостях, нужны только getUpdates/copyMessage.
Маршрутизация ответа — по topic_message_id: message_id в Telegram уникален
в пределах чата сквозь все топики, а все зеркала лежат в одном support-чате,
поэтому спутать адресата нельзя. Реплай на шапку/на ответ другого оператора
не резолвится (у direction='out' topic_message_id IS NULL) → тихий игнор.
Безопасность (найдено ревью, воспроизведено эмпирически):
- токен Telegram живёт в PATH URL, поэтому sanitize_url его не режет;
утекал в GlitchTip через locals стек-фреймов (include_local_variables
по умолчанию True) и через span data HttpxIntegration. Закрыто
include_local_variables=False + regex-редактор в before_send (обе формы:
/bot<id>:<secret> и голая <id>:<secret>), поверх существующего PII-scrub.
- httpx-логгер печатает полный URL на INFO → боевой токен уходил бы в
docker logs каждые 30с. Приглушён до WARNING.
Надёжность:
- kill-switch при пустом токене — idle-блокировка, не exit(0): при
restart: unless-stopped выход с любым кодом даёт рестарт-луп.
unless-stopped выбран сознательно — только он гарантирует автозапуск
после ребута VPS.
- stop_grace_period: 120s — дефолтные 10с убивали бы контейнер раньше,
чем докрутится long-poll (30с) и отработает drain (100с).
- сбой SQL теперь ловится отдельно и делает rollback перед сдвигом offset:
иначе сессия в failed-transaction не давала сохранить offset, апдейт
переигрывался и зеркалился в топик по кругу.
152-ФЗ: переписка — ПДн, ON DELETE CASCADE по chat_id, удаление клиента
одним DELETE. Ретенция — follow-up.
Бот не включается автоматически: TELEGRAM_* задаются в runtime-env на VPS,
без них воркер штатно висит в idle. Порядок — в DEPLOY.md.
Тесты: 51 passed (маршрутизация обоих направлений, дедуп, 403→is_blocked,
throttle-окно шапки, redaction токена во всех формах event).
sanitize_image декодировал изображение целиком (img.load) ДО ресайза,
а Pillow MAX_IMAGE_PIXELS оставался дефолтным (~89 MP). Хорошо сжимаемый
≤10 MB аплоад с заявленными ~89-178 MP разворачивался в raw RGB буфер на
сотни MB до thumbnail — OOM-kill backend'а (768 MiB, #2214), усиление ×12
слотов на estimate.
Defense in depth:
- _MAX_PIXELS = 40 MP: проверка img.size (из хедера, без декода) ДО img.load;
превышение → ImageSanitizationError без загрузки полного буфера.
- Image.MAX_IMAGE_PIXELS понижен до 40 MP — Pillow сам бросает
DecompressionBombError на любом декод-пути в обход явной проверки.
- Явный except Image.DecompressionBombError + re-raise ImageSanitizationError
(byte/pixel cap) до широкого except — грациозный reject, не 500.
- _MAX_BYTES backstop (endpoint уже режет 10 MB → 413, но сервис standalone).
Контракт caller'а не меняется: reject → ImageSanitizationError → HTTP 400.
Тест tests/services/test_image_sanitizer.py: happy-path, resize, pixel-flood
без декода (load не вызывается), byte-backstop, граница cap, garbage bytes.
Отчёт печатал измерения, которых не делал:
1. Выдуманный «срок продажи 4–118 дней». _days_on_market_range при
отсутствии реальных days_on_market (~48% оценок) возвращал хардкод
(4, 118), а обложка и страница объявлений рисовали его как
измеренный срок экспозиции. Теперь функция возвращает None, и оба
call-site'а не рисуют срок (show_days=False) — как уже делает
страница сделок.
2. № отчёта хардкодил «EKБ» для любой оценки — объект в Серове /
Нижнем Тагиле получал екатеринбургский код на обложке, в шапках и
футере. Префикс теперь выводится из target_address (ЕКБ / НТ / СЕР /
…); неизвестный город → нейтральный «МЕРА», а не ложный «EKБ».
3. Статичный «сделки на 10–18% ниже» противоречил рассчитанному в том
же PDF «−N%». Совет на обложке и баннер на странице сделок теперь
ссылаются на реальный _discount_pct (тот же, что chip «−N%»), с
нейтральной формулировкой когда дисконт неизвестен. Удалён ложный
хвост «(Екатеринбург, 2026)».
Тесты: +11 кейсов (suppression (4,118), city-aware № отчёта,
computed-discount вместо «10–18%»). Все 23 проходят, ruff чисто.
Геокодер был жёстко EKB-bound и ВЫБРАСЫВАЛ корректные non-EKB геокоды (bbox-accept-фильтры) → блокировал оценку любого адреса области. Обобщено до region-66.
- OBLAST66_BBOX + is_within_oblast66_bbox (superset EKB-tight, EKB не затронут); EKB Yandex Tier1/2 fast-path сохранён.
- Все accept-фильтры (Yandex/Nominatim forward+suggest, geocode() accept+typo) → oblast-66.
- City-prefix по word-boundary _has_oblast_marker (не substring): EKB-улицы 'Серова 27'/'Ирбитская 5' и др. больше не роняют 'Екатеринбург,'-префикс (был silent wrong-city баг).
- Two-pass tie-break в accept-циклах: tight-EKB кандидат приоритетнее → EKB-результат идентичен прежнему.
- Region cross-check (Nominatim address.state / Yandex AdministrativeAreaName) отсекает соседние области (Тюмень и др.); bbox-fallback когда region отсутствует.
- DaData suggest: region-параметр (hard-filter), убран no-op restrict_value.
- +26 тестов: OBLAST66 superset TIGHT, far-town accept, Тюмень reject, marker +/- (street-collision), DaData region body.
Foundation для A2 (геокодинг non-EKB сделок) и live-оценки адресов области.
Удаляет весь `app/services/scrapers/` (16 файлов, ~7100 строк) — Part D (D1-D5)
убрал всех внешних вызывающих, 0 runtime importers подтверждено grep'ом на main.
Заодно:
- удалены 7 осиротевших локальных probe/sweep-скриптов (tradein-mvp/scripts/),
импортировавших уже-удалённые или удаляемые сейчас legacy-модули
- тест-хирургия по 65+ файлам: DELETE прямых legacy-юнит-тестов, RETARGET
тестов, тестирующих ещё живую бизнес-логику (переключены на scraper_kit.*
эквиваленты, включая quality-gate #781/#753/#754/#755/#773/#740), partial-delete
golden-parity тестов, потерявших legacy-оракл
- kit save_listings/AvitoScraper/etc. требуют инжектируемые matcher/config —
ретаргетированные тесты обновлены под новую сигнатуру (RealScraperConfig(),
MagicMock HouseMatcher, region_code=66)
Полный pytest suite зелёный (2255 passed, 6 skipped) кроме известного флейка
#2208 (test_search_cache_hit, не связан со scrapers).
Финальный PR issue #2045 (BE-3): GET /api/v1/trade-in/location-coef для
LocationDrawer. FDW foreign table -> локальное зеркало osm_poi_ekb_local
(TRUNCATE+INSERT, тот же паттерн что cad_buildings_local/cadastral_geo_match,
избегает ~1.16s/row FDW round-trip) -> straight-line POI-скоринг, портированный
из Site Finder poi_score.py::compute_poi_weighted_top7 (CATEGORY_WEIGHTS as-is,
радиус 1200м для квартир вместо Ptica 2000м для участков). score->coef -
новая MVP-эвристика (0.95..1.05, не откалибрована на реальных дельтах).
Graceful fallback (не 500, не фабрикуем факторы): пустая/не отрефрешенная
osm_poi_ekb_local или отсутствие lat/lon у оценки -> coef=1.0, factors=[],
geo_source="unavailable".
Scheduler: source=osm_poi_ekb_refresh, daily, зарегистрирован и в боевом
dispatch (scheduler.py), и в kit-registry (product_handlers.py) - иначе
test_kit_registry_completeness падает на ship-dark инварианте (#2192).
Frontend wiring (mappers.ts/LocationDrawer.tsx) - вне scope, отдельная задача
после проверки endpoint'а curl'ом на деплое.
#1786 merged before the test fix landed → main red on
test_geocode_uses_cadastral_before_yandex. The new geoportal house-match
tier runs first in geocode(); this legacy cadastral test must mock it to
None so it exercises the cadastral forward path (else the MagicMock db row
leaks through the new tier as lat=1.0).
The legacy resolve_cian_zhk_url hit /zhk/<id>/ which now 404s, leaving the
318 geo-matched cian houses (house_sources.ext_id set, cian_zhk_url NULL)
unfetchable -> the newbuilding-enrichment backfill matched 0 houses.
Add resolve_cian_zhk_url_via_search(nb_id): fetch the cat.php newbuilding SERP
and extract the canonical zhk-<slug>.cian.ru url, ANCHORED on the
<h1 data-name="Title"> header anchor (NOT naive first-match — the SERP carries
promo/recommendation zhk-* links before the title that would otherwise resolve
the wrong ЖК and silently corrupt enrichment). Validated against the real
2.81MB prod SERP + an adversarial poisoned-recommendation test.
Wire into newbuilding_enrich_backfill: broaden selection to "has ext_id OR
cian_zhk_url", resolve+persist the url under a SAVEPOINT before enriching,
rate-limited + resumable + idempotent. Keep the old resolver (deprecated).
Bounded prod proof (5 houses, direct): 5/5 urls resolved+persisted, 3/5 fully
enriched (+18 price_dynamics, +3 reliability); the 2 misses were direct-mode
anti-bot on the 2nd fetch. Full 318-run gated on the cian mobile proxy
(mproxy.site) being restored. code-reviewer APPROVE (SQL/idempotency) +
resolver hardened against wrong-ЖК. 19 tests green.
Refs #972.
Replaces tradein.cad_buildings snapshot with live postgres_fdw foreign table reading gendesign.v_tradein_cad_buildings. Fixes /trade-in/api/v1/geocode/reverse 500 (Nominatim ban) and address_not_geocoded for cadastre addresses (e.g. Хохрякова 81).
Security (deep-review fixes):
- 100_tradein_fdw_role.sql: passwordless CREATE ROLE; password set by deploy.yml ALTER ROLE bootstrap reading GENDESIGN_FDW_PASSWORD from backend/.env.runtime (via psql :'pw' var → format %L — injection-safe).
- core/fdw.py: regex whitelist [A-Za-z0-9_-]{32,256} on password, ValueError without echoing value, try/rollback on commit.
- 060_postgres_fdw_extension.sql: connect_timeout='3' on FOREIGN SERVER + ALTER ADD/SET fallback.
- geocoder.py: _cadastral_forward_sync / _cadastral_reverse_sync wrapped in asyncio.to_thread.
- 100_*.sql: REVOKE ALL ON ALL TABLES/SEQUENCES/FUNCTIONS IN SCHEMA public; only GRANT SELECT on v_tradein_cad_buildings.
- pg_user_mappings query handles PUBLIC mapping (usename IS NULL).
Tests: 3 SQL-injection guards on ensure_fdw_user_mapping + rewritten cadastral suite.