|
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 → падает тест маршрутов.
|
||
|---|---|---|
| .. | ||
| backend | ||
| browser | ||
| deploy | ||
| docs | ||
| frontend | ||
| ops | ||
| packages/scraper-kit | ||
| scripts | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| CHANGELOG.md | ||
| DEPLOY.md | ||
| docker-compose.prod.yml | ||
| docker-compose.yml | ||
| Makefile | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
| VERSION | ||
Trade-In MVP
Локальный standalone-форк фичи Trade-In Estimator из проекта gendesign — оценка выкупной стоимости квартиры на вторичном рынке по аналогам и реальным сделкам. Layout повторяет PDF-отчёт «Брусника.Обмен» (см. docs/).
Что это и откуда взято
| Источник | Что | Где |
|---|---|---|
gendesign/main PR #316 TI-1 |
mock endpoint + Pydantic + SQL migration | backend/ |
gendesign/main PR #317 TI-3 |
Next.js страница + 5 компонентов + hooks | frontend/ |
gendesign/main PR #319 TI-2 |
PDF export 4 страницы (как у Брусники) | backend/app/services/exporters/ |
gendesign/main PR #283 |
статичный tradein.html mockup (для Геныча) |
frontend/public/tradein.html |
| Встреча 19.05.2026 («Птица») | требования к MVP оценки вторички | docs/PTITSA_MEETING_2026-05-19.pdf |
| PDF Брусники EКБ-2485 | референс layout-а отчёта | docs/BRUSNIKA_REFERENCE_EKB-2485.pdf |
Быстрый старт
make up # build + up весь стек (caddy + frontend + backend + postgres)
open http://localhost:8080
Откроется / → автоматически редирект на /trade-in. Заполняешь форму (адрес/площадь/комнаты/этаж/...), нажимаешь «Оценить» — backend возвращает mock-оценку, фронт показывает median + диапазон цен + список аналогов.
Проверка backend напрямую:
make test-estimate
# или вручную:
curl -sS -X POST http://localhost:8080/api/v1/trade-in/estimate \
-H 'Content-Type: application/json' \
-d '{"address":"ул. Малышева, 1","area_m2":54,"rooms":2,"floor":5,"total_floors":17}' \
| python3 -m json.tool
OpenAPI документация: http://localhost:8000/docs
Для сравнения макет vs реальная фича:
Структура
tradein-mvp/
├── docker-compose.yml # caddy + frontend + backend + postgres
├── Makefile # удобные команды (up/down/logs/test-estimate)
├── deploy/
│ └── Caddyfile # local reverse-proxy на http://localhost:8080
├── backend/ # FastAPI + WeasyPrint
│ ├── Dockerfile
│ ├── pyproject.toml
│ ├── app/
│ │ ├── main.py # FastAPI entry — только trade-in router
│ │ ├── core/
│ │ │ ├── config.py # минимальный pydantic-settings
│ │ │ └── db.py # SQLAlchemy engine + get_db
│ │ ├── api/v1/
│ │ │ └── trade_in.py # 3 endpoint'а: POST /estimate, GET /estimate/{id}, GET /estimate/{id}/pdf
│ │ ├── schemas/
│ │ │ └── trade_in.py # Pydantic: TradeInEstimateInput / AnalogLot / AggregatedEstimate
│ │ └── services/exporters/
│ │ └── trade_in_pdf.py # WeasyPrint → 4-страничный PDF (cover/listings/deals/offer)
│ └── data/sql/
│ └── 001_trade_in_estimates.sql # CREATE TABLE; применяется при первом старте postgres
├── frontend/ # Next.js 15 + React 19 + TanStack Query
│ ├── Dockerfile
│ ├── package.json
│ ├── next.config.ts # rewrites /api/* → backend
│ ├── tsconfig.json
│ ├── src/
│ │ ├── app/
│ │ │ ├── layout.tsx
│ │ │ ├── page.tsx # redirect → /trade-in
│ │ │ ├── globals.css
│ │ │ ├── providers.tsx # QueryClientProvider
│ │ │ └── trade-in/
│ │ │ └── page.tsx
│ │ ├── components/trade-in/
│ │ │ ├── EstimateForm.tsx # форма ввода (sticky 360px)
│ │ │ ├── EstimateProgress.tsx # индикатор «Парсим Циан → Авито → ...»
│ │ │ ├── EstimateResult.tsx # карточка результата
│ │ │ ├── PriceRangeBar.tsx # визуализация диапазона цен (как у Брусники)
│ │ │ └── AnalogsTable.tsx # таблица аналогов
│ │ ├── lib/
│ │ │ ├── api.ts # apiFetch + HTTPError
│ │ │ ├── sessionId.ts # X-Session-Id из localStorage
│ │ │ └── trade-in-api.ts # useEstimateMutation + useEstimate hooks
│ │ └── types/
│ │ └── trade-in.ts # TS типы зеркалят Pydantic schemas
│ └── public/
│ └── tradein.html # статичный mockup от 17.05 (для side-by-side review)
└── docs/
├── BRUSNIKA_REFERENCE_EKB-2485.pdf # эталон layout-а
└── PTITSA_MEETING_2026-05-19.pdf # AI-протокол встречи с требованиями
API
POST /api/v1/trade-in/estimate — оценить квартиру.
Запрос:
{
"address": "ул. Малышева, 1, кв. 5, Екатеринбург",
"area_m2": 54.0,
"rooms": 2,
"floor": 5,
"total_floors": 17,
"year_built": 1985,
"house_type": "panel",
"repair_state": "good",
"has_balcony": true
}
Ответ:
{
"estimate_id": "...uuid...",
"median_price_rub": 13125000,
"range_low_rub": 11550000,
"range_high_rub": 14700000,
"median_price_per_m2": 243056,
"confidence": "high",
"n_analogs": 8,
"period_months": 24,
"analogs": [ {"address": "...", "area_m2": 56, "price_rub": 12700000, ...} ],
"actual_deals": [ ... ],
"expires_at": "2026-05-20T22:48:00Z"
}
GET /api/v1/trade-in/estimate/{id} — получить сохранённую оценку (TTL 24ч)
GET /api/v1/trade-in/estimate/{id}/pdf — скачать 4-страничный PDF (cover / listings / deals / offer)
Что внутри _mock_estimate() (текущая реализация)
Формула:
price = base_price_by_rooms × floor_factor × repair_factor
| Поле | Значения |
|---|---|
| Базовая цена ЕКБ 2026 | студия 6.5M (260K/м²) · 1к 9.0M (225K/м²) · 2к 12.5M (208K/м²) · 3к 17.0M (213K/м²) |
floor_factor |
1-й этаж = ×0.95, последний = ×0.97, остальные = ×1.00 |
repair_factor |
needs_repair = ×0.90, standard = ×1.00, good = ×1.05, excellent = ×1.10 |
confidence |
1-3 комнаты = high, остальные = medium |
| Улицы аналогов | реальные центральные ЕКБ (Малышева, Куйбышева, 8 Марта, Белинского, пр. Ленина, Толмачёва, Радищева, Мамина-Сибиряка, Луначарского, Первомайская) |
Каждая оценка сохраняется в trade_in_estimates с TTL 24 часа — UUID можно использовать для shareable links и PDF-экспорта.
Roadmap — что доделать
Phase 1 — заменить mock на реальные данные (TODO TI-1b из gendesign)
Сейчас _mock_estimate() возвращает хардкод. На встрече Птица 19.05 решили:
- источники: Циан, Авито, Дом.Клик, Я.Недвижимость, Н1, Дом РФ
- Объектив НЕ использовать на вторичке (он про первичку/ДДУ)
- Росреестр для исторических сделок (квартал глубины)
- картография ЕКБ для проверки этажности/года/планировок
Phase 2 — то что обсуждали на встрече
| Задача | Из протокола Птицы |
|---|---|
| Парольный вход + учёт пользователей + аналитика | 0:23:44, 0:25:54 |
| Доступ только Геныч / Загайнов / Паша (НЕ Рожкова) | 0:25:50, 0:22:35 |
| PDF-отчёт под паролем | 0:08:39 |
| Real-time парсинг ≥1/час чтобы ловить быстрые продажи | 0:40:19 |
| MVP к понедельнику 25.05.2026 | 0:26:12 |
| Демо для девелопера в четверг 28.05.2026 | 0:18:08 |
Phase 3 — следующие продукты (упоминалось на встрече)
- Птица — анализ участков + расселение домов (≥20% квартир дома в продаже → подсветить можно расселять)
- Расселение как сервис — следствие #1 и Птицы
См. полный протокол: docs/PTITSA_MEETING_2026-05-19.pdf.
Как это связано с прод gendesign
| Аспект | Прод (gendsgn.ru) | Этот MVP |
|---|---|---|
| URL | https://gendsgn.ru/trade-in | http://localhost:8080/trade-in |
| Backend | shared FastAPI /api/v1/trade-in/* |
то же самое, standalone |
| Frontend | Next.js 15 в большом monorepo | тот же код, standalone |
| Postgres | 84 таблицы, 6.83M ДДУ partitioned | только trade_in_estimates (1 таблица) |
| Caddy | TLS + 5 доменов + reverse-proxy | local :8080 без TLS |
| Что отрезано | site-finder, analytics, generative, scraper, OSM, NSPD, sentry, celery, redis, playwright | всё это — кроме trade-in |
Важно: эти два инстанса полностью изолированы. Локальный backend пишет в свой Postgres контейнер (порт 5433), не трогает прод. Можно сломать локально что угодно — прод не пострадает.
Лицензия
Internal use only. Forked from gendesign monorepo (private).