Compare commits

...

16 commits

Author SHA1 Message Date
a48070dd89 fix(tradein): миграция платежей 277 → 279 (столкновение номеров) + lock_timeout
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m54s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
Два параллельных агента взяли ОДИН номер: 277_landing_showcase_runs.sql в ветке
витрины и 277_payments_live_checkout_uidx.sql здесь. Обе ветки по отдельности
зелёные, но на main второй файл встал бы конфликтом — ровно ловушка из шапки
tests/test_migration_numbering.py: номер сверяется с origin/main, а не с чужими
открытыми ветками.

Заняты сейчас: 275 (метрики), 276+277 (витрина), 278 (публичный токен) → этот 279.
Ссылки на номер обновлены в payments.py и test_payments_router.py, включая путь,
по которому тест читает предикат частичного UNIQUE.

Плюс CREATE UNIQUE INDEX на существующей таблице payments обёрнут в
SET LOCAL lock_timeout = '5s' — гейт #2752.
2026-08-29 19:38:38 +05:00
c5315539fa fix(payments): один живой платёж на оценку — гарантия БД, а не порядка выполнения
Идемпотентность checkout держалась на «SELECT, потом INSERT» — ровно на том,
что шапка модуля называет дефектом. Двойной клик по кнопке оплаты давал два
параллельных запроса, два INSERT, два Init и два холда на карте покупателя.

- миграция 277: частичный UNIQUE (estimate_id, product_code) по живым статусам
  + ON CONFLICT DO NOTHING в INSERT. Проигравший гонку не идёт в банк: отдаёт
  ссылку соперника, если та уже готова, иначе 409;
- граница по времени для брошенных попыток: NEW/FORM_SHOWED старше 30 минут
  переводятся в DEADLINE_EXPIRED. Без неё зависший платёж (нотификации по нему
  может не прийти вовсе) навсегда отдавал покупателю одну и ту же протухшую
  PaymentURL. Окно НЕ распространяется на AUTHORIZED и прочие карточные
  статусы — там деньги уже в игре, разгребать их — работа реконсиляции;
- IDOR: checkout читал оценку без _assert_estimate_access. По чужому
  estimate_id возвращался order_id чужого живого платежа, а order_id — право
  доступа для /payments/status/<order_id>, отдающего capability-ссылку на
  отчёт. Проверка ставится только для оценок с владельцем: у анонимной покупки
  идентичности нет, правом там работает сам estimate_id.

Тесты двусторонние, фальсификация прогнана: снятие ON CONFLICT / границы по
времени / IDOR-гварда красит ровно один тест каждый раз, два из трёх — по
значению ответа.
2026-08-29 19:38:38 +05:00
28e13d5841 feat(payments): роутер checkout/notify, статус-машина и выдача по capability-ссылке
Не хватало ровно проводки: сервисный слой Т-Банка (PR-C) и схема (PR-B, 233)
уже были, HTTP-ручек и статус-машины — нет, как и доставки купленного.

Всё за kill-switch PAYMENTS_ENABLED (дефолт false): при выключенном контуре
каждая ручка отвечает 503 и не трогает ни банк, ни платёжные таблицы, поэтому
merge на проде не меняет поведения.

Идемпотентность целиком отдана БД (UNIQUE миграции 233 + ON CONFLICT DO
NOTHING), а не паре «проверить-потом-вставить»: между проверкой и вставкой
проходит параллельный ретрай банка, и товар выдаётся дважды. Признаком
«выдача состоялась» служит payment_notifications.processed_at, а не сам факт
строки — иначе падение процесса между записью нотификации и выдачей оставило
бы клиента без отчёта при списанных деньгах.

Доставка — capability-ссылка /api/v1/trade-in/r/<token>: токен лежит в
payment_entitlements.subject (ref_id остаётся estimate_id, на нём держится
UNIQUE «выдали один раз»), режется из GlitchTip-событий и открыт в rbac
отдельным узким префиксом. Тело GET /estimate/{id} вынесено в load_estimate,
чтобы у второго права доступа был тот же загрузчик, а не третья копия
гейта читаемости.
2026-08-29 19:38:37 +05:00
67d9efbac4 Merge pull request 'feat(mera/b2c): анонимный расчёт и повторное чтение результата по токену (за флагом)' (#3230) from feat/b2c-anon-estimate into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 1m50s
Deploy Trade-In / test (push) Successful in 4m3s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s
2026-08-29 14:37:20 +00:00
6255eccc7c fix(tradein): миграция публичного токена без lock_timeout — гейт #2752
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m53s
278 делает ALTER TABLE trade_in_estimates ADD COLUMN + CREATE UNIQUE INDEX на
ЖИВОЙ таблице (1123 строки на проде). ALTER берёт ACCESS EXCLUSIVE: без
lock_timeout он встал бы в очередь за запросами приложения и утащил их за собой.

Обёрнуто в BEGIN + SET LOCAL lock_timeout = '5s' + COMMIT по образцу
272_houses_region_code.sql.
2026-08-29 19:28:37 +05:00
dcfea2ad39 test(mera/b2c): пинит kwargs делегации анонимного расчёта, а не факт вызова
Весь анти-абузный контур публичной ручки (анонимная квота cookie+IP, семафор,
503 вместо 502, consent-гейт) держится на одном аргументе: в
app.api.v1.trade_in.estimate уходит x_authenticated_user=None. Проверял это
ноль тестов: estimate везде замокан AsyncMock, который принимает любую
сигнатуру, — подмена None на чтение заголовка запроса оставляла все 14 тестов
зелёными, а публичная форма начинала считать от чужого имени мимо квоты.

Новый тест шлёт запрос С заголовком X-Authenticated-User: admin и сверяет
фактические await_args.kwargs; заодно требует, чтобы аргументы ехали по имени
(позиционный вызов обесценивает сверку) и чтобы аргумент вообще присутствовал
(дефолт эстиматора — чужая гарантия, не наша). Проверено падением: подмена на
request.headers.get даёт «пришло: 'admin'».

Там же UPDATE токена: параметры сверялись только по хэшу, id строки — нет.
Теперь пинится result.estimate_id: токен обязан вешаться на только что
посчитанную оценку. Проверено подменой параметра — красный по значению.

test_read_filters_by_expiry_and_hash оставлен текстовым: живого Postgres с
миграцией 278 здесь нет, а поведенческий тест, ни разу не прогнанный, — это
ещё один зелёный по построению. Вместо этого в самом тесте написано, что он
проверяет (предикат есть в тексте SQL, в параметрах хэш) и чего НЕ проверяет
(сессия — MagicMock, запрос не исполняется, протухший токен не отсекается), и
чем его заменить, когда БД появится.
2026-08-29 19:28:37 +05:00
2d4daceb2f feat(mera): анонимный расчёт и капабилити-ссылка на его бесплатную часть
Публичный контур умел только подсказки и пробу покрытия: полный расчёт закрыт
RBAC, а результат анонима нельзя было прочитать повторно — _assert_estimate_access
отдаёт 404 на строку с created_by IS NULL всем, кроме админа, то есть расчёт жил
ровно в теле POST-ответа и не переживал перезагрузку страницы.

POST /api/public/mera/estimate делегирует в app.api.v1.trade_in.estimate (копии
логики нет — иначе публичная когорта разъедется с платной) и отдаёт наружу только
бесплатную часть: число аналогов и вердикт покрытия из той же coverage_probe.
Цены, прогнозы и списки аналогов остаются в БД для платного контура.

Согласие 152-ФЗ обязательно и строго True на уровне схемы, поэтому отказ
происходит до входа в хендлер — раньше, чем адрес физлица дошёл бы до БД.

POST /api/public/mera/estimate/read читает бесплатную часть по токену
(secrets.token_urlsafe(32), в БД только sha256, срок жизни 7 дней, миграция 278).
Токен едет телом: access-лог Caddy пишет URI целиком, и капабилити-ссылка в пути
легла бы в файл рядом с IP посетителя — тот же довод, по которому POST'ом сделан
/suggest. Постоянный путь заодно не требует префиксной ветки в rbac._PUBLIC_PATHS.

Всё закрыто флагом public_estimate_enabled (дефолт false → 404): включение
открывает запись ПДн и требует решения владельца вместе с правкой политики.
2026-08-29 19:28:37 +05:00
df9555b5c4 Merge pull request 'feat(mera/b2c): лента сделок и раунды игры — из реальных сделок с реальным прогнозом' (#3229) from feat/b2c-showcase-real-deals into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 2m9s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
Deploy Trade-In / test (push) Successful in 4m3s
Deploy Trade-In / build-backend (push) Successful in 1m7s
2026-08-29 14:27:25 +00:00
87aa5bdf07 fix(tradein): миграции витрины без lock_timeout — CI-гейт #2752 краснел
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m54s
Обе миграции создавали индекс без транзакции и без SET LOCAL lock_timeout.
Гейт scripts/check-migration-lock-timeout.py это и поймал:

  ::error 276_landing_showcase_deals.sql:: блокирующий DDL без lock_timeout
  (CREATE INDEX IF NOT EXISTS idx_landing_showcase_deals_computed_at ...)

Обе обёрнуты в BEGIN + SET LOCAL lock_timeout = '5s' + COMMIT по образцу
272_houses_region_code.sql. Локально гейт зелёный: «проверено новых миграций: 31».
2026-08-29 19:20:04 +05:00
abe559cf8f fix(mera): витрина больше не отсеивает промахи оценщика, счётчики едут на фронт
Ревью MAJOR по честности, два пункта.

1. Убран MAX_ABS_ERR_PCT = 40 из build_row. Докстринг модуля сам запрещает
отбор по величине ошибки, но запрет был реализован только в _sort_key, а
фильтр — тот же отбор ступенькой раньше, и злее: строка не попадала даже в
кандидаты. Обоснование «отклонение >40% — почти всегда занижение ДКП ради
налога» не держится: _load_sample уже режет выборку санитарным диапазоном
₽/м² (для ЕКБ это глобальные PPM2_MIN=30k / PPM2_MAX=600k — город намеренно
не заведён в deal_city_price_bands), то есть грубые занижения вырезаны выше
по потоку и ПО СВОЙСТВУ САМОЙ СДЕЛКИ. Всё, что после этого дало большую
ошибку, — работа оценщика, и посетитель обязан её видеть. Честность про
заниженные ДКП перенесена в note каждой строки.

Заодно убраны MIN_FACT_PPM2=30k (дублировал уже применённый фильтр) и
MAX_FACT_PPM2=1.2M (недостижим при потолке выборки 600k): из трёх отбраковок
в проде срабатывала ровно одна — та, что льстила витрине, а два мёртвых
порога читались как работающие. Осталась только структурная отбраковка «нет
прогноза / квартала / площади».

2. Счётчики прогона выведены в ответ ручки. Итог пересчёта пишется в
landing_showcase_runs (миграция 277) и уезжает в ShowcaseResponse.stats
вместе с правилом отбраковки: показано 20 из N годных, рассмотрено M сделок.
Отдельная таблица, а не колонки в строках, — иначе в самом важном случае
(показывать нечего) счётчики исчезли бы вместе со строками. Ручка теперь
берёт и строки, и числа ИЗ ОДНОГО прогона: иначе пустой прогон показал бы
вчерашние строки под сегодняшними счётчиками.

Тесты двусторонние и проверены на сломанном коде: возврат любого порога по
ошибке → красный с величиной отклонения в сообщении; возврат любой границы
₽/м² → красная своя половина; stats=None при живом прогоне → красный.
2026-08-29 19:20:04 +05:00
b72dbc5da3 feat(mera): витрина лэндинга на реальных ДКП-сделках вместо выдуманных
Лента «МЕРА сказала X — продали за Y» жила на константах в marketing-v3.ts.
Здесь появляется её настоящий источник: сделки Росреестра по ЕКБ, прогнанные
через тот же спайн оценщика, что и боевой расчёт (backtest_estimator).

Отбор строк идёт по полноте данных и свежести квартала и НЕ смотрит на
величину ошибки: отбор по малой ошибке дал бы формально работающий код и
врущую витрину — показанные строки перестали бы быть выборкой из работы
оценщика. Свойство закреплено двусторонним тестом.

Витрина не показывает адреса (номер дома есть у 2.7% сделок) и не показывает
дня сделки (deal_date — первое число квартала). Каждая строка несёт note о
том, что замер не point-in-time. Заниженные ради налога ДКП отбрасываются по
|отклонению| > 40% и ₽/м² вне [30k; 1.2M], счётчик отброшенного — в лог.
2026-08-29 19:20:04 +05:00
e936ca73f8 Merge pull request 'feat(mera/b2c): метрики лэндинга считаются по проду, а не лежат литералами' (#3228) from feat/b2c-landing-stats into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m6s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 1m19s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-08-29 14:18:31 +00:00
2eb7814c36 Merge pull request 'fix(gendesign): FDW-роль не могла читать районы ЕКБ — витрина сделок МЕРЫ теряла район' (#3227) from fix/ekb-districts-fdw-grant into main
All checks were successful
Deploy / changes (push) Successful in 13s
Deploy / build-backend (push) Successful in 44s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 43s
Deploy / deploy-caddy (push) Has been skipped
Deploy / deploy (push) Successful in 1m8s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 13s
2026-08-29 14:15:45 +00:00
e9a2fff0b3 test(mera/b2c): гейт на сам SQL доли снижений + чистка протухших метрик
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
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 / 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 5m32s
Ревью: гейт охранял не то место. Подмена знаменателя красила три теста, но
дефект «84.8% вместо 48.1%» живёт в SQL — во включении однострочных записей
истории (у domklik одна запись = «цену не менял») в знаменатель. Ревьюер
вернул дефект условием n_rows >= 2 в CTE moved, и все 25 тестов остались
зелёными: текстовые пины держали только span_days и max_abs_pct.

Новый пин держит обе половины: однострочные попадают в moved веткой CASE со
значением 0, и нигде в запросе нет фильтра по числу записей истории (ни в
WHERE, ни HAVING). Живой прогон на подготовленных строках не заведён
намеренно: DATABASE_URL в тестовой джобе — заглушка, Postgres там нет, и тест
по образцу test_purge_expired_trade_in_data.py молча скипался бы, то есть не
гейтил бы ничего. Фальсифицировано руками — с n_rows >= 2 тест красный и
называет причину.

Второе: метрика, у которой пропал вход, больше не доживает в таблице со
старым computed_at (ручка отдавала её неотличимо от свежей). Строки вне
сегодняшнего набора удаляются в той же транзакции. На ПУСТОМ наборе чистка
не ходит: разом отвалившиеся все входы — признак поломки прогона, а не пяти
одновременных «данных больше нет». Оба поведения покрыты тестами, оба
проверены на сломанном коде.
2026-08-29 19:10:04 +05:00
f2e6a59c68 fix(gendesign): FDW-роль не могла читать районы ЕКБ — витрина сделок МЕРЫ теряла район
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 / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m5s
CI / backend-tests (pull_request) Successful in 17m46s
foreign table tradein.gendesign_ekb_districts_geom падала с «permission denied for
view ekb_districts_geom». Замер: has_table_privilege('tradein_fdw_reader',
'public.ekb_districts_geom','SELECT') = false, при том что все четыре соседних
объекта того же FDW-сервера (rosreestr_deals, mv_quarter_price_index,
v_tradein_cad_buildings, v_tradein_osm_poi_ekb) грант имеют.

Грант не «потерялся» — его не выдавали никогда: 74_dedupe_unified_views.sql
меняет тип объекта (DROP TABLE + CREATE OR REPLACE VIEW), то есть создаёт его
заново, а строки GRANT рядом не было. Тот же класс, что #2583.

Приёмка на проде после деплоя: тот же has_table_privilege обязан вернуть true,
а SELECT count(*) FROM gendesign_ekb_districts_geom — 8 вместо ошибки.
2026-08-29 18:56:11 +05:00
b5645ec1bc feat(mera/b2c): витринные метрики лэндинга считаются по проду
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m13s
Числа на публичном лэндинге лежали литералами во фронте
(mera-public/marketing-v3.ts) — то есть были выдуманы и не имели срока
годности. Теперь их считает ночная задача и отдаёт публичная ручка,
вместе с размером выборки и описанием того, что именно измерено.

Что считается: число расчётов и период работы, медиана аналогов на
расчёт, медианная ЭКСПОЗИЦИЯ активного объявления по ЕКБ (не срок
продажи — так и написано в note), доля снижавших цену и медианное
снижение за 30 дней, сделки Росреестра по ЕКБ за 12 месяцев.

Ценовые метрики берут ТОЛЬКО domklik: у avito/yandex триггер не пишет
стартовую цену, а yandex вдобавок сеет синтетическую пару со сдвигом в
сутки — на такой смеси «снизил» и «не снижал» неразличимы. Знаменатель
доли — все объявления, наблюдавшиеся от 14 дней, включая не менявшие
цену; считая только по менявшим, получили бы 85% вместо честных 48%.

Метрика без входных данных строку НЕ пишет: подставленный ноль читался
бы как измеренный ноль. Пустая таблица — валидные {} и 200, а не 500.

«Точность прогноза» и «срок продажи» здесь не считаются намеренно —
таких величин в данных нет.
2026-08-29 18:45:26 +05:00
24 changed files with 4469 additions and 24 deletions

View file

@ -0,0 +1,26 @@
-- 194_grant_ekb_districts_fdw.sql
-- Витрина сделок МЕРЫ не могла показать район: FDW-чтение падало с
-- permission denied for view ekb_districts_geom
-- (foreign table tradein.gendesign_ekb_districts_geom → сервер gendesign_remote,
-- роль tradein_fdw_reader).
--
-- Причина не в потере гранта, а в том, что его не выдавали НИКОГДА.
-- 74_dedupe_unified_views.sql делает `DROP TABLE ekb_districts_geom;
-- CREATE OR REPLACE VIEW ekb_districts_geom ...` — объект сменил тип с таблицы
-- на вьюху и был создан заново, а строки GRANT рядом не появилось. Все четыре
-- соседних объекта, которые tradein читает через тот же сервер
-- (rosreestr_deals, mv_quarter_price_index, v_tradein_cad_buildings,
-- v_tradein_osm_poi_ekb), грант имеют — этот остался единственным без него.
--
-- Тот же класс, что #2583 (188_regrant_quarter_price_index_fdw.sql): грант живёт
-- отдельно от объекта и молча исчезает при пересоздании. Поэтому GRANT дописан
-- ТАКЖЕ в конец 74-й — чтобы повторное применение той миграции его не теряло.
--
-- Признак починки: на tradein-стороне
-- SELECT count(*) FROM gendesign_ekb_districts_geom; -- было ERROR, стало 8
BEGIN;
GRANT SELECT ON public.ekb_districts_geom TO tradein_fdw_reader;
COMMIT;

View file

@ -254,3 +254,19 @@ SELECT 'nspd', log_id, run_id, ts, level, stage, cad_number, message
COMMENT ON VIEW v_scrape_log_unified IS
'Per-step log двух скраперов. entity_id = obj_id для kn / cad_number для nspd. '
'objective scraper пока не пишет log — добавим если понадобится debug.';
-- ── ГРАНТ ЖИВЁТ ЗДЕСЬ, А НЕ ОТДЕЛЬНО (29.08.2026) ──────────────────────────
-- Выше `DROP TABLE ekb_districts_geom` + `CREATE OR REPLACE VIEW` меняет тип
-- объекта, то есть создаёт его заново — а вместе с объектом исчезают и его
-- гранты. Из-за этого tradein-сторона (foreign table
-- gendesign_ekb_districts_geom, роль tradein_fdw_reader) читала вьюху с
-- `permission denied` и витрина сделок МЕРЫ теряла район.
-- Тот же урок, что в 188_regrant_quarter_price_index_fdw.sql: грант, лежащий в
-- отдельной миграции, переживает ровно до следующего пересоздания объекта.
-- Держим его рядом с CREATE, чтобы объект и его права ехали вместе.
--
-- ЧЕСТНО О ДЕЙСТВИИ: на ДЕЙСТВУЮЩЕМ проде эта строка не выполнится никогда —
-- деплой применяет только файлы, которых ещё нет в _schema_migrations, а 74-я
-- давно записана. Работу делает 194_grant_ekb_districts_fdw.sql. Здесь строка
-- нужна для чистой базы и для случая, когда файл прогоняют руками.
GRANT SELECT ON public.ekb_districts_geom TO tradein_fdw_reader;

View file

@ -1,4 +1,4 @@
"""Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный, ровно две ручки.
"""Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный.
ЗАЧЕМ ОТДЕЛЬНЫЙ ПРЕФИКС, А НЕ ОТКРЫТИЕ КУСКА /api/v1/*
-------------------------------------------------------
@ -26,20 +26,29 @@ API, нужно было выбрать одно из двух:
АНОНИМНОСТЬ
-----------
`rbac_guard` (app/core/rbac.py) требует `X-Authenticated-User` для любого
non-public пути. Обе ручки перечислены в `_PUBLIC_PATHS` ТОЧНЫМИ строками
non-public пути. Все ручки перечислены в `_PUBLIC_PATHS` ТОЧНЫМИ строками
не префиксом: множество там frozenset с проверкой `path in ...`, и
добавление префиксной ветки ради двух путей расширило бы механизм, которым
пользуется весь бэкенд, ради одной фичи.
добавление префиксной ветки расширило бы механизм, которым пользуется весь
бэкенд, ради одной фичи. Поэтому и чтение по токену POST с постоянным
путём `/estimate/read`, а не `GET /estimate/{token}`: переменный сегмент
пути потребовал бы ровно такой префиксной ветки (плюс сам токен уехал бы в
access-лог Caddy, см. `PublicEstimateTokenInput`).
ЧТО ЭТИ РУЧКИ НЕ ДЕЛАЮТ
-----------------------
Ни одна из них не пишет в БД строк с адресом пользователя: `/coverage`
чистое чтение (один SELECT), `/suggest` прокси автокомплита. Это не
случайность, а условие, при котором публичная форма может работать ДО того,
как появится контур согласия 152-ФЗ (issue #2895: сегодня адрес физлица
попадает в `trade_in_estimates` раньше любого согласия, а пути удаления
данных в бэкенде нет). Платный расчёт, который писать будет, открывается
отдельно и только вместе с этим контуром.
ЧТО ЭТИ РУЧКИ ДЕЛАЮТ С ДАННЫМИ
------------------------------
`/suggest` и `/coverage` не пишут в БД ничего: первый прокси автокомплита,
второй один SELECT. Это по-прежнему так и меняться не должно.
`/estimate` единственная, которая ПИШЕТ строку с адресом физлица (issue
#2895), и поэтому устроена иначе: она закрыта флагом
`settings.public_estimate_enabled` (дефолт false 404) и требует явного
согласия 152-ФЗ в payload'е строго `True` — на уровне схемы, то есть 422
прилетает до входа в хендлер и до любого обращения к БД. Пути удаления
данных в бэкенде всё ещё нет; включать флаг на проде вместе с ним и с
правкой п.5.5 политики обработки ПДн.
`/estimate/read` возвращает по капабилити-токену ТОЛЬКО бесплатную часть
(`PublicEstimateResult`) цену и прогнозы продаёт платный контур.
БЮДЖЕТЫ
-------
@ -58,19 +67,28 @@ non-public пути. Обе ручки перечислены в `_PUBLIC_PATHS`
from __future__ import annotations
import asyncio
import hashlib
import logging
from typing import Annotated
import secrets
from datetime import datetime
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api.v1.geocode import SuggestResponse, suggest_addresses
from app.api.v1.trade_in import coverage_probe
from app.api.v1.trade_in import coverage_probe, estimate
from app.core.config import settings
from app.core.db import get_db
from app.core.public_request import install_address_log_redaction, public_request_scope
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.schemas.trade_in import CoverageProbeInput, CoverageProbeResponse
from app.schemas.trade_in import (
CoverageProbeInput,
CoverageProbeResponse,
TradeInEstimateInput,
)
logger = logging.getLogger(__name__)
@ -91,10 +109,26 @@ router = APIRouter()
# нажал ещё раз».
_SUGGEST_LIMIT = 20
_COVERAGE_LIMIT = 15
# Витрина — один SELECT по своей же маленькой таблице, внешних вызовов нет,
# поэтому бюджет шире соседних: он здесь против перебора-в-цикле, а не против
# денежных трат. Лэндинг дёргает ручку один раз на загрузку страницы.
_SHOWCASE_LIMIT = 60
# Расчёт — самый дорогой шаг публичного контура (геокодер + десяток SQL по
# листингам + запись строки). Бюджет намеренно ниже пробы покрытия: живой
# человек нажимает «рассчитать» единицы раз, а анонимная месячная квота
# (settings.anon_estimate_quota_limit) обходится сменой IP — минутное окно
# делает такой обход дорогим по времени.
_ESTIMATE_LIMIT = 5
# Чтение по токену дешевле расчёта (один SELECT по уникальному индексу), но
# ослаблять его до бесконечности нельзя: перебор токенов — это перебор.
_ESTIMATE_READ_LIMIT = 30
_WINDOW_S = 60.0
_suggest_limiter = SlidingWindowLimiter(limit=_SUGGEST_LIMIT, window_s=_WINDOW_S)
_coverage_limiter = SlidingWindowLimiter(limit=_COVERAGE_LIMIT, window_s=_WINDOW_S)
_showcase_limiter = SlidingWindowLimiter(limit=_SHOWCASE_LIMIT, window_s=_WINDOW_S)
_estimate_limiter = SlidingWindowLimiter(limit=_ESTIMATE_LIMIT, window_s=_WINDOW_S)
_estimate_read_limiter = SlidingWindowLimiter(limit=_ESTIMATE_READ_LIMIT, window_s=_WINDOW_S)
# ── Общий суточный потолок публичных подсказок ──────────────────────────────
#
@ -286,3 +320,432 @@ def public_coverage(
"""
_enforce(_coverage_limiter, request, "coverage")
return coverage_probe(payload=payload, db=db)
class LandingStat(BaseModel):
"""Одна витринная величина лэндинга.
`sample_n` и `note` едут наружу вместе со значением намеренно: цифра без
размера выборки и без описания измеренного это ровно тот литерал, который
лежал во фронте до появления landing_stats. Пусть фронт решает, показывать
ли их мелким шрифтом, но получить число БЕЗ них он не может.
"""
value: float | str | None
sample_n: int | None
note: str | None
computed_at: datetime
_STATS_LIMIT = 30
_stats_limiter = SlidingWindowLimiter(limit=_STATS_LIMIT, window_s=_WINDOW_S)
# Все строки витрины — их единицы, LIMIT не нужен, но потолок пусть будет:
# таблица наполняется только ночной задачей, и если она когда-нибудь начнёт
# писать метрику на город, ручка не должна молча вырасти в мегабайты.
_STATS_SQL = text("""
SELECT metric, value_num, value_text, sample_n, note, computed_at
FROM landing_stats
ORDER BY metric
LIMIT 200
""")
@router.get("/stats", response_model=dict[str, LandingStat])
def public_stats(
request: Request,
db: Annotated[Session, Depends(get_db)],
) -> dict[str, LandingStat]:
"""Витринные метрики лэндинга — готовый ночной срез (issue: числа по проду).
GET, в отличие от соседей: здесь в запросе нет ни адреса, ни чего-либо
относящегося к посетителю, поэтому довод «URI попадает в access-лог» не
работает, а кэшируемость GET'а для страницы, которую открывают все, полезна.
Читает готовые строки, НЕ считает на лету: агрегаты по offer_price_history с
подзапросами на листинг секунды, а анонимная ручка, которая стоит секунду
CPU, это рычаг DoS. Считает их app/tasks/landing_stats.py раз в сутки.
Пустая таблица валидные `{}` и 200. Это штатное состояние сразу после
накатки миграции (задача ещё не отработала) и оно же состояние «данных для
метрики нет»: задача не пишет строку, когда мерить нечего. Фронт обязан это
пережить и не рисовать блок, а не получить 500 и сломанную страницу.
`value` числовое value_num, если оно есть; иначе value_text (для метрик,
у которых значение не число). Оба NULL отдаём null, а не выдуманный ноль.
"""
_enforce(_stats_limiter, request, "stats")
rows = db.execute(_STATS_SQL).fetchall()
return {
row.metric: LandingStat(
value=(float(row.value_num) if row.value_num is not None else row.value_text),
sample_n=row.sample_n,
note=row.note,
computed_at=row.computed_at,
)
for row in rows
}
class ShowcaseDeal(BaseModel):
"""Одна строка витрины «МЕРА сказала X — продали за Y».
`district` / `floor` / `total_floors` НУЛЛАБЕЛЬНЫ намеренно: этих величин в
ДКП-данных может не быть, и фронт обязан пережить null, а не получить
правдоподобную подстановку. Улицы и дома в модели нет вовсе номер дома
есть у 2.7% сделок (разбор в миграции 276).
"""
district: str | None
rooms: int
area_m2: float
floor: int | None
total_floors: int | None
deal_quarter: str
predicted_rub: int
fact_rub: int
err_pct: float
n_analogs: int
note: str
class ShowcaseStats(BaseModel):
"""Итог прогона, который дал показанные строки. Подпись под витриной.
Без этих чисел «20 отличных строк» неотличимо от «столько и было»:
посетитель не может отличить выборку из работы оценщика от её лучшего
хвоста. `eligible` минус `written` сколько годных строк не поместилось
в витрину; `rejection_rule` по какому правилу отсеяно остальное,
записанное ТЕМ прогоном, который эти строки посчитал.
"""
considered: int
priced: int
no_prediction: int
incomplete: int
eligible: int
written: int
with_district: int
rejection_rule: str
class ShowcaseResponse(BaseModel):
"""Витрина целиком: когда считали, что показываем и из чего это отобрано.
`stats` = None только до первого пересчёта тогда и `deals` пуст.
"""
computed_at: str | None
deals: list[ShowcaseDeal]
stats: ShowcaseStats | None = None
# Последний прогон — единственная точка отсчёта: и `computed_at`, и счётчики, и
# набор строк берутся ИЗ НЕГО. Брать строки по своему max(computed_at) значило
# бы, что прогон, не давший ни одной строки, показывает вчерашние строки под
# сегодняшними счётчиками.
_SHOWCASE_RUN_SQL = text(
"""
SELECT computed_at, considered, priced, no_prediction, incomplete,
eligible, written, with_district, rejection_rule
FROM landing_showcase_runs
ORDER BY computed_at DESC, id DESC
LIMIT 1
"""
)
_SHOWCASE_SQL = text(
"""
SELECT district, rooms, area_m2, floor, total_floors, deal_quarter,
predicted_rub, fact_rub, err_pct, n_analogs, note
FROM landing_showcase_deals
WHERE computed_at = CAST(:computed_at AS timestamptz)
ORDER BY id
"""
)
@router.get("/showcase", response_model=ShowcaseResponse)
def public_showcase(
request: Request,
db: Annotated[Session, Depends(get_db)],
) -> ShowcaseResponse:
"""Витрина лэндинга: реальные ДКП-сделки против прогноза МЕРЫ.
Читает готовый батч из `landing_showcase_deals` (пересчёт
`app/tasks/landing_showcase_deals.py`), а не считает прогноз на лету:
один прогноз это несколько пространственных SELECT'ов, двадцать штук на
анонимный GET были бы рычагом для DoS.
Пустой список штатный ответ, а не ошибка: до первого пересчёта показывать
нечего, и это ровно то, что фронт должен увидеть вместо выдуманных строк.
Вместе со строками едет `stats` сколько сделок рассмотрено, сколько
годных строк не поместилось и по какому правилу отсеяно остальное. Числа
считает пересчёт; без них витрина не имеет права подписаться честно.
"""
_enforce(_showcase_limiter, request, "showcase")
run = db.execute(_SHOWCASE_RUN_SQL).mappings().first()
if run is None:
return ShowcaseResponse(computed_at=None, deals=[], stats=None)
rows = db.execute(_SHOWCASE_SQL, {"computed_at": run["computed_at"]}).mappings().all()
return ShowcaseResponse(
computed_at=run["computed_at"].isoformat(),
stats=ShowcaseStats(
considered=int(run["considered"]),
priced=int(run["priced"]),
no_prediction=int(run["no_prediction"]),
incomplete=int(run["incomplete"]),
eligible=int(run["eligible"]),
written=int(run["written"]),
with_district=int(run["with_district"]),
rejection_rule=run["rejection_rule"],
),
deals=[
ShowcaseDeal(
district=r["district"],
rooms=int(r["rooms"]),
area_m2=float(r["area_m2"]),
floor=(int(r["floor"]) if r["floor"] is not None else None),
total_floors=(int(r["total_floors"]) if r["total_floors"] is not None else None),
deal_quarter=r["deal_quarter"],
predicted_rub=int(r["predicted_rub"]),
fact_rub=int(r["fact_rub"]),
err_pct=float(r["err_pct"]),
n_analogs=int(r["n_analogs"]),
note=r["note"],
)
for r in rows
],
)
# ── Анонимный расчёт и повторное чтение его бесплатной части ────────────────
#
# ФЛАГ. Обе ручки ниже мертвы, пока `settings.public_estimate_enabled` не
# включён в .env.runtime: это первая пара публичных ручек, которая ПИШЕТ в
# `trade_in_estimates` адрес физлица, а такое включение — продуктовое решение
# владельца (нужна правка п.5.5 политики обработки ПДн, она сегодня анонимный
# расчёт не описывает), а не следствие мержа кода. Тот же приём, что у
# `payments_enabled`. Отвечаем 404, а не 403: выключенная ручка не должна
# подтверждать, что она существует.
#
# ОБЩИЙ СУТОЧНЫЙ ПОТОЛОК. Per-IP окно ограничивает одного клиента, но не сумму:
# 5/мин с адреса — это 7200 расчётов в сутки, каждый из которых дёргает
# геокодер и пишет строку. Потолок ниже — про защиту закрытого контура и
# квоты геокодера (тот же довод, что у `_DAILY_SUGGEST_BUDGET`), исчерпание —
# сигнал абуза, не штатный режим.
_DAILY_ESTIMATE_BUDGET = 300
_daily_estimate_limiter = SlidingWindowLimiter(limit=_DAILY_ESTIMATE_BUDGET, window_s=86_400.0)
_ESTIMATE_GLOBAL_KEY = "public-estimate"
# Срок жизни капабилити-ссылки. Ссылка — единственный способ анонима вернуться
# к своему расчёту (аккаунта у него нет), поэтому недели мало не будет для
# «оплатил, закрыл вкладку, вернулся вечером», а бесконечной она быть не может:
# это ссылка на данные о конкретной квартире конкретного человека.
_TOKEN_TTL = "7 days"
def _require_public_estimate_enabled() -> None:
"""404, пока публичный расчёт не включён владельцем явно."""
if not settings.public_estimate_enabled:
raise HTTPException(status_code=404, detail="Not Found")
def _token_hash(token: str) -> str:
"""sha256(hex) — в БД лежит только это, сам токен не хранится нигде.
Без соли намеренно: вход `secrets.token_urlsafe(32)` (256 бит), перебор
по словарю невозможен, а соль сделала бы невозможным поиск по равенству.
"""
return hashlib.sha256(token.encode()).hexdigest()
class PublicEstimateInput(TradeInEstimateInput):
"""Вход анонимного расчёта: тот же payload, что у закрытого контура, но
согласие 152-ФЗ ОБЯЗАТЕЛЬНОЕ и строго True.
В базовой схеме `consent: bool | None = None`: сделать его обязательным там
нельзя B2B-пилоты согласия в UI не дают, у них договор, и их фронт поля
не шлёт. Здесь же анонимный посетитель единственный источник согласия,
поэтому `Literal[True]`: без него Pydantic отвечает 422 ДО входа в
хендлер, то есть до первого обращения к БД. Это не дубль гейта в
`estimate_quality` (тот ловит любых вызывающих), а его сдвиг на самую
раннюю возможную границу «согласие фиксируется до первого INSERT»
перестаёт зависеть от порядка строк внутри эстиматора.
"""
consent: Literal[True]
class PublicEstimateTokenInput(BaseModel):
"""Токен едет ТЕЛОМ, а ручка чтения — POST, а не GET /{token}.
Причина ровно та же, по которой POST'ом сделан `/suggest`: access-лог Caddy
на публичном домене пишет URI целиком, поэтому капабилити-ссылка в пути
легла бы в файл рядом с IP посетителя и любой, у кого есть доступ к
логам (или их бэкапу), открыл бы чужой расчёт. Токен это пароль; пароли
в URL не кладут.
"""
token: str = Field(min_length=16, max_length=128)
class PublicEstimateResult(BaseModel):
"""БЕСПЛАТНАЯ часть расчёта — ровно то, что можно показать до оплаты.
Здесь СОЗНАТЕЛЬНО нет ни одного поля из `AggregatedEstimate` с ценой,
прогнозом или списком аналогов (`median_price_rub`, `range_*`,
`expected_sold_*`, `analogs`, `actual_deals`, `market_percentile`,
`est_days_on_market`, `cian_valuation`, `avito_imv`, `dkp_corridor`).
Модель отдельная, а не `AggregatedEstimate` с `exclude`: список исключений
надо помнить и дополнять при каждом новом поле эстиматора, а отдельная
модель молчит по умолчанию новое платное поле не утечёт само.
`coverage` тот же ответ, что у бесплатной пробы `/coverage` (в нём цен
нет по построению, см. `CoverageProbeResponse`): число похожих квартир,
возраст объявлений, вердикт покрытия. null координаты дома не
разрезолвились, честнее отдать «неизвестно», чем правдоподобное число.
"""
token: str
token_expires_at: datetime
n_analogs: int
coverage: CoverageProbeResponse | None = None
def _coverage_for(
db: Session, lat: float | None, lon: float | None, rooms: int, area_m2: float
) -> CoverageProbeResponse | None:
"""Вердикт покрытия для уже посчитанной оценки — через ту же `coverage_probe`.
Своей копии порогов здесь нет намеренно: разъедься она с бесплатной пробой,
один и тот же адрес получил бы «есть данные» на одном экране и «мало» на
соседнем.
"""
if lat is None or lon is None:
return None
return coverage_probe(
payload=CoverageProbeInput(lat=lat, lon=lon, rooms=rooms, area_m2=area_m2),
db=db,
)
@router.post("/estimate", response_model=PublicEstimateResult)
async def public_estimate(
request: Request,
response: Response,
payload: PublicEstimateInput,
db: Annotated[Session, Depends(get_db)],
) -> PublicEstimateResult:
"""Анонимный расчёт: считает полную оценку, отдаёт только бесплатную часть.
Делегирует в `app.api.v1.trade_in.estimate` ту же функцию, что обслуживает
закрытый контур, а не копию её тела. Оттуда же бесплатно достаётся всё
анти-абузное хозяйство: анонимная квота на связку (подписанная cookie + IP)
с лимитом `settings.anon_estimate_quota_limit`, семафор одновременности,
503 вместо непрозрачного 502 при сбое и consent-гейт (`require_consent`
включается ровно потому, что мы зовём её без `X-Authenticated-User`).
Полный расчёт при этом СОХРАНЯЕТСЯ в `trade_in_estimates` целиком платный
контур (сосед, `/api/v1/trade-in/r/{token}`) открывает его после оплаты по
своему токену. Наружу здесь уезжает только `PublicEstimateResult`.
"""
_require_public_estimate_enabled()
_enforce(_estimate_limiter, request, "estimate")
daily_retry = _daily_estimate_limiter.retry_after(_ESTIMATE_GLOBAL_KEY)
if daily_retry is not None:
logger.error(
"публичные расчёты исчерпали суточный бюджет (%d) — закрытый контур "
"защищён, но форма на лэндинге сейчас не считает",
_DAILY_ESTIMATE_BUDGET,
)
raise HTTPException(
status_code=429,
detail="Расчёт временно недоступен. Попробуйте позже.",
headers={"Retry-After": str(int(daily_retry) + 1)},
)
_daily_estimate_limiter.record(_ESTIMATE_GLOBAL_KEY)
# Пометка публичного запроса — как у `/suggest`: внутри цепочки геокодер
# печатает введённый адрес, а публичная форма обещает обратное.
with public_request_scope():
result = await estimate(
payload=payload,
request=request,
response=response,
db=db,
x_authenticated_user=None,
)
# Токен выдаём ПОСЛЕ успешного расчёта: ссылка на несуществующий результат
# не нужна никому, а строка в БД уже есть — estimate() её закоммитил.
token = secrets.token_urlsafe(32)
row = db.execute(
text(
"""
UPDATE trade_in_estimates
SET public_token_hash = :token_hash,
public_token_expires_at = NOW() + CAST(:ttl AS interval)
WHERE id = CAST(:id AS uuid)
RETURNING public_token_expires_at
"""
),
{"token_hash": _token_hash(token), "ttl": _TOKEN_TTL, "id": str(result.estimate_id)},
).fetchone()
db.commit()
if row is None: # pragma: no cover — оценка только что записана этой же транзакцией
raise HTTPException(status_code=503, detail="estimate temporarily unavailable")
return PublicEstimateResult(
token=token,
token_expires_at=row.public_token_expires_at,
n_analogs=result.n_analogs,
coverage=_coverage_for(
db, result.target_lat, result.target_lon, payload.rooms, payload.area_m2
),
)
@router.post("/estimate/read", response_model=PublicEstimateResult)
def public_estimate_read(
request: Request,
payload: PublicEstimateTokenInput,
db: Annotated[Session, Depends(get_db)],
) -> PublicEstimateResult:
"""Повторное чтение бесплатной части по капабилити-токену.
Существует потому, что у анонима нет аккаунта: без этой ручки результат
жил бы ровно в теле POST-ответа и не переживал бы перезагрузку страницы
(`_assert_estimate_access` в закрытом контуре отдаёт 404 на оценку с
`created_by IS NULL` всем, кроме админа и это правильно, менять его
ради анонима значило бы ослабить IDOR-гейт для всех).
Просрочка и «нет такого токена» отвечают ОДИНАКОВО (404): различать их
значит подтверждать существование расчёта тому, кто угадал токен.
"""
_require_public_estimate_enabled()
_enforce(_estimate_read_limiter, request, "estimate-read")
row = db.execute(
text(
"""
SELECT n_analogs, lat, lon, rooms, area_m2, public_token_expires_at
FROM trade_in_estimates
WHERE public_token_hash = :token_hash
AND public_token_expires_at > NOW()
"""
),
{"token_hash": _token_hash(payload.token)},
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Расчёт не найден или ссылка устарела.")
return PublicEstimateResult(
token=payload.token,
token_expires_at=row.public_token_expires_at,
n_analogs=row.n_analogs,
coverage=_coverage_for(db, row.lat, row.lon, row.rooms, row.area_m2),
)

View file

@ -0,0 +1,797 @@
"""Платёжный роутер МЕРЫ (Т-Банк эквайринг) — PR-D3: checkout, нотификация, выдача.
Проводка уже готовых слоёв: `app/services/payments/*` (подпись, разбор, httpx-
клиент PR-C, БД и конфига не знают), схема `data/sql/233_payments.sql` (PR-B),
периметр (`ratelimit`/`request_audit`/`sentry_scrub` PR-D2). Здесь только
то, чего не было: HTTP-ручки и статус-машина.
ВСЁ за kill-switch `settings.payments_enabled` (дефолт False): при выключенном
контуре каждая ручка отвечает 503 и не ходит ни в банк, ни в платёжные таблицы.
Merge безопасен на выключенном контуре на проде это ровно ноль изменений
поведения, пока владелец не выставит PAYMENTS_ENABLED=true вместе с ключами
терминала (`app/main.py` роняет старт, если включить без ключей).
Идемпотентность держится на БД, а не на «проверить-потом-вставить»
Все три гонки, которые здесь реальны (двойной клик по кнопке оплаты; банк шлёт
AUTHORIZED и CONFIRMED одновременно; банк ретраит нотификацию почасово сутки),
закрыты UNIQUE-ключами миграций 233 и 279 + `ON CONFLICT DO NOTHING`. Пара
«SELECT, потом INSERT» здесь была бы дефектом: между ними успевает пройти
параллельный запрос, и выдача (или холд на карте) происходит дважды. SELECT
живого платежа в `checkout` остался, но только как быстрый путь для честного
повтора гонку ловит не он, а `payments_live_estimate_product_uidx`.
`payment_notifications.processed_at` единственный признак «выдача
состоялась». Наличие строки нотификации таким признаком НЕ является: процесс
мог упасть между INSERT нотификации и выдачей, и тогда ретрай банка обязан
довести выдачу до конца, а не ответить "OK" на полпути (см. блок про
processed_at в шапке 233_payments.sql).
Никаких исходящих HTTP внутри notify
Бюджет одного вызова `TBankClient` до ~74 с (см. его докстринг), окно ответа
банку порядка 10 с. Поэтому обработчик нотификации только валидирует,
сохраняет и отвечает `"OK"` текстом (банк ждёт именно эту строку); любая сверка
с банком (GetState/CheckOrder) задача реконсиляции, вне HTTP-цикла.
Доставка купленного: capability-ссылка
`/r/<token>` непредсказуемый `secrets.token_urlsafe(32)`, лежит в
`payment_entitlements.subject` (колонка документирована как «username или
anon-token, кому выдано»). Право доступа сам токен: у покупателя-физлица на
meraocenka.ru идентичности нет и не будет. `ref_id` при этом остаётся
`estimate_id` как задокументировано в миграции: именно на `(payment_id, kind,
ref_id)` держится UNIQUE «выдали один раз», и подстановка туда случайного
токена молча отменила бы эту гарантию (каждый повтор дал бы новый ref_id
новую строку).
Токен НЕ логируется: ни в `logger.*` здесь, ни в GlitchTip путь `/r/<token>`
режет `redact_report_link_token` в `app/observability/sentry_scrub.py`.
"""
from __future__ import annotations
import asyncio
import json
import logging
import secrets
from datetime import UTC, datetime
from typing import Annotated, Any
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api.v1.trade_in import (
ESTIMATE_READABLE_SQL,
_assert_estimate_access,
load_estimate,
)
from app.core.config import settings
from app.core.db import get_db
from app.schemas.trade_in import AggregatedEstimate
from app.services.payments.notification import NotificationParseError, parse_notification
from app.services.payments.receipt import ReceiptItem, build_receipt, receipt_total_kopecks
from app.services.payments.tbank_client import TBankApiError, TBankClient
from app.services.payments.token import verify_notification_token
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Каталог ───────────────────────────────────────────────────────────────────
# Цена — НЕ из тела запроса (иначе клиент назначает её сам), а из этой таблицы
# по product_code. 15 000 копеек = 150 ₽ = `SERVICE_PRICE_RUB` в
# frontend/src/app/mera-public/content.ts, где цена названа в оферте (п. 4.1) и
# в политике возврата. Рассинхронизацию ловит тест
# tests/test_payments_router.py::test_price_matches_published_offer — цена,
# отличающаяся от опубликованной в оферте, это не баг рендера, а неисполнение
# договора.
PRODUCT_PAID_REPORT = "paid_report"
_PRODUCTS: dict[str, tuple[str, int]] = {
# product_code: (наименование позиции чека — <=128 символов, цена в копейках)
PRODUCT_PAID_REPORT: ("Оценка стоимости квартиры (онлайн-отчёт)", 15_000),
}
ENTITLEMENT_REPORT_LINK = "report_link"
# Полный список из CHECK payments_status_check (233_payments.sql). Любой статус
# вне списка пишется как 'UNKNOWN' — контракт миграции: тихо исказить статус
# хуже, чем громко упасть, но и падать на нотификации нельзя (банк ретраит
# сутки). Дрейф относительно миграции ловит
# tests/test_payments_router.py::test_status_whitelist_matches_migration.
_KNOWN_STATUSES = frozenset(
{
"NEW",
"FORM_SHOWED",
"DEADLINE_EXPIRED",
"CANCELED",
"PREAUTHORIZING",
"AUTHORIZING",
"AUTHORIZED",
"AUTH_FAIL",
"REJECTED",
"3DS_CHECKING",
"3DS_CHECKED",
"CHECKING",
"CHECKED",
"PROCESSING",
"CONFIRMING",
"CONFIRMED",
"COMPLETING",
"COMPLETED",
"REVERSING",
"PARTIAL_REVERSED",
"REVERSED",
"REFUNDING",
"PARTIAL_REFUNDED",
"REFUNDED",
"REFUND_FAILED",
"UNKNOWN",
}
)
# Статусы ДО подтверждения: нотификация с таким статусом не имеет права
# затереть уже проставленный CONFIRMED. Банк не гарантирует порядок доставки
# (AUTHORIZED и CONFIRMED уходят одновременно при одностадийной оплате), а
# ретрай «отставшей» нотификации может прийти через час — без этой проверки
# оплаченный платёж откатился бы в AUTHORIZED и отчёт перестал бы выдаваться.
_PRE_CONFIRM_STATUSES = frozenset(
{
"NEW",
"FORM_SHOWED",
"PREAUTHORIZING",
"AUTHORIZING",
"AUTHORIZED",
"3DS_CHECKING",
"3DS_CHECKED",
"CHECKING",
"CHECKED",
"PROCESSING",
"CONFIRMING",
}
)
# Платёж в одном из этих статусов ещё «живой»: повторный checkout по той же
# оценке обязан вернуть ту же ссылку, а не создавать второй холд на карте
# покупателя. Терминальные (CANCELED/REJECTED/REFUNDED/...) сюда не входят —
# после отказа человек вправе попробовать оплатить заново.
#
# Этот же список — предикат частичного UNIQUE(estimate_id, product_code)
# миграции 279, который и делает «один живой платёж» свойством БД, а не
# порядка выполнения. Расхождение кода и миграции ловит
# tests/test_payments_router.py::test_live_status_predicate_matches_code.
_REUSABLE_STATUSES = _PRE_CONFIRM_STATUSES
# Статусы, из которых платёж НИКОГДА не выйдет сам: банк шлёт нотификации по
# исходу платежа, а не по факту «форма открыта», поэтому брошенный checkout
# остаётся в NEW/FORM_SHOWED навсегда. Без границы по времени покупатель
# получал бы на каждый повторный checkout одну и ту же ссылку — а она у банка
# уже протухла, и начать оплату заново становилось бы невозможно.
#
# Границу двигаем ТОЛЬКО по этим двум статусам. Всё, что дальше по цепочке
# (AUTHORIZING/AUTHORIZED/3DS_*/CHECKING/PROCESSING/CONFIRMING), означает, что
# карточный поток уже начался и деньги, возможно, захолдированы: пометить такое
# «просроченным» и выдать вторую ссылку — это и есть второй холд. Разгребать
# зависшие карточные статусы — работа реконсиляции (PR-E, GetState), а не
# checkout'а.
_ABANDONABLE_STATUSES = frozenset({"NEW", "FORM_SHOWED"})
# 30 минут. Обоснование срока: за это окно нельзя «случайно» не дойти до карты —
# ввод карты и 3DS укладываются в минуты, а как только поток начался, статус
# уходит из _ABANDONABLE_STATUSES и окно к платежу вообще не применяется.
# Значит, к моменту истечения окна карточный поток провабельно не начинался, и
# освободить пару (estimate_id, product_code) под новую попытку безопасно.
# Остаточный риск честно называем: если покупатель через час всё-таки дооплатит
# СТАРУЮ форму, банк подтвердит её (выдача состоится по ней — статус-машина
# ниже это переживает), а новая попытка так и останется неоплаченной; два
# списания требуют, чтобы человек намеренно оплатил обе формы.
_ABANDONED_AFTER_MINUTES = 30
_ORDER_ID_PREFIX = "mera-"
# 32 байта энтропии (43 символа base64url) — перебор capability-ссылки
# неосуществим, а сама ссылка остаётся кликабельной в мессенджере.
_REPORT_TOKEN_BYTES = 32
def _require_enabled() -> None:
"""Kill-switch контура. 503, а не 404: путь существует, приём оплаты выключен."""
if not settings.payments_enabled:
raise HTTPException(status_code=503, detail="payments are disabled")
def _client() -> TBankClient:
return TBankClient(
terminal_key=settings.tbank_terminal_key,
password=settings.tbank_password.get_secret_value(),
base_url=settings.tbank_api_base_url,
)
class CheckoutInput(BaseModel):
estimate_id: UUID
product_code: str = Field(default=PRODUCT_PAID_REPORT, max_length=64)
# Чек 54-ФЗ требует Email ИЛИ Phone. Оба опциональны здесь и проверяются
# только когда чек включён (TBANK_RECEIPT_ENABLED) — до подключения ОФД
# требовать контакт незачем.
customer_email: str | None = Field(default=None, max_length=254)
customer_phone: str | None = Field(default=None, max_length=32)
class CheckoutOut(BaseModel):
order_id: str
payment_url: str
amount_kopecks: int
status: str
class ReportLinkOut(BaseModel):
"""Статус оплаты для экрана «после оплаты».
`report_url` None, пока выдача не состоялась. Именно None, а не
правдоподобная ссылка «которая скоро заработает»: пустой результат честнее
ссылки, ведущей в 404.
"""
order_id: str
status: str
report_url: str | None
@router.post("/payments/checkout", response_model=CheckoutOut)
def checkout(
payload: CheckoutInput,
db: Annotated[Session, Depends(get_db)],
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> CheckoutOut:
"""Создаёт платёж и возвращает `PaymentURL` формы Т-Банка.
Идемпотентность свойство БД, а не порядка выполнения: частичный UNIQUE
(estimate_id, product_code) по живым статусам (миграция 279) физически не
даёт существовать двум живым платежам по одной оценке, а `ON CONFLICT DO
NOTHING` превращает проигрыш в гонке в ответ, а не во второй `Init` (и,
значит, во второй холд на карте покупателя).
Три исхода: живой платёж с готовой ссылкой 200 с ТОЙ ЖЕ ссылкой; параллельный
checkout ещё не дошёл до ответа банка 409 (ретрай через секунду вернёт
ссылку); иначе создаём новый платёж.
"""
_require_enabled()
product = _PRODUCTS.get(payload.product_code)
if product is None:
raise HTTPException(status_code=400, detail="unknown product_code")
item_name, amount_kopecks = product
estimate = db.execute(
text(
f"""
SELECT id, created_by
FROM trade_in_estimates
WHERE id = CAST(:id AS uuid) AND {ESTIMATE_READABLE_SQL}
"""
),
{"id": str(payload.estimate_id)},
).fetchone()
if estimate is None:
raise HTTPException(status_code=404, detail="estimate not found or expired")
if estimate.created_by is not None:
# Тот же IDOR-гвард (#690), что у остальных ручек по оценке. Он здесь не
# про «показать чужой отчёт напрямую», а про две другие двери: checkout
# по чужому estimate_id возвращает order_id чужого живого платежа (ветка
# переиспользования ниже), а order_id — это право доступа для
# /payments/status/<order_id>, который отдаёт capability-ссылку на отчёт,
# как только владелец заплатит.
#
# Проверяем только оценки, у которых владелец ЕСТЬ. У анонимной покупки
# на meraocenka.ru идентичности нет (см. блок про capability-ссылку в
# шапке модуля), и правом там работает сам неугадываемый estimate_id —
# требовать заголовок означало бы сделать анонимный checkout
# невозможным, а не более безопасным.
_assert_estimate_access(estimate.created_by, x_authenticated_user)
# Освобождаем пару (estimate_id, product_code) от брошенных попыток ДО
# проверки живого платежа: иначе и переиспользование вернуло бы мёртвую
# ссылку, и UNIQUE миграции 279 не дал бы создать новую (см. комментарий у
# _ABANDONED_AFTER_MINUTES).
db.execute(
text(
"""
UPDATE payments
SET status = 'DEADLINE_EXPIRED', updated_at = NOW()
WHERE estimate_id = CAST(:estimate_id AS uuid)
AND product_code = :product_code
AND status = ANY(CAST(:abandonable AS text[]))
AND created_at < NOW() - make_interval(mins => CAST(:mins AS int))
"""
),
{
"estimate_id": str(payload.estimate_id),
"product_code": payload.product_code,
"abandonable": sorted(_ABANDONABLE_STATUSES),
"mins": _ABANDONED_AFTER_MINUTES,
},
)
existing = _find_live_payment(db, payload)
if existing is not None:
return CheckoutOut(
order_id=existing.order_id,
payment_url=existing.payment_url,
amount_kopecks=existing.amount_kopecks,
status=existing.status,
)
receipt = _build_receipt_or_none(item_name, amount_kopecks, payload)
# order_id генерируем СВОЙ и до похода в банк: он и есть ключ, по которому
# нотификация найдёт платёж, а UNIQUE(order_id) — страховка от двойной
# записи. 5 + 32 = 37 символов, влезает в CHECK(char_length <= 50).
order_id = f"{_ORDER_ID_PREFIX}{uuid4().hex}"
inserted = db.execute( # fetchone() ДО commit(): курсор после коммита пуст
text(
"""
INSERT INTO payments (
order_id, terminal_key, product_code, amount_kopecks, status,
created_by, estimate_id, customer_email, customer_phone
) VALUES (
:order_id, :terminal_key, :product_code, :amount, 'NEW',
:created_by, CAST(:estimate_id AS uuid), :email, :phone
)
ON CONFLICT DO NOTHING
RETURNING order_id
"""
),
{
"order_id": order_id,
"terminal_key": settings.tbank_terminal_key,
"product_code": payload.product_code,
"amount": amount_kopecks,
"created_by": x_authenticated_user,
"estimate_id": str(payload.estimate_id),
"email": payload.customer_email,
"phone": payload.customer_phone,
},
).fetchone()
db.commit()
if inserted is None:
# Гонку выиграл параллельный checkout (двойной клик). Своего Init не
# делаем ни при каких условиях — он и есть второй холд.
rival = _find_live_payment(db, payload)
if rival is not None:
return CheckoutOut(
order_id=rival.order_id,
payment_url=rival.payment_url,
amount_kopecks=rival.amount_kopecks,
status=rival.status,
)
# Соперник вставил строку, но ответа банка ещё не получил: ссылки пока
# нет ни у кого. Честный 409 — придумать ссылку нечем, а ждать чужого
# Init внутри HTTP-цикла нельзя (его бюджет — до ~74 с).
logger.info("checkout: параллельный checkout ещё в полёте, estimate_id известен клиенту")
raise HTTPException(status_code=409, detail="checkout already in progress, retry shortly")
try:
# asyncio.run в синхронном хендлере — тот же мост, что и
# trade_in._try_revive_dead_estimate: Starlette гоняет `def`-хендлер в
# threadpool, поэтому свой event loop здесь никому не мешает, а
# остальные db.execute() остаются синхронными.
init = asyncio.run(
_client().init_payment(
order_id=order_id,
amount_kopecks=amount_kopecks,
description=item_name,
notification_url=settings.tbank_notification_url or None,
success_url=settings.tbank_success_url or None,
fail_url=settings.tbank_fail_url or None,
receipt=receipt,
pay_type=settings.tbank_pay_type,
)
)
except TBankApiError as exc:
# Запись остаётся в БД со статусом NEW и текстом ошибки — иначе факт
# попытки (и возможного холда, если обрыв случился после приёма запроса
# банком) не остался бы нигде. Слепой повтор Init по тому же order_id
# запрещён (см. докстринг init_payment) — это работа реконсиляции.
db.execute(
text(
"""
UPDATE payments
SET error_code = :code, error_message = :message, updated_at = NOW()
WHERE order_id = :order_id
"""
),
{"code": exc.error_code[:64], "message": str(exc)[:500], "order_id": order_id},
)
db.commit()
logger.warning("checkout: Init отклонён банком, order_id=%s: %s", order_id, exc)
raise HTTPException(status_code=502, detail="payment provider error") from exc
payment_url = init.get("PaymentURL")
tbank_payment_id = init.get("PaymentId")
if not isinstance(payment_url, str) or not payment_url:
# Success:true без PaymentURL — контракт банка нарушен; выдумывать
# ссылку нечем.
logger.error("checkout: Init без PaymentURL, order_id=%s", order_id)
raise HTTPException(status_code=502, detail="payment provider returned no payment url")
status = init.get("Status")
db.execute(
text(
"""
UPDATE payments
SET tbank_payment_id = :payment_id,
payment_url = :payment_url,
status = :status,
init_response = CAST(:init AS jsonb),
updated_at = NOW()
WHERE order_id = :order_id
"""
),
{
"payment_id": str(tbank_payment_id) if tbank_payment_id is not None else None,
"payment_url": payment_url,
"status": status if status in _KNOWN_STATUSES else "UNKNOWN",
"init": json.dumps(init, ensure_ascii=False),
"order_id": order_id,
},
)
db.commit()
return CheckoutOut(
order_id=order_id,
payment_url=payment_url,
amount_kopecks=amount_kopecks,
status=status if status in _KNOWN_STATUSES else "UNKNOWN",
)
def _find_live_payment(db: Session, payload: CheckoutInput) -> Any:
"""Живой платёж по этой оценке, у которого уже есть ссылка на форму.
`payment_url IS NOT NULL` обязателен: строка без ссылки это чужой checkout
в полёте (Init ещё не ответил), и отдавать по ней `payment_url=None` значило
бы соврать в схеме ответа. Такой случай 409 у вызывающей стороны.
"""
return db.execute(
text(
"""
SELECT order_id, payment_url, amount_kopecks, status
FROM payments
WHERE estimate_id = CAST(:estimate_id AS uuid)
AND product_code = :product_code
AND payment_url IS NOT NULL
AND status = ANY(CAST(:reusable AS text[]))
ORDER BY created_at DESC
LIMIT 1
"""
),
{
"estimate_id": str(payload.estimate_id),
"product_code": payload.product_code,
"reusable": sorted(_REUSABLE_STATUSES),
},
).fetchone()
def _build_receipt_or_none(
item_name: str, amount_kopecks: int, payload: CheckoutInput
) -> dict[str, Any] | None:
"""Чек 54-ФЗ — только когда владелец включил его и задал систему налогообложения.
Сверка `receipt_total_kopecks == Init.Amount` здесь и есть тот инвариант,
который `build_receipt` намеренно не проверяет у себя (см. его докстринг):
чек на сумму, отличную от списанной, это фискальное нарушение, а не
косметика.
"""
if not settings.tbank_receipt_enabled:
return None
if not settings.tbank_taxation:
logger.error("checkout: TBANK_RECEIPT_ENABLED=true, но TBANK_TAXATION не задан")
raise HTTPException(status_code=503, detail="receipt is not configured")
if not payload.customer_email and not payload.customer_phone:
raise HTTPException(status_code=400, detail="customer_email or customer_phone is required")
receipt = build_receipt(
items=[ReceiptItem(name=item_name, price_kopecks=amount_kopecks)],
taxation=settings.tbank_taxation, # type: ignore[arg-type]
email=payload.customer_email,
phone=payload.customer_phone,
)
if receipt_total_kopecks(receipt) != amount_kopecks:
logger.error("checkout: сумма чека разошлась с суммой заказа")
raise HTTPException(status_code=500, detail="receipt total mismatch")
return receipt
@router.post("/payments/notify")
async def notify(request: Request, db: Annotated[Session, Depends(get_db)]) -> Response:
"""Вебхук Т-Банка. Отвечает `"OK"` текстом — банк ждёт именно эту строку.
Любой другой ответ банк трактует как недоставленную нотификацию и ретраит.
Поэтому "OK" отдаётся и на том, что мы обработать не можем, но что нашей
проблемой не является (неизвестный OrderId); отказ (4xx) остаётся только
там, где принять событие означало бы солгать про деньги: неверная подпись
и расхождение суммы.
"""
_require_enabled()
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="malformed body") from None
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="malformed body")
token_valid = verify_notification_token(body, settings.tbank_password.get_secret_value())
# Пишем сырой лог ДО любых выводов о содержимом (включая невалидную
# подпись): append-only лог входящих — единственное место, где остаётся
# факт попытки. Ключи читаем «как есть», без разбора: разбор может упасть,
# запись факта — нет.
notif = _log_notification(db, body, token_valid=token_valid)
if not token_valid:
db.commit()
logger.warning("notify: подпись не сошлась — нотификация отвергнута")
raise HTTPException(status_code=403, detail="invalid token")
try:
parsed = parse_notification(body)
except NotificationParseError as exc:
db.commit()
logger.warning("notify: тело не разобралось: %s", exc)
raise HTTPException(status_code=400, detail="malformed notification") from None
if notif is not None and notif.processed_at is not None:
# Выдача по этой нотификации уже состоялась — ретрай банка.
db.commit()
return Response(content="OK", media_type="text/plain")
payment = db.execute(
text(
"""
SELECT id, order_id, status, amount_kopecks, estimate_id, created_by
FROM payments
WHERE order_id = :order_id
"""
),
{"order_id": parsed.order_id},
).fetchone()
if payment is None:
# Чужой/устаревший OrderId. Ретраи не помогут — отвечаем "OK", факт
# уже лежит в payment_notifications.
db.commit()
logger.warning("notify: нотификация по неизвестному order_id")
return Response(content="OK", media_type="text/plain")
if parsed.amount_kopecks != payment.amount_kopecks:
# Подпись Т-Банка конкатенирует значения БЕЗ разделителя, поэтому сама
# по себе не гарантирует сумму (см. докстринг notification.py). Сверка
# с суммой, записанной при Init, — единственная реальная защита.
db.commit()
logger.error(
"notify: сумма нотификации разошлась с суммой заказа (order_id=%s)", parsed.order_id
)
raise HTTPException(status_code=400, detail="amount mismatch")
status = parsed.status if parsed.status in _KNOWN_STATUSES else "UNKNOWN"
if not (payment.status == "CONFIRMED" and status in _PRE_CONFIRM_STATUSES):
_apply_status(db, payment.order_id, status, parsed.payment_id)
if status == "CONFIRMED" and parsed.success:
_fulfill(db, payment_id=payment.id, estimate_id=payment.estimate_id)
if notif is not None:
# processed_at ставится ПОСЛЕ выдачи — иначе ретрай банка увидел бы
# «обработано» по нотификации, выдача по которой не состоялась.
db.execute(
text(
"UPDATE payment_notifications SET processed_at = NOW() "
"WHERE id = :id AND processed_at IS NULL"
),
{"id": notif.id},
)
db.commit()
return Response(content="OK", media_type="text/plain")
def _log_notification(db: Session, body: dict[str, Any], *, token_valid: bool) -> Any:
"""INSERT ... ON CONFLICT DO NOTHING + добор уже существующей строки.
Дедуп делает БД (UNIQUE NULLS NOT DISTINCT на (tbank_payment_id, status,
amount_kopecks, token), миграция 233), а не «SELECT, потом INSERT»: между
проверкой и вставкой проходит параллельный ретрай банка, и выдача
происходит дважды.
Если строка уже была достаём её тем же ключом через IS NOT DISTINCT FROM
(зеркало NULLS NOT DISTINCT), потому что решение «выдавать или нет»
принимается по её `processed_at`, а не по факту существования.
"""
key = {
"order_id": _raw_str(body.get("OrderId")),
"payment_id": _raw_str(body.get("PaymentId")),
"status": _raw_str(body.get("Status")),
"amount": body.get("Amount") if isinstance(body.get("Amount"), int) else None,
"token": _raw_str(body.get("Token")),
}
inserted = db.execute(
text(
"""
INSERT INTO payment_notifications (
order_id, tbank_payment_id, status, amount_kopecks, token, token_valid, body
) VALUES (
:order_id, :payment_id, :status, :amount, :token, :token_valid,
CAST(:body AS jsonb)
)
ON CONFLICT DO NOTHING
RETURNING id, processed_at
"""
),
{**key, "token_valid": token_valid, "body": json.dumps(body, ensure_ascii=False)},
).fetchone()
if inserted is not None:
return inserted
return db.execute(
text(
"""
SELECT id, processed_at
FROM payment_notifications
WHERE tbank_payment_id IS NOT DISTINCT FROM :payment_id
AND status IS NOT DISTINCT FROM :status
AND amount_kopecks IS NOT DISTINCT FROM :amount
AND token IS NOT DISTINCT FROM :token
"""
),
key,
).fetchone()
def _raw_str(value: Any) -> str | None:
"""Значение для сырого лога: строка как есть, всё остальное — NULL.
Приводить чужие типы к строке здесь нельзя: дедуп-ключ должен совпадать у
оригинала и ретрая побайтово, а `str(1)` и `"1"` уже разные истории.
"""
return value if isinstance(value, str) else None
def _apply_status(db: Session, order_id: str, status: str, tbank_payment_id: str) -> None:
"""Двигает статус платежа и проставляет отметки времени переходов."""
db.execute(
text(
"""
UPDATE payments
SET status = :status,
tbank_payment_id = COALESCE(tbank_payment_id, :payment_id),
authorized_at = CASE WHEN :status = 'AUTHORIZED'
THEN COALESCE(authorized_at, NOW()) ELSE authorized_at END,
confirmed_at = CASE WHEN :status = 'CONFIRMED'
THEN COALESCE(confirmed_at, NOW()) ELSE confirmed_at END,
refunded_at = CASE WHEN :status IN ('REFUNDED', 'PARTIAL_REFUNDED',
'REVERSED', 'PARTIAL_REVERSED')
THEN COALESCE(refunded_at, NOW()) ELSE refunded_at END,
updated_at = NOW()
WHERE order_id = :order_id
"""
),
{"status": status, "payment_id": tbank_payment_id, "order_id": order_id},
)
def _fulfill(db: Session, *, payment_id: Any, estimate_id: Any) -> None:
"""Выдача: capability-ссылка на оплаченный отчёт + продление хранения оценки.
«Выдали один раз» гарантирует UNIQUE NULLS NOT DISTINCT (payment_id, kind,
ref_id) миграции 233: повторная нотификация не вернёт строку из RETURNING и
второй токен не родится. Проверять существование заранее нельзя гонка.
"""
if estimate_id is None:
logger.error("fulfill: платёж без estimate_id — выдавать нечего")
return
row = db.execute(
text(
"""
INSERT INTO payment_entitlements (payment_id, subject, kind, ref_id, expires_at)
VALUES (
CAST(:payment_id AS uuid), :subject, :kind, CAST(:ref_id AS uuid),
NOW() + make_interval(days => CAST(:days AS int))
)
ON CONFLICT DO NOTHING
RETURNING id
"""
),
{
"payment_id": str(payment_id),
"subject": secrets.token_urlsafe(_REPORT_TOKEN_BYTES),
"kind": ENTITLEMENT_REPORT_LINK,
"ref_id": str(estimate_id),
"days": settings.trade_in_paid_retention_days,
},
).fetchone()
if row is None:
logger.info("fulfill: выдача по этому платежу уже была — повтор не создаётся")
return
# Оплаченная оценка живёт год (retain_until, миграция 240) — иначе purge-
# джоба удалит строку через 24 часа и capability-ссылка укажет в пустоту.
# GREATEST — чтобы повторная покупка не УКОРАЧИВАЛА уже выданный срок.
db.execute(
text(
"""
UPDATE trade_in_estimates
SET retain_until = GREATEST(
COALESCE(retain_until, NOW()),
NOW() + make_interval(days => CAST(:days AS int))
)
WHERE id = CAST(:id AS uuid)
"""
),
{"days": settings.trade_in_paid_retention_days, "id": str(estimate_id)},
)
@router.get("/payments/status/{order_id}", response_model=ReportLinkOut)
def payment_status(
order_id: str,
db: Annotated[Session, Depends(get_db)],
) -> ReportLinkOut:
"""Экран «после оплаты»: статус заказа и ссылка на отчёт, когда выдача была.
Правом здесь работает сам `order_id` 128 бит случайности, известные
только браузеру покупателя (он же уходит в банк как OrderId и возвращается
на SuccessURL). Пока выдачи нет `report_url: null`.
"""
_require_enabled()
row = db.execute(
text(
"""
SELECT p.order_id, p.status, pe.subject AS report_token
FROM payments p
LEFT JOIN payment_entitlements pe
ON pe.payment_id = p.id AND pe.kind = :kind
WHERE p.order_id = :order_id
"""
),
{"order_id": order_id, "kind": ENTITLEMENT_REPORT_LINK},
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="order not found")
return ReportLinkOut(
order_id=row.order_id,
status=row.status,
report_url=f"/api/v1/trade-in/r/{row.report_token}" if row.report_token else None,
)
@router.get("/r/{token}", response_model=AggregatedEstimate)
def report_by_link(
token: str,
db: Annotated[Session, Depends(get_db)],
) -> AggregatedEstimate:
"""Оплаченный отчёт по capability-ссылке — БЕЗ RBAC, право доступа = токен.
Одна и та же 404 на «токена нет», «токен протух» и «оценка удалена»:
различать их значило бы подтверждать существование чужих токенов
перебирающему.
"""
_require_enabled()
row = db.execute(
text(
"""
SELECT ref_id, expires_at
FROM payment_entitlements
WHERE kind = :kind AND subject = :token
"""
),
{"kind": ENTITLEMENT_REPORT_LINK, "token": token},
).fetchone()
if row is None or row.ref_id is None:
raise HTTPException(status_code=404, detail="report not found")
if row.expires_at is not None and row.expires_at.replace(tzinfo=UTC) <= datetime.now(tz=UTC):
raise HTTPException(status_code=404, detail="report not found")
# capability_granted=True: право уже доказано токеном выше. Флаг не является
# полем запроса — подобрать его снаружи нельзя (см. докстринг load_estimate).
return load_estimate(
db, UUID(str(row.ref_id)), x_authenticated_user=None, capability_granted=True
)

View file

@ -627,6 +627,31 @@ def get_estimate(
Возвращает 404 если оценка не найдена или TTL истёк.
"""
return load_estimate(db, estimate_id, x_authenticated_user=x_authenticated_user)
def load_estimate(
db: Session,
estimate_id: UUID,
*,
x_authenticated_user: str | None,
capability_granted: bool = False,
) -> AggregatedEstimate:
"""Тело GET /estimate/{id} без FastAPI-обвязки — чтобы у ВТОРОГО права
доступа был тот же самый загрузчик, а не его копия.
`capability_granted=True` вызывающая сторона уже доказала право доступа
ДРУГИМ способом, чем `X-Authenticated-User` + roles.yaml: capability-ссылка
`/r/<token>` (app/api/v1/payments.py) отдаёт оплаченный отчёт анониму,
у которого идентичности нет и не будет там правом является сам
непредсказуемый токен, сверенный по `payment_entitlements`.
Флаг ИМЕННО параметр обычной функции, а не поле запроса: у route-хендлера
`get_estimate` выше его нет, поэтому подобрать его снаружи (query/заголовком)
невозможно включить его может только код в этом процессе. Обратное
(добавить параметр в сам хендлер с default=False) сделало бы обход IDOR-
гварда #690 доступным любому клиенту через `?capability_granted=true`.
"""
row = db.execute(
text(
f"""
@ -654,7 +679,8 @@ def get_estimate(
if row is None:
raise HTTPException(status_code=404, detail="estimate not found or expired")
_assert_estimate_access(row.created_by, x_authenticated_user)
if not capability_granted:
_assert_estimate_access(row.created_by, x_authenticated_user)
# #incident-2026-08-10: строка «мертва» (median_price<=0/NULL) — посчитана
# ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825). Пробуем

View file

@ -1226,5 +1226,13 @@ class Settings(BaseSettings):
# обязаны отказывать сразу, ничего не вызывая у T-Bank.
payments_enabled: bool = Field(default=False, validation_alias="PAYMENTS_ENABLED")
# Kill-switch анонимного расчёта на публичном домене (POST/GET
# /api/public/mera/estimate*). false — обе ручки отвечают 404, как будто их
# нет: включение публичного расчёта открывает запись адреса физлица в
# trade_in_estimates, и решение это продуктовое (нужна правка п.5.5 политики
# обработки ПДн), а не «смержили код». Тот же приём и та же причина, что у
# payments_enabled выше. ENV: PUBLIC_ESTIMATE_ENABLED.
public_estimate_enabled: bool = Field(default=False, validation_alias="PUBLIC_ESTIMATE_ENABLED")
settings = Settings()

View file

@ -114,8 +114,42 @@ _PUBLIC_PATHS = frozenset(
# держится на структуре пакета app/api/public/, а не на матчере.
"/api/public/mera/suggest",
"/api/public/mera/coverage",
# Витринные числа лэндинга (landing_stats, миграция 275): агрегаты по
# проду без единой персональной строки — их и показывают анонимному
# посетителю, ради чего метрики и считаются.
"/api/public/mera/stats",
# Витрина реальных ДКП-сделок против прогноза (миграция 276): читает
# СВОЮ таблицу-витрину, где по построению нет ни адреса, ни владельца —
# район + характеристики квартиры + пара «прогноз/факт».
"/api/public/mera/showcase",
# Анонимный расчёт и повторное чтение его бесплатной части. Оба
# POST с точным путём: у чтения токен едет ТЕЛОМ, а не в URI, иначе
# капабилити-ссылка легла бы в access-лог Caddy рядом с IP посетителя
# (тот же довод, что у /suggest — см. app/api/public/mera.py).
# Обе ручки дополнительно закрыты флагом settings.public_estimate_enabled.
"/api/public/mera/estimate",
"/api/public/mera/estimate/read",
# Вебхук Т-Банка (app/api/v1/payments.py::notify): сервер-к-серверу,
# X-Authenticated-User/сессии у банка нет и быть не может. Путь не
# секрет — аутентификацией здесь работает подпись `Token` тела,
# которую проверяет сам хендлер (services/payments/token.py), плюс
# отдельный узкий rate-limit (core/ratelimit.py). Всё остальное
# платёжное (checkout, статус заказа) остаётся ЗАКРЫТЫМ — открыт ровно
# тот путь, который иначе получил бы 401 у банка.
"/api/v1/trade-in/payments/notify",
}
)
# Capability-ссылка на оплаченный отчёт: /api/v1/trade-in/r/<token>. Точной
# строкой её в множество выше не положить — токен переменный, а множество
# проверяется как `path in`. Отдельный кортеж префиксов, а не превращение
# `_PUBLIC_PATHS` в префиксный матчер: тот механизм держит auth-гейт всего
# бэкенда, расширять его семантику ради одного роута нельзя.
#
# Открывается ИМЕННО подпуть /r/ и ничего выше: правом доступа служит сам
# непредсказуемый токен (32 байта энтропии), сверяемый по payment_entitlements
# в app/api/v1/payments.py. У покупателя-физлица на meraocenka.ru идентичности
# нет и не будет, поэтому иного способа отдать ему купленное не существует.
_PUBLIC_PATH_PREFIXES = ("/api/v1/trade-in/r/",)
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
# scope-проверки восстанавливаем внешний путь.
@ -202,7 +236,7 @@ async def rbac_guard(
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
path = request.url.path
if path in _PUBLIC_PATHS:
if path in _PUBLIC_PATHS or path.startswith(_PUBLIC_PATH_PREFIXES):
return await call_next(request)
username: str | None = None

View file

@ -31,6 +31,7 @@ from app.api.v1 import (
glitchtip,
lead,
me,
payments,
privacy_admin,
search,
support,
@ -286,6 +287,11 @@ app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-ve
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
app.include_router(glitchtip.router, prefix="/api/v1/trade-in", tags=["trade-in-ops"])
# Платёжный контур — весь за settings.payments_enabled (дефолт False): роутер
# подключён всегда, но каждая его ручка отвечает 503, пока контур выключен.
# Подключать по флагу было бы хуже: путь /payments/notify обязан существовать
# и отвечать предсказуемо, а не менять форму ответа вместе с конфигом.
app.include_router(payments.router, prefix="/api/v1/trade-in", tags=["trade-in-payments"])
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
app.include_router(search.router, prefix="/api/v1", tags=["search"])
app.include_router(me.router, prefix="/api/v1", tags=["me"])

View file

@ -131,6 +131,20 @@ _URL_SECRET_QUERY_REPLACEMENT = r"\g<1>" + _REDACTED
_HTTPX_ERROR_URL_QUERY_RE = re.compile(r"(for url '[^'?]*)\?[^']*(')")
_HTTPX_ERROR_URL_QUERY_REPLACEMENT = r"\g<1>?" + _REDACTED + r"\g<2>"
# Capability-токен оплаченного отчёта живёт В ПУТИ (`/api/v1/trade-in/r/<token>`,
# app/api/v1/payments.py), а не в query и не в теле — значит ни `_PII_KEYS`
# (ключ-based), ни `scrub_payment_request_body` (режет request.data по сегменту
# `/payments/`), ни sentry_sdk `sanitize_url` (режет только userinfo и query)
# его не касаются. А путь попадает в событие несколькими путями сразу:
# `event.request.url`, `transaction`, breadcrumb'ы, текст исключения. Токен —
# это ПРАВО ДОСТУПА целиком: утёкший в GlitchTip путь равен выданному отчёту.
# Поэтому — full-text regex по всему событию, как у TG-токена.
# `[\w-]` покрывает алфавит `secrets.token_urlsafe` (base64url), хвост
# `(?=[/?#]|$)` оставляет нетронутым остаток URL (query/фрагмент) — он полезен
# для диагностики и секретом не является.
_REPORT_LINK_TOKEN_RE = re.compile(r"(/api/v1/trade-in/r/)[\w-]+(?=[/?#]|$)")
_REPORT_LINK_TOKEN_REPLACEMENT = r"\g<1>" + _REDACTED
def _scrub(obj: Any) -> None:
"""Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place)."""
@ -205,6 +219,7 @@ def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
_scrub(event.get("contexts"))
_regex_redact_inplace(event, _URL_SECRET_QUERY_RE, _URL_SECRET_QUERY_REPLACEMENT)
_regex_redact_inplace(event, _HTTPX_ERROR_URL_QUERY_RE, _HTTPX_ERROR_URL_QUERY_REPLACEMENT)
_regex_redact_inplace(event, _REPORT_LINK_TOKEN_RE, _REPORT_LINK_TOKEN_REPLACEMENT)
return event

View file

@ -308,6 +308,16 @@ async def _job_deals_freshness_monitor(
await loop.run_in_executor(None, check_deals_freshness, db, run_id, params)
# ── landing_stats_refresh — sync DB-only пересчёт витрины в executor ─────────
async def _job_landing_stats(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.landing_stats import refresh_landing_stats
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, refresh_landing_stats, db, run_id, params)
# ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
async def _job_sber_freshness_monitor(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -657,6 +667,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"rosreestr_quarter_poll": Handler(_job_rosreestr_quarter_poll, "rosreestr_quarter_poll"),
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
"landing_stats_refresh": Handler(_job_landing_stats, "landing_stats_refresh"),
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
"yandex_newbuilding_sweep": Handler(
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"

View file

@ -0,0 +1,407 @@
"""Пересчёт витрины лэндинга на РЕАЛЬНЫХ сделках (миграция 276).
ЧТО ЭТО. Публичный лэндинг МЕРЫ показывал ленту «МЕРА сказала X продали за Y»
на выдуманных константах (frontend `marketing-v3.ts`). Здесь считается её
настоящий источник: берём зарегистрированные ДКП-сделки Росреестра по ЕКБ,
прогоняем каждую через ТОТ ЖЕ спайн оценщика, что и боевой расчёт
(`scripts/backtest_estimator._predict_full_spine` `estimator._price_from_inputs`),
и кладём получившиеся пары «прогноз / факт» в `landing_showcase_deals`.
ПРАВИЛО ОТБОРА ЯВНО И БЕЗ ПОДГОНКИ
------------------------------------
Отбираем N строк ключом::
(полнота данных , свежесть квартала , id сделки )
Величина ошибки в ключе НЕ УЧАСТВУЕТ и участвовать не должна. Отбор по малой
ошибке превращает витрину в рекламу: показанные 20 строк перестают быть
выборкой из работы оценщика и становятся её лучшим хвостом, а посетитель
читает их как «вот так МЕРА обычно и попадает». Это тот самый случай, когда
код формально работает, а продукт врёт. Проверяется тестом
`test_landing_showcase_deals.py::test_selection_ignores_error_magnitude`.
ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ И ЭТО ТО ЖЕ САМОЕ ПРАВИЛО. До 2026-08-29 здесь
жил порог `MAX_ABS_ERR_PCT = 40`, выбрасывавший кандидата ПО ВЕЛИЧИНЕ ОШИБКИ
до ранжирования. Запрет выше он обходил ступенькой раньше: отбор по ошибке в
ключе и отбор по ошибке в фильтре одно и то же действие, и второе даже
злее, потому что не оставляет строку в кандидатах. Обоснование «отклонение
больше 40% это почти всегда занижение ДКП ради налога» не держится: см.
следующий раздел, грубые занижения вырезаны выше по потоку и по свойству
самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет
квартала, нет площади) «число некрасивое» причиной не является.
Полнота сколько из полей, которые видит посетитель (район, этаж, этажность),
у строки заполнено. Свежесть порядок квартала сделки.
ЧЕСТНОСТЬ ВИТРИНЫ (нарушение любого пункта = витрина врёт)
----------------------------------------------------------
* АДРЕСА НЕТ. Номер дома есть у 2.7% сделок, поэтому строка это «район +
2-к, 54 м², 5 эт.», и никогда не улица с домом.
* ДНЯ НЕТ. `deals.deal_date` первое число квартала (10 различных значений
на всю таблицу), поэтому в витрине только «II квартал 2026».
* ЗАМЕР НЕ POINT-IN-TIME. Спайн считает прогноз по СЕГОДНЯШНИМ активным
объявлениям, а сделка прошлая. Между ними дрейф рынка, который в ошибку
входит целиком. Это записано в `note` КАЖДОЙ строки, а не только здесь:
поле note едет на фронт вместе с числами, а докстринг нет.
* ЦЕНА ДКП БЫВАЕТ ЗАНИЖЕНА (налоговая оптимизация, сделки между своими), и
такая строка выглядит как чудовищный промах оценщика. Санитарный диапазон
/м² применяется ОДИН раз и ВЫШЕ ПО ПОТОКУ в `_load_sample`, по свойству
самой сделки, а не по ошибке прогноза: для ЕКБ это глобальные
`PPM2_MIN = 30 000` / `PPM2_MAX = 600 000` (город намеренно не заведён в
`deal_city_price_bands`, там же и комментарий об этом). Значит грубые
занижения из выборки уже вырезаны ДО того, как сюда приходит кандидат, а
всё, что после этого дало большую ошибку, работа оценщика, и витрина
обязана её показать. Своей копии диапазона здесь нет намеренно: прежние
`MIN_FACT_PPM2 = 30k` дублировал уже применённый фильтр, а
`MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k из трёх
отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине.
Неработающая проверка читается как работающая, поэтому её нет.
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
строк» неотличимо от «столько и было», пока рядом не написано, сколько
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
прогона пишется в `landing_showcase_runs` (миграция 277) и отдаётся
ручкой `/api/public/mera/showcase` вместе со строками.
ЗАПУСК (прод, read-mostly: один DELETE+INSERT в свою таблицу)::
docker exec tradein-backend python -m app.tasks.landing_showcase_deals
Планировщиком пока не дёргается витрина обновляется редко (сделки приезжают
кварталами), а вешать ежедневный джоб ради данных, которые меняются раз в три
месяца, значит платить сотнями пространственных запросов за ничего.
"""
from __future__ import annotations
import argparse
import logging
from dataclasses import dataclass
from datetime import date
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# ── Правило отбраковки: одна формулировка, она же едет на фронт ──────────────
#
# Порогов на величину ошибки здесь НЕТ (разбор — в докстринге модуля). Санитарный
# диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут
# значило бы завести проверку, которая в проде не срабатывает никогда.
REJECTION_RULE = (
"Строка не попадает на витрину, только если данных нет: оценщик не дал "
"ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или "
"площадь. Величина отклонения на отбор и отбраковку не влияет — иначе "
"витрина показывала бы лучший хвост, а не работу оценщика. Санитарный "
"диапазон цены сделки (30 000600 000 ₽/м² для Екатеринбурга) применён "
"к выборке до расчёта, по цене самой сделки."
)
NOTE = (
"Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: "
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
"Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда "
"строка выглядит как промах оценщика, хотя врёт документ."
)
_ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"}
def quarter_label(d: date | None) -> str | None:
"""`date(2026, 4, 1)` → ``'II квартал 2026'``. Нет даты — нет ярлыка."""
if d is None:
return None
return f"{_ROMAN[(d.month - 1) // 3 + 1]} квартал {d.year}"
@dataclass(frozen=True)
class ShowcaseRow:
"""Одна строка витрины — ровно то, что уедет в таблицу и на фронт."""
deal_id: int
district: str | None
rooms: int
area_m2: float
floor: int | None
total_floors: int | None
deal_date: date | None
deal_quarter: str
predicted_rub: int
fact_rub: int
err_pct: float
n_analogs: int
def completeness(row: ShowcaseRow) -> int:
"""Сколько ВИДИМЫХ посетителю полей заполнено (0..3).
Считаем район/этаж/этажность: комнаты и площадь есть у всех кандидатов по
построению выборки, поэтому в оценке полноты они бесполезны.
"""
return sum(x is not None for x in (row.district, row.floor, row.total_floors))
def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
"""Ключ отбора. Ошибки здесь нет — см. «ПРАВИЛО ОТБОРА» в докстринге модуля."""
return (
-completeness(row),
-(row.deal_date or date.min).toordinal(),
-row.deal_id,
)
def select_rows(rows: list[ShowcaseRow], limit: int) -> list[ShowcaseRow]:
"""Отобрать `limit` строк по полноте и свежести (НЕ по величине ошибки)."""
return sorted(rows, key=_sort_key)[:limit]
def build_row(
*,
deal_id: int,
district: str | None,
rooms: int,
area_m2: float,
floor: int | None,
total_floors: int | None,
deal_date: date | None,
predicted_rub: float | None,
fact_ppm2: float,
n_analogs: int,
) -> ShowcaseRow | None:
"""Кандидат → строка витрины, либо None если считать не из чего.
Причины отказа ИСЧЕРПЫВАЮЩИЕ и все «данных нет»: спайн не дал ожидаемой
цены продажи; квартал сделки неизвестен; нет площади или цены сделки
(делить не на что). Величина отклонения причиной НЕ является ни при каких
значениях см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля.
"""
if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0 or fact_ppm2 <= 0:
return None
quarter = quarter_label(deal_date)
if quarter is None:
return None
fact_rub = fact_ppm2 * area_m2
# Знак ошибки — как в бэктесте: (прогноз факт) / факт. Плюс = МЕРА
# назвала дороже, чем ушло по ДКП.
err_pct = 100.0 * (predicted_rub - fact_rub) / fact_rub
return ShowcaseRow(
deal_id=deal_id,
district=district,
rooms=rooms,
area_m2=round(area_m2, 2),
floor=floor,
total_floors=total_floors,
deal_date=deal_date,
deal_quarter=quarter,
predicted_rub=round(predicted_rub),
fact_rub=round(fact_rub),
err_pct=round(err_pct, 2),
n_analogs=n_analogs,
)
# ── Район: FDW-вьюха чужой базы, поэтому best-effort ─────────────────────────
_DISTRICT_SQL = text(
"""
SELECT d.id AS deal_id, g.district_name
FROM deals d
JOIN gendesign_ekb_districts_geom g
ON ST_Contains(g.geom, d.geom::geometry)
WHERE d.id = ANY(CAST(:ids AS bigint[]))
"""
)
def _fetch_districts(db: Session, deal_ids: list[int]) -> dict[int, str]:
"""id сделки → район. Недоступна вьюха — пустой словарь, а не выдуманный район.
`gendesign_ekb_districts_geom` foreign table в базу gendesign, и её гранты
на той стороне уже терялись (DROP MV CASCADE снимает GRANT). Оборачиваем в
SAVEPOINT ИМЕННО ЗДЕСЬ, на месте глушения: провалившийся SELECT переводит
транзакцию в aborted, и следующий запрос упал бы уже не по своей вине.
"""
if not deal_ids:
return {}
try:
with db.begin_nested():
rows = db.execute(_DISTRICT_SQL, {"ids": deal_ids}).mappings().all()
except Exception as exc:
logger.warning("район не резолвится (витрина будет без района): %s", exc)
return {}
return {int(r["deal_id"]): r["district_name"] for r in rows if r["district_name"]}
_DELETE_SQL = text("DELETE FROM landing_showcase_deals")
_DELETE_RUNS_SQL = text("DELETE FROM landing_showcase_runs")
# Тот же `now()`, что у DEFAULT в строках витрины: в Postgres now() — время
# НАЧАЛА транзакции, а батч и его итог пишутся одной транзакцией. Ручка по
# этому computed_at и связывает счётчики со строками.
_INSERT_RUN_SQL = text(
"""
INSERT INTO landing_showcase_runs
(considered, priced, no_prediction, incomplete, eligible, written,
with_district, rejection_rule)
VALUES
(CAST(:considered AS integer), CAST(:priced AS integer),
CAST(:no_prediction AS integer), CAST(:incomplete AS integer),
CAST(:eligible AS integer), CAST(:written AS integer),
CAST(:with_district AS integer), CAST(:rejection_rule AS text))
"""
)
_INSERT_SQL = text(
"""
INSERT INTO landing_showcase_deals
(district, rooms, area_m2, floor, total_floors, deal_quarter,
predicted_rub, fact_rub, err_pct, n_analogs, note)
VALUES
(CAST(:district AS text), CAST(:rooms AS integer), CAST(:area_m2 AS numeric),
CAST(:floor AS integer), CAST(:total_floors AS integer),
CAST(:deal_quarter AS text), CAST(:predicted_rub AS bigint),
CAST(:fact_rub AS bigint), CAST(:err_pct AS numeric),
CAST(:n_analogs AS integer), CAST(:note AS text))
"""
)
def refresh_landing_showcase_deals(
db: Session,
*,
sample: int = 200,
since: str = "2025-01-01",
limit: int = 20,
city: str = "Екатеринбург",
) -> dict[str, int]:
"""Прогнать бэктест по ЕКБ и перезаписать витрину. Возвращает счётчики.
Счётчики не отладочный шум: без них «на витрине 20 отличных строк»
неотличимо от «столько и было». Поэтому они не только пишутся в лог, но и
сохраняются в `landing_showcase_runs` и уезжают на фронт вместе со
строками. Значения:
considered сколько ДКП-сделок взято в работу
priced из них оценщик дал ожидаемую цену продажи
no_prediction не дал (мало аналогов / спайн упал)
incomplete цена есть, но нет квартала/площади строку не собрать
eligible годных строк ВСЕГО (никакого отсева по ошибке нет)
written из них показано (обрезано по `limit`)
with_district у скольких показанных удалось определить район
"""
# Импорт внутри функции: `scripts.backtest_estimator` тянет оценщик со всеми
# его зависимостями, а web-процессу это на импорте приложения не нужно.
from scripts.backtest_estimator import (
_import_estimator_full,
_load_sample,
_predict_full_spine,
)
est = _import_estimator_full()
deals = _load_sample(db, sample=sample, since=since, city=city)
logger.info("витрина: загружено %d ДКП-сделок (city=%s, since=%s)", len(deals), city, since)
districts = _fetch_districts(db, [d.id for d in deals])
candidates: list[ShowcaseRow] = []
n_priced = 0
n_incomplete = 0
for deal in deals:
capture: list[dict[str, Any]] = []
try:
pr = _predict_full_spine(db, deal, est, capture=capture)
except Exception as exc:
logger.warning("сделка %s: спайн упал, пропускаем: %s", deal.id, exc)
db.rollback()
continue
if pr is None:
continue
n_priced += 1
row = build_row(
deal_id=deal.id,
district=districts.get(deal.id),
rooms=deal.rooms,
area_m2=deal.area_m2,
floor=deal.floor,
total_floors=deal.total_floors,
deal_date=deal.deal_date,
predicted_rub=pr.expected_sold_price,
fact_ppm2=deal.sold_ppm2,
n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0,
)
if row is None:
n_incomplete += 1
continue
candidates.append(row)
chosen = select_rows(candidates, limit)
db.execute(_DELETE_SQL)
db.execute(_DELETE_RUNS_SQL)
for row in chosen:
db.execute(
_INSERT_SQL,
{
"district": row.district,
"rooms": row.rooms,
"area_m2": row.area_m2,
"floor": row.floor,
"total_floors": row.total_floors,
"deal_quarter": row.deal_quarter,
"predicted_rub": row.predicted_rub,
"fact_rub": row.fact_rub,
"err_pct": row.err_pct,
"n_analogs": row.n_analogs,
"note": NOTE,
},
)
counters = {
"considered": len(deals),
"priced": n_priced,
"no_prediction": len(deals) - n_priced,
"incomplete": n_incomplete,
"eligible": len(candidates),
"written": len(chosen),
"with_district": sum(1 for r in chosen if r.district is not None),
}
db.execute(_INSERT_RUN_SQL, {**counters, "rejection_rule": REJECTION_RULE})
db.commit()
logger.info(
"витрина обновлена: рассмотрено=%d оценено=%d без_прогноза=%d неполных=%d "
"годных=%d записано=%d с_районом=%d",
counters["considered"],
counters["priced"],
counters["no_prediction"],
counters["incomplete"],
counters["eligible"],
counters["written"],
counters["with_district"],
)
return counters
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--sample", type=int, default=200)
parser.add_argument("--since", default="2025-01-01")
parser.add_argument("--limit", type=int, default=20)
parser.add_argument("--city", default="Екатеринбург")
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
from app.core.db import SessionLocal
db = SessionLocal()
try:
refresh_landing_showcase_deals(
db, sample=args.sample, since=args.since, limit=args.limit, city=args.city
)
finally:
db.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,397 @@
"""Пересчёт витринных метрик публичного лэндинга МЕРЫ (таблица landing_stats).
ЗАЧЕМ
-----
Числа на лэндинге (frontend/src/app/mera-public/marketing-v3.ts) были литералами
то есть придуманными. Публичная страница, которая продаёт «расчёт по данным»,
не может показывать цифры, которых в данных нет: это ровно та подмена, против
которой продукт и позиционируется. Здесь каждая витринная величина считается
запросом к проду, и вместе с ней пишется размер выборки.
ГЛАВНОЕ ПРАВИЛО: НЕТ ВХОДА НЕТ СТРОКИ
---------------------------------------
Ни одна метрика не пишется с подставленным значением. Если выборка пуста
(нет оценок, нет истории цен, нет сделок) строка в landing_stats просто не
появляется, ручка её не отдаёт, фронт не рисует блок. Ноль здесь читался бы как
измеренный ноль («ни одно объявление не снижало цену»), а это враньё другого
рода, чем отсутствие данных. Правило действует и на ВТОРОМ прогоне: пропавшая
метрика удаляется из таблицы (см. refresh_landing_stats), иначе она осталась бы
на витрине со старым computed_at и читалась бы как измеренная сегодня.
ЧЕГО ЗДЕСЬ НЕТ И НЕ БУДЕТ
-------------------------
«Точность прогноза» и «срок продажи» величин с такими именами в базе нет.
Точность считает бэктест (своя задача, свои допущения), а срок продажи требует
пары «объявление снято сделка», которой у нас нет: снятие объявления не
означает продажу. `listing_age_median_days` НЕ является сроком продажи и назван
экспозицией активного объявления см. note метрики.
ПОЧЕМУ ТОЛЬКО DOMKLIK В ЦЕНОВЫХ МЕТРИКАХ
----------------------------------------
`offer_price_history` наполняется триггером, и наполняется по-разному:
у avito/yandex стартовая цена в историю НЕ пишется (первая строка появляется
только при изменении, то есть «снизил» и «не снижал» неразличимы), а yandex
вдобавок сеет синтетическую пару со сдвигом в сутки. Считать долю снижений по
такой смеси значит получить число, у которого нет смысла. Domklik пишет старт,
поэтому только он.
Задача синхронная (только SELECT'ы + UPSERT), запускается kit-scheduler'ом через
product_handlers._job_landing_stats в run_in_executor по образцу
deals_freshness_monitor / listing_source_snapshot.
"""
from __future__ import annotations
import logging
from decimal import Decimal
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__)
__all__ = ["EKB", "collect_landing_metrics", "refresh_landing_stats"]
# Город витрины. Лэндинг сегодня продаёт Екатеринбург, и метрики обязаны быть
# про него же: медиана по всей области смешала бы рынки с разной динамикой.
EKB = "Екатеринбург"
# Порог наблюдения для ценовых метрик. За две недели объявление успевает получить
# первую правку цены; более короткие живут слишком мало, чтобы «не снижал» было
# наблюдением, а не «не успел».
_PRICE_SPAN_DAYS = 14
# Отсечка аномалий: изменение больше 30% за наблюдение — это, как правило, смена
# объекта под тем же id (перевыставили другую квартиру) или опечатка в цене,
# а не торг. Медиану такие хвосты не двигают, но долю снижений — двигают.
_PRICE_MAX_ABS_PCT = 30
# ── Оценки ──────────────────────────────────────────────────────────────────
# Период считаем по фактическим краям created_at, а не «с даты запуска»: витрина
# обещает «за N дней работы», и N должен быть измеренным.
_ESTIMATES_SQL = text("""
SELECT count(*) AS total,
EXTRACT(EPOCH FROM (max(created_at) - min(created_at)))
/ 86400.0 AS period_days
FROM trade_in_estimates
""")
# n_analogs > 0: оценка без аналогов — это отказ расчёта, а не «ноль аналогов»;
# включив её, мы бы занизили медиану наблюдениями, где измерять было нечего.
_ANALOGS_SQL = text("""
SELECT count(*) AS n,
percentile_cont(0.5) WITHIN GROUP (ORDER BY n_analogs) AS median
FROM trade_in_estimates
WHERE n_analogs > 0
""")
# Возраст АКТИВНОГО объявления = экспозиция на сегодня, а не срок продажи:
# знаменатель — те, кто ещё висит, поэтому величина по построению занижена
# относительно «сколько в итоге продавалось». Это ограничение уезжает в note.
_LISTING_AGE_SQL = text("""
SELECT count(*) AS n,
percentile_cont(0.5) WITHIN GROUP (
ORDER BY (CURRENT_DATE - listing_date)
) AS median
FROM listings
WHERE is_active
AND city = CAST(:city AS text)
AND listing_date IS NOT NULL
AND listing_date <= CURRENT_DATE
""")
# ── Динамика цены объявлений ────────────────────────────────────────────────
#
# Знаменатель — объявления, которые МОЖНО было наблюдать: от первой записи в
# истории до последнего показа прошло >= 14 дней. Сюда попадают и те, у кого
# запись одна (domklik пишет старт → одна запись означает «цену не менял»); без
# них доля снижений считалась бы только по менявшим и давала 85% вместо 48%.
#
# Скорость снижения нормируем на 30 дней по интервалу МЕЖДУ КРАЙНИМИ ПРАВКАМИ,
# а не по всему наблюдению: цена не менялась после последней правки, и растягивая
# знаменатель на «висит до сих пор», мы измеряли бы терпение продавца, а не торг.
_PRICE_MOVES_SQL = text("""
WITH hist AS (
SELECT listing_id,
min(change_time) AS first_change,
max(change_time) AS last_change,
count(*) AS n_rows
FROM offer_price_history
WHERE source = 'domklik'
GROUP BY listing_id
),
observed AS (
SELECT h.listing_id,
h.n_rows,
EXTRACT(EPOCH FROM (h.last_change - h.first_change)) / 86400.0 AS change_days
FROM hist h
JOIN listings l ON l.id = h.listing_id
WHERE GREATEST(h.last_change, COALESCE(l.last_seen_at, h.last_change)) - h.first_change
>= make_interval(days => CAST(:span_days AS integer))
),
priced AS (
SELECT o.listing_id,
o.n_rows,
o.change_days,
(SELECT p.price_rub FROM offer_price_history p
WHERE p.listing_id = o.listing_id AND p.source = 'domklik'
ORDER BY p.change_time ASC, p.id ASC LIMIT 1) AS price_first,
(SELECT p.price_rub FROM offer_price_history p
WHERE p.listing_id = o.listing_id AND p.source = 'domklik'
ORDER BY p.change_time DESC, p.id DESC LIMIT 1) AS price_last
FROM observed o
),
moved AS (
SELECT listing_id,
change_days,
CASE WHEN n_rows >= 2
THEN (price_last - price_first) / price_first * 100.0
ELSE 0
END AS pct
FROM priced
WHERE price_first IS NOT NULL AND price_first > 0
)
SELECT count(*) AS n,
count(*) FILTER (WHERE pct < 0) AS n_cut,
percentile_cont(0.5) WITHIN GROUP (
ORDER BY pct * 30.0 / NULLIF(change_days, 0)
) FILTER (WHERE pct < 0) AS median_pct_per_month
FROM moved
WHERE abs(pct) <= CAST(:max_abs_pct AS numeric)
""")
# 12 месяцев от сегодня. deal_date у Росреестра — лейбл начала квартала, поэтому
# окно накрывает 4-5 кварталов и число «за год» тут приблизительно по построению;
# это сказано в note, а не спрятано.
_DEALS_SQL = text("""
SELECT count(*) AS n
FROM deals
WHERE city = CAST(:city AS text)
AND deal_date >= (CURRENT_DATE - INTERVAL '12 months')
""")
_UPSERT_SQL = text("""
INSERT INTO landing_stats (metric, value_num, value_text, sample_n, note, computed_at)
VALUES (
CAST(:metric AS text),
CAST(:value_num AS numeric),
CAST(:value_text AS text),
CAST(:sample_n AS integer),
CAST(:note AS text),
now()
)
ON CONFLICT (metric) DO UPDATE SET
value_num = EXCLUDED.value_num,
value_text = EXCLUDED.value_text,
sample_n = EXCLUDED.sample_n,
note = EXCLUDED.note,
computed_at = EXCLUDED.computed_at
""")
# Строки метрик, которых в СЕГОДНЯШНЕМ наборе нет, удаляются. Метрика исчезает
# из набора ровно тогда, когда у неё пропал вход (см. «нет входа — нет строки»),
# и оставленная строка продолжала бы отдаваться ручкой как обычная — со старым
# computed_at, который витрина не обязана читать. Удалённая метрика — блок,
# которого на странице нет; протухшая — блок с враньём.
_PRUNE_SQL = text("""
DELETE FROM landing_stats
WHERE metric <> ALL(CAST(:kept AS text[]))
""")
def _num(value: Any) -> float | None:
"""Привести значение агрегата к float; None остаётся None.
percentile_cont возвращает Decimal/float в зависимости от типа входа в
numeric-колонку и в JSON поедет одинаково только после явного приведения.
"""
if value is None:
return None
if isinstance(value, Decimal):
return float(value)
return float(value)
def collect_landing_metrics(db: Session) -> list[dict[str, Any]]:
"""Посчитать метрики витрины. Метрика без данных в список НЕ попадает.
Отделено от записи, чтобы тест мог проверить сами ЗНАЧЕНИЯ на подготовленной
базе, не разбирая по дороге счётчики прогона.
"""
metrics: list[dict[str, Any]] = []
row = db.execute(_ESTIMATES_SQL).first()
total = int(row.total) if row is not None and row.total else 0
if total > 0:
metrics.append(
{
"metric": "estimates_total",
"value_num": float(total),
"value_text": None,
"sample_n": total,
"note": "Расчётов сделано в системе (все города, весь срок работы)",
}
)
period = _num(row.period_days)
# Один-единственный расчёт даёт период 0 дней — это не измерение, а
# артефакт единственной точки; такую строку не пишем.
if period is not None and total > 1:
metrics.append(
{
"metric": "estimates_period_days",
"value_num": round(period, 1),
"value_text": None,
"sample_n": total,
"note": "Дней между первым и последним расчётом",
}
)
row = db.execute(_ANALOGS_SQL).first()
if row is not None and row.n and _num(row.median) is not None:
metrics.append(
{
"metric": "analogs_median",
"value_num": round(_num(row.median) or 0.0, 1),
"value_text": None,
"sample_n": int(row.n),
"note": "Медиана числа аналогов на расчёт (только расчёты, где аналоги нашлись)",
}
)
row = db.execute(_LISTING_AGE_SQL, {"city": EKB}).first()
if row is not None and row.n and _num(row.median) is not None:
metrics.append(
{
"metric": "listing_age_median_days",
"value_num": round(_num(row.median) or 0.0, 1),
"value_text": None,
"sample_n": int(row.n),
"note": (
"Медианная ЭКСПОЗИЦИЯ активного объявления в Екатеринбурге "
"(сколько дней висит на сегодня). Это НЕ срок продажи: "
"считается по тем, кто ещё продаётся, и снятие объявления "
"не означает сделку"
),
}
)
row = db.execute(
_PRICE_MOVES_SQL,
{"span_days": _PRICE_SPAN_DAYS, "max_abs_pct": _PRICE_MAX_ABS_PCT},
).first()
if row is not None and row.n:
n = int(row.n)
base_note = (
f"Только Домклик (единственный источник, где триггер пишет стартовую цену), "
f"наблюдение от {_PRICE_SPAN_DAYS} дней, изменения свыше "
f"{_PRICE_MAX_ABS_PCT}% отброшены как смена объекта"
)
metrics.append(
{
"metric": "price_cut_share_pct",
"value_num": round(int(row.n_cut) * 100.0 / n, 1),
"value_text": None,
"sample_n": n,
"note": f"Доля объявлений, снижавших цену. {base_note}",
}
)
median_move = _num(row.median_pct_per_month)
if median_move is not None:
metrics.append(
{
"metric": "price_cut_median_pct_per_month",
"value_num": round(median_move, 2),
"value_text": None,
# Выборка ЗДЕСЬ — только снижавшие: медиана считается по ним,
# и подставить сюда общий n значило бы приписать величине
# выборку, по которой её не считали.
"sample_n": int(row.n_cut),
"note": (
f"Медианное изменение цены за 30 дней среди снижавших "
f"(отрицательное). {base_note}"
),
}
)
row = db.execute(_DEALS_SQL, {"city": EKB}).first()
if row is not None and row.n:
metrics.append(
{
"metric": "deals_total_12m",
"value_num": float(row.n),
"value_text": None,
"sample_n": int(row.n),
"note": (
"Сделок Росреестра по Екатеринбургу за последние 12 месяцев. "
"Дата сделки — лейбл начала квартала, поэтому окно накрывает "
"целые кварталы, а не ровно год"
),
}
)
return metrics
def refresh_landing_stats(
db: Session,
run_id: int,
params: dict[str, Any] | None = None,
) -> dict[str, int]:
"""Пересчитать landing_stats и финализировать прогон.
Sync (вызывается scheduler-триггером в executor, как check_deals_freshness).
`params` не используется принимается ради единой сигнатуры обработчиков.
Метрика, у которой пропал вход, СНИМАЕТСЯ с витрины, а не доживает со старым
computed_at: строки, которых нет в сегодняшнем наборе, удаляются в той же
транзакции. Иначе «нет входа нет строки» действует только на первом
прогоне, а дальше отсутствие данных выглядит как данные ручка отдаёт такую
строку неотличимо от свежей, и отличить её можно только сравнив computed_at с
соседями, чего фронт не делает.
Пустой результат НЕ ошибка прогона: на свежей базе метрик может не быть ни
одной, и падать в failed из-за этого значит завести шумный алерт там, где
система работает штатно. Но и чистка в этом случае НЕ выполняется: разом
отвалившиеся все входы это признак поломки самого прогона (пустая/недоступная
база), а не пяти одновременных «данных больше нет», и стирать по такому
признаку всю витрину нельзя. Чистка ходит только с непустым набором, где
пропажу конкретной метрики видно на фоне посчитавшихся соседей.
"""
del params
counters: dict[str, int] = {"metrics_written": 0, "metrics_removed": 0}
try:
runs_mod.update_heartbeat(db, run_id, counters)
metrics = collect_landing_metrics(db)
for row in metrics:
db.execute(_UPSERT_SQL, row)
if metrics:
removed = db.execute(_PRUNE_SQL, {"kept": [m["metric"] for m in metrics]})
counters["metrics_removed"] = int(removed.rowcount or 0)
db.commit()
counters["metrics_written"] = len(metrics)
if not metrics:
logger.warning(
"landing_stats run_id=%d: ни одной метрики не посчиталось — "
"витрина покажет прошлый срез (или пусто, если его не было)",
run_id,
)
runs_mod.mark_done(db, run_id, counters)
logger.info(
"refresh_landing_stats run_id=%d done: %d метрик (%s)",
run_id,
len(metrics),
", ".join(m["metric"] for m in metrics) or "",
)
return counters
except Exception as exc:
logger.exception("refresh_landing_stats run_id=%d failed", run_id)
try:
db.rollback()
except Exception:
pass
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise

View file

@ -0,0 +1,90 @@
-- 275_landing_stats.sql
-- Витринные метрики публичного лэндинга МЕРЫ — считаются по проду, не пишутся руками.
--
-- ЗАЧЕМ ТАБЛИЦА, А НЕ ЗАПРОС ИЗ РУЧКИ
-- -----------------------------------
-- Числа на лэндинге сегодня лежат литералами во фронте
-- (frontend/src/app/mera-public/marketing-v3.ts) — то есть выдуманы и не имеют
-- срока годности: когда база меняется, страница врёт молча. Но и считать их в
-- момент запроса нельзя: медиана по offer_price_history с подзапросами на
-- листинг — это секунды на анонимной ручке без авторизации, то есть готовый
-- рычаг для DoS. Поэтому срез считает ночная задача
-- (app/tasks/landing_stats.py), а ручка отдаёт готовые строки.
--
-- ОДНА СТРОКА НА МЕТРИКУ, ИСТОРИИ НЕТ
-- -----------------------------------
-- PK (metric) + UPSERT: лэндингу нужно «сколько сейчас», а не тренд. Заводить
-- историю впрок значит выбрать схему под запрос, которого никто не задавал;
-- когда понадобится динамика — она приедет отдельной таблицей со своим PK,
-- и это будет дешевле, чем сейчас угадывать её ключ.
--
-- value_num И value_text РАЗДЕЛЬНО
-- -------------------------------
-- Числовые метрики фронт форматирует сам (округление, склонение, разделители
-- разрядов), поэтому числу нельзя приезжать строкой. value_text оставлен для
-- метрик, у которых значение — не число (например период «май–август 2026»);
-- сегодня такие не пишутся, но колонка дешевле, чем миграция под первую же.
--
-- sample_n ОБЯЗАТЕЛЕН ПО СМЫСЛУ, NULL ПО СХЕМЕ
-- -------------------------------------------
-- Требование продукта: у каждой витринной цифры видно, по скольким наблюдениям
-- она получена — иначе «медиана» неотличима от «медиана по двум объявлениям».
-- Гарантирует это задача (она НЕ пишет строку, если входа нет), а не NOT NULL:
-- жёсткое ограничение на колонке заставило бы будущую метрику без выборки
-- подставлять фиктивный ноль, то есть врать ради схемы.
--
-- Идемпотентно: IF NOT EXISTS — безопасно переприменять.
BEGIN;
CREATE TABLE IF NOT EXISTS landing_stats (
metric text PRIMARY KEY,
value_num numeric,
value_text text,
sample_n integer,
note text,
computed_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE landing_stats IS
'Витринные метрики лэндинга МЕРЫ; пересчёт — app/tasks/landing_stats.py (раз в сутки)';
COMMENT ON COLUMN landing_stats.sample_n IS
'Размер выборки, по которой получено значение — показывается рядом с цифрой';
COMMENT ON COLUMN landing_stats.note IS
'Что именно измерено, человеческим языком — защита от подмены смысла на витрине';
-- ── Регистрация в планировщике ──────────────────────────────────────────────
--
-- Периодические задачи МЕРЫ живут не в crontab, а строками scrape_schedules:
-- kit-scheduler (app/scheduler_main.py) выбирает source по окну и резолвит
-- обработчик через app/services/product_handlers.py. Поэтому «регистрация»
-- задачи — это ровно две вещи: Handler в реестре и вот эта строка.
--
-- Окно 05:0006:00 UTC: после ночных лоадеров листингов и после
-- asking_to_sold_ratio_refresh (06:0007:00 UTC мы бы догоняли), но до
-- рабочего дня по Екатеринбургу (UTC+5) — витрина к утру уже пересчитана.
-- Задача читающая (несколько агрегирующих SELECT, внешних вызовов нет), так
-- что enabled=true сразу: цена ошибки — минуты CPU ночью.
--
-- next_run_at на завтра: не выстреливает прямо в момент деплоя (образец —
-- 162_seed_deals_freshness_monitor.sql).
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'landing_stats_refresh',
true,
5,
6,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC',
'{}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -0,0 +1,50 @@
-- 276: витрина лэндинга на РЕАЛЬНЫХ сделках (issue B2C-showcase).
--
-- ЗАЧЕМ ТАБЛИЦА, А НЕ ВЫЧИСЛЕНИЕ В РУЧКЕ. Прогноз считается полным спайном
-- оценщика: несколько пространственных SELECT'ов на КАЖДУЮ сделку. Двадцать
-- сделок — это сотни запросов; на публичной ручке без авторизации это готовый
-- рычаг для DoS. Поэтому пересчёт — офлайн-задача (app/tasks/landing_showcase_deals.py),
-- ручка читает готовые строки.
--
-- ЧЕГО ЗДЕСЬ НАМЕРЕННО НЕТ — АДРЕСА. В `deals` номер дома есть у 2.7% строк
-- (620 различных адресов на 24 644 сделки), то есть «улица + дом» на витрине
-- была бы додумана. Показываем район + характеристики квартиры; улицы нет
-- даже колонкой, чтобы её нельзя было «на минутку» вывести.
--
-- deal_quarter — ТЕКСТ КВАРТАЛА, не дата: `deals.deal_date` принимает всего 10
-- различных значений на всю таблицу (первое число квартала), то есть дня
-- сделки в данных нет. Хранить date здесь значило бы отдать фронту точность,
-- которой не существует.
--
-- district и floor — NULLABLE. Район резолвится через FDW-вьюху чужой базы
-- (gendesign_ekb_districts_geom), и её гранты уже терялись (см. C3); floor в
-- части ДКП-строк пуст. Правило проекта: нет величины — пишем NULL, а не
-- правдоподобное значение. Отбор в задаче ранжирует такие строки ниже, но не
-- запрещает их: пустая витрина хуже витрины без района.
BEGIN;
-- Конвенция проекта (#2752): блокирующий DDL идёт под lock_timeout, иначе он
-- встанет в очередь за чужой сессией и утащит за собой запросы приложения.
SET LOCAL lock_timeout = '5s';
CREATE TABLE IF NOT EXISTS landing_showcase_deals (
id bigserial PRIMARY KEY,
computed_at timestamptz NOT NULL DEFAULT now(),
district text,
rooms integer NOT NULL,
area_m2 numeric(8, 2) NOT NULL,
floor integer,
total_floors integer,
deal_quarter text NOT NULL,
predicted_rub bigint NOT NULL,
fact_rub bigint NOT NULL,
err_pct numeric(6, 2) NOT NULL,
n_analogs integer NOT NULL,
note text NOT NULL
);
-- Ручка всегда читает ПОСЛЕДНИЙ пересчёт (max computed_at) — старые батчи
-- остаются для сверки «что показывали неделю назад».
CREATE INDEX IF NOT EXISTS idx_landing_showcase_deals_computed_at
ON landing_showcase_deals (computed_at DESC);
COMMIT;

View file

@ -0,0 +1,44 @@
-- 277: итог пересчёта витрины лэндинга — счётчики рядом со строками.
--
-- ЗАЧЕМ ОТДЕЛЬНАЯ ТАБЛИЦА, А НЕ КОЛОНКИ В landing_showcase_deals. Счётчики —
-- факт ПРОГОНА, а не строки: на 20 строк они были бы продублированы 20 раз, а
-- в самом важном случае — когда показывать оказалось нечего — исчезли бы
-- вместе со строками. «Рассмотрено 200, показывать нечего» обязано доезжать до
-- фронта ровно так же, как «рассмотрено 200, показано 20».
--
-- ЗАЧЕМ ВООБЩЕ. Витрина показывает 20 сделок «МЕРА сказала X — продали за Y».
-- Без чисел рядом эти 20 строк читаются как «столько и было»: посетитель не
-- может отличить выборку из работы оценщика от её лучшего хвоста. Поэтому
-- ручка /api/public/mera/showcase отдаёт вместе со строками, сколько сделок
-- рассмотрено, сколько годных строк не поместилось и по какому правилу
-- отбраковано остальное.
--
-- rejection_rule — ТЕКСТ, А НЕ КОД ПРАВИЛА. Правило живёт в
-- app/tasks/landing_showcase_deals.REJECTION_RULE и пишется сюда тем прогоном,
-- который эти числа и посчитал: подпись под витриной обязана описывать ТОТ
-- отбор, что дал эти строки, а не тот, что задеплоен сегодня.
--
-- computed_at совпадает со строками батча: задача пишет строки и этот итог
-- ОДНОЙ транзакцией, а now() в Postgres — время начала транзакции.
BEGIN;
-- Конвенция проекта (#2752): блокирующий DDL идёт под lock_timeout, иначе он
-- встанет в очередь за чужой сессией и утащит за собой запросы приложения.
SET LOCAL lock_timeout = '5s';
CREATE TABLE IF NOT EXISTS landing_showcase_runs (
id bigserial PRIMARY KEY,
computed_at timestamptz NOT NULL DEFAULT now(),
considered integer NOT NULL,
priced integer NOT NULL,
no_prediction integer NOT NULL,
incomplete integer NOT NULL,
eligible integer NOT NULL,
written integer NOT NULL,
with_district integer NOT NULL,
rejection_rule text NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_landing_showcase_runs_computed_at
ON landing_showcase_runs (computed_at DESC);
COMMIT;

View file

@ -0,0 +1,45 @@
-- 278_trade_in_estimates_public_token.sql
-- Purpose: capability-ссылка на БЕСПЛАТНУЮ часть анонимного расчёта
-- (GET /api/public/mera/estimate/{token}).
--
-- ЗАЧЕМ КОЛОНКА, А НЕ ОТДЕЛЬНАЯ ТАБЛИЦА. Токен — атрибут ровно одной оценки и
-- живёт/умирает вместе с ней; таблица 1:1 добавила бы join и вторую точку,
-- где строку можно забыть удалить.
--
-- ХРАНИМ ХЭШ, А НЕ ТОКЕН. Дамп/бэкап/случайный SELECT в поддержке не должны
-- давать доступ к чужому расчёту. sha256 без соли осознанно: вход —
-- secrets.token_urlsafe(32), 256 бит энтропии, словарь по нему невозможен,
-- а соль сломала бы поиск по равенству (пришлось бы сканировать таблицу).
--
-- ПОЧЕМУ ОТДЕЛЬНЫЙ СРОК, А НЕ expires_at/retain_until. expires_at — про то,
-- сколько живёт сам расчёт, retain_until двигает контур оплаты. Ссылка на
-- бесплатную часть — третье, независимое обещание («ссылка работает N дней»),
-- и склеивать его с чужими сроками значит менять его молча при каждой правке
-- соседей.
--
-- Dependencies: trade_in_estimates (миграция 001+).
-- Deploy order: применять после 277.
BEGIN;
-- Конвенция проекта (#2752): ALTER TABLE на ЖИВОЙ таблице берёт ACCESS
-- EXCLUSIVE, и без lock_timeout встанет в очередь за запросами приложения,
-- утащив их за собой. trade_in_estimates — таблица боевая (1123 строки на
-- 29.08), поэтому это не формальность.
SET LOCAL lock_timeout = '5s';
ALTER TABLE trade_in_estimates
ADD COLUMN IF NOT EXISTS public_token_hash text,
ADD COLUMN IF NOT EXISTS public_token_expires_at timestamptz;
-- UNIQUE, а не просто индекс: коллизия хэшей означала бы, что по одной ссылке
-- отдаются два разных расчёта. Partial — токен есть у меньшинства строк
-- (B2B-пилоты его не получают вовсе), индексировать NULL'ы незачем.
CREATE UNIQUE INDEX IF NOT EXISTS idx_trade_in_estimates_public_token_hash
ON trade_in_estimates (public_token_hash)
WHERE public_token_hash IS NOT NULL;
COMMENT ON COLUMN trade_in_estimates.public_token_hash IS
'sha256(hex) капабилити-токена бесплатной части (app/api/public/mera.py). Сам токен не хранится.';
COMMENT ON COLUMN trade_in_estimates.public_token_expires_at IS
'До какого момента работает GET /api/public/mera/estimate/{token}. Не связан с expires_at/retain_until.';
COMMIT;

View file

@ -0,0 +1,65 @@
-- 279_payments_live_checkout_uidx.sql
-- Идемпотентность checkout на уровне БД: не больше одного ЖИВОГО платежа на
-- пару (estimate_id, product_code).
--
-- ── WHY ──────────────────────────────────────────────────────────────────────
-- До этой миграции переиспользование живого платежа в
-- app/api/v1/payments.py::checkout держалось на «SELECT, потом INSERT» — ровно
-- на том, что шапка того же модуля называет дефектом. Обычный двойной клик по
-- кнопке оплаты даёт два параллельных запроса: оба проходят SELECT до того, как
-- любой из них вставит строку, оба делают INSERT, оба зовут Init — и на карте
-- покупателя появляются ДВА ХОЛДА. Порядок выполнения тут ничего не решает,
-- решает уникальный ключ: второй INSERT обязан отбиться самой БД.
--
-- ── Почему частичный, а не полный UNIQUE(estimate_id, product_code) ──────────
-- Полный запретил бы повторную покупку навсегда: после CANCELED/REJECTED
-- человек вправе попробовать оплатить заново, а после CONFIRMED — купить
-- второй раз. Поэтому в предикате только «живые» статусы — те, при которых
-- платёжная сессия ещё не завершилась. Список = _REUSABLE_STATUSES
-- (= _PRE_CONFIRM_STATUSES) в app/api/v1/payments.py; расхождение ловит
-- tests/test_payments_router.py::test_live_status_predicate_matches_code —
-- индекс, который шире кода, запрещает легитимный повтор, а который уже —
-- пропускает второй холд.
--
-- Предикат ссылается только на неизменяемые выражения (сравнение колонок с
-- литералами), поэтому индекс легален как частичный, а строка входит в него и
-- выходит автоматически при UPDATE статуса.
--
-- estimate_id IS NOT NULL — обязательная часть предиката: колонка nullable
-- (FK ON DELETE SET NULL, 233_payments.sql), а обычный UNIQUE считает NULL
-- отличным от NULL, так что без этого условия строки с NULL просто копились бы
-- в индексе без пользы.
--
-- ── Безопасность применения ─────────────────────────────────────────────────
-- Контур выключен (PAYMENTS_ENABLED=false), таблица на проде пуста
-- (проверено 2026-08-29: SELECT count(*) FROM payments → 0), поэтому обычный
-- CREATE UNIQUE INDEX не может упасть на существующих дублях и не блокирует
-- ничью запись. IF NOT EXISTS — повторное применение безвредно.
BEGIN;
-- Конвенция проекта (#2752): CREATE UNIQUE INDEX на существующей таблице берёт
-- блокировку и без lock_timeout встанет в очередь за чужой сессией.
SET LOCAL lock_timeout = '5s';
CREATE UNIQUE INDEX IF NOT EXISTS payments_live_estimate_product_uidx
ON payments (estimate_id, product_code)
WHERE estimate_id IS NOT NULL
AND status IN (
'NEW',
'FORM_SHOWED',
'PREAUTHORIZING',
'AUTHORIZING',
'AUTHORIZED',
'3DS_CHECKING',
'3DS_CHECKED',
'CHECKING',
'CHECKED',
'PROCESSING',
'CONFIRMING'
);
COMMENT ON INDEX payments_live_estimate_product_uidx IS
'Не больше одного живого платежа на (estimate_id, product_code): двойной '
'клик по кнопке оплаты не создаёт второй холд на карте покупателя.';
COMMIT;

View file

@ -635,12 +635,18 @@ def test_estimate_readable_sql_uses_disjunction() -> None:
def test_get_estimate_sql_built_from_shared_constant() -> None:
"""GET /estimate/{id} SQL filter is built FROM ESTIMATE_READABLE_SQL, not a
hand-copied literal regression guard against the two gates drifting apart
again (that's exactly what happened before this PR: 404 here, 410 in /pdf)."""
again (that's exactly what happened before this PR: 404 here, 410 in /pdf).
Inspects `load_estimate`, not the `get_estimate` route: the payments PR moved
the body there so the paid capability link (`/r/<token>`, app/api/v1/
payments.py) reuses the SAME loader instead of growing a third copy of the
readability gate which is precisely what this guard exists to prevent.
"""
import inspect
from app.api.v1.trade_in import get_estimate
from app.api.v1.trade_in import load_estimate
src = inspect.getsource(get_estimate)
src = inspect.getsource(load_estimate)
assert "ESTIMATE_READABLE_SQL" in src
assert "expires_at > NOW()" not in src, "hand-copied predicate, not the shared constant"
assert "retain_until" in src, "SELECT must also fetch retain_until"

View file

@ -0,0 +1,171 @@
"""Витрина лэндинга на реальных сделках — отбор и отбраковка (миграция 276).
Главное, что здесь защищается, НЕ формат строки, а свойство отбора: витрина
показывает выборку из работы оценщика, а не её лучший хвост. Отбор по малой
ошибке дал бы формально работающий код и врущий продукт, и заметить это на
глаз в проде нельзя числа будут красивые. Поэтому проверка двусторонняя:
самая точная строка, у которой не хватает данных, обязана проиграть менее
точной, но полной.
"""
from __future__ import annotations
import os
from datetime import date
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.tasks.landing_showcase_deals import (
ShowcaseRow,
build_row,
quarter_label,
select_rows,
)
def _row(
deal_id: int,
*,
district: str | None = "Кировский",
floor: int | None = 5,
total_floors: int | None = 9,
deal_date: date = date(2026, 1, 1),
err_pct: float = 10.0,
) -> ShowcaseRow:
return ShowcaseRow(
deal_id=deal_id,
district=district,
rooms=2,
area_m2=54.0,
floor=floor,
total_floors=total_floors,
deal_date=deal_date,
deal_quarter="I квартал 2026",
predicted_rub=6_000_000,
fact_rub=5_500_000,
err_pct=err_pct,
n_analogs=40,
)
def test_selection_ignores_error_magnitude() -> None:
"""Точнейшая строка с дырами в данных НЕ должна оказаться впереди полной.
Ломать так: добавить в `_sort_key` слагаемое `abs(row.err_pct)` тест
покраснеет с id 1 на первом месте вместо id 2.
"""
almost_perfect_but_thin = _row(1, district=None, floor=None, err_pct=0.1)
complete_but_worse = _row(2, err_pct=27.0)
chosen = select_rows([almost_perfect_but_thin, complete_but_worse], limit=1)
assert [r.deal_id for r in chosen] == [2], (
"отбор поехал за величиной ошибки — витрина перестала быть выборкой "
"и стала рекламой лучшего хвоста"
)
def test_selection_prefers_fresher_quarter_at_equal_completeness() -> None:
older = _row(1, deal_date=date(2025, 1, 1), err_pct=1.0)
fresher = _row(2, deal_date=date(2026, 4, 1), err_pct=35.0)
assert [r.deal_id for r in select_rows([older, fresher], limit=1)] == [2]
def test_selection_is_deterministic_on_full_ties() -> None:
"""Полные совпадения ключа разводятся id — иначе витрина «мерцает»."""
rows = [_row(7), _row(9), _row(8)]
assert [r.deal_id for r in select_rows(rows, limit=3)] == [9, 8, 7]
# ── Отбраковка: только «данных нет», никогда «число некрасивое» ──────────────
def _build(**over: object) -> ShowcaseRow | None:
kwargs: dict[str, object] = {
"deal_id": 1,
"district": "Кировский",
"rooms": 2,
"area_m2": 50.0,
"floor": 5,
"total_floors": 9,
"deal_date": date(2026, 4, 1),
"predicted_rub": 5_000_000.0,
"fact_ppm2": 100_000.0, # → факт 5 000 000 ₽, ошибка 0%
"n_analogs": 30,
}
kwargs.update(over)
return build_row(**kwargs) # type: ignore[arg-type]
def test_plain_row_survives_and_carries_signed_error() -> None:
row = _build(predicted_rub=5_500_000.0)
assert row is not None
assert row.fact_rub == 5_000_000
assert row.err_pct == 10.0, "знак и база ошибки: (прогноз факт) / факт"
assert row.deal_quarter == "II квартал 2026"
def test_no_error_magnitude_is_ever_rejected() -> None:
"""Промах оценщика ЛЮБОГО размера остаётся на витрине.
Это второй половина запрета «не отбирать по ошибке»: фильтр по величине
ошибки тот же отбор, просто ступенькой раньше, и он тем злее, что не
оставляет строку даже в кандидатах.
Ломать так: вернуть в `build_row` любой порог вида
`if abs(err_pct) > X: return None` тест покраснеет на первом же
отклонении больше X с этим отклонением в сообщении.
"""
fact_rub = 5_000_000.0 # 100 000 ₽/м² × 50 м²
for err_pct in (-95.0, -60.0, -41.0, -5.0, 0.0, 5.0, 41.0, 150.0, 900.0):
row = _build(predicted_rub=fact_rub * (1 + err_pct / 100))
assert row is not None, (
f"строка с отклонением {err_pct:+.0f}% выброшена: витрина снова "
"показывает лучший хвост, а не работу оценщика"
)
assert row.err_pct == round(err_pct, 2)
def test_underdeclared_dkp_is_shown_not_hidden() -> None:
"""Занижение ДКП ради налога выглядит как промах — и всё равно показывается.
Прятать такие строки нельзя: «отклонение больше 40% это почти всегда
дефект ДКП» было догадкой, а санитарный диапазон /м² уже применён к
выборке выше по потоку (`_load_sample`, для ЕКБ 30k..600k). Всё, что
прошло его и дало большую ошибку, работа оценщика. Честность за счёт
строки в `note`, а не за счёт отсева.
"""
# Факт 2 000 000 ₽ против прогноза 5 000 000 — отклонение +150%.
row = _build(fact_ppm2=40_000.0)
assert row is not None
assert row.err_pct == 150.0
def test_ppm2_band_is_not_duplicated_here() -> None:
"""Своей копии ₽/м²-диапазона в `build_row` нет — она была мёртвой.
`MIN_FACT_PPM2 = 30k` дублировал уже применённый фильтр выборки, а
`MAX_FACT_PPM2 = 1.2M` был недостижим при её потолке 600k: из трёх
отбраковок срабатывала ровно одна по ошибке. Ломать так: вернуть любую
из границ покраснеет соответствующая половина.
"""
assert _build(fact_ppm2=20_000.0, predicted_rub=1_000_000.0) is not None
assert _build(fact_ppm2=2_000_000.0, predicted_rub=100_000_000.0) is not None
def test_missing_fact_price_is_rejected() -> None:
"""Нулевая цена сделки — это «данных нет», а не «число некрасивое»: делить не на что."""
assert _build(fact_ppm2=0.0) is None
assert _build(area_m2=0.0) is None
def test_no_expected_sold_price_is_not_invented() -> None:
"""Спайн не дал ожидаемой цены продажи — строки нет. Подставлять нечего."""
assert _build(predicted_rub=None) is None
def test_unknown_quarter_is_not_invented() -> None:
assert _build(deal_date=None) is None
assert quarter_label(None) is None
assert quarter_label(date(2026, 7, 1)) == "III квартал 2026"

View file

@ -0,0 +1,470 @@
"""Витринные метрики лэндинга — задача пересчёта + публичная ручка.
ЧТО ЗДЕСЬ ПРОВЕРЯЕТСЯ И ПОЧЕМУ ИМЕННО ЭТО
1. ЗНАЧЕНИЯ. Арифметика витрины (доля снижавших, нормировка на 30 дней,
округления, какой sample_n к какой метрике) живёт в Python, и она проверена
по ЧИСЛАМ: подставляем агрегаты и сверяем ровно то, что уедет на страницу.
Тест обязан краснеть, если share посчитать от не того знаменателя или
приписать медиане общий n вместо числа снижавших.
2. НЕТ ВХОДА НЕТ СТРОКИ. Отдельная проверка на каждую пустую выборку:
подстановка правдоподобного нуля главный способ соврать на витрине, и
запрещена она поведением задачи, а не комментарием.
3. ГРАНИЦЫ ВЫБОРКИ В SQL. Условия «только domklik», «наблюдение >= 14 дней»,
«|изменение| <= 30%» на mock-сессии не проявляются: их исполняет Postgres.
Поэтому они запинены статически по тексту запроса иначе их молчаливое
исчезновение (а с ним и мусор от yandex-синтетики) прошло бы незамеченным.
4. РУЧКА. Публичность (rbac), форма ответа, и главное пустая таблица даёт
200 и {}, а не 500: это штатное состояние сразу после накатки миграции.
"""
from __future__ import annotations
import os
import re
import sys
from datetime import UTC, datetime
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from app.api.public import mera as public_mera # noqa: E402
from app.core.db import get_db # noqa: E402
from app.core.rbac import _PUBLIC_PATHS, rbac_guard # noqa: E402
from app.tasks import landing_stats as ls # noqa: E402
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_275 = _SQL_DIR / "275_landing_stats.sql"
PREFIX = "/api/public/mera"
# ── Мок-сессия: отдаёт заранее заданную строку на каждый из запросов задачи ───
#
# Раскладываем ответы по ПОРЯДКУ вызовов, а не по тексту SQL: порядок — часть
# контракта collect_landing_metrics (он же порядок метрик на витрине), и его
# перестановка должна быть заметна.
class _FakeSession:
def __init__(self, rows: list[Any], *, prune_rowcount: int = 0) -> None:
self._rows = list(rows)
self.upserts: list[dict[str, Any]] = []
# Чистка протухших метрик: пишем сюда параметры каждого DELETE, чтобы
# тест видел И факт вызова, И список оставляемых метрик.
self.prunes: list[dict[str, Any] | None] = []
self._prune_rowcount = prune_rowcount
self.committed = 0
# Считываем ТОЛЬКО запросы самой витрины: по этой же сессии ходит
# runs_mod (heartbeat/mark_done пишут в scrape_runs), и если раздавать
# заготовленные строки по любому execute, первый же heartbeat съест
# агрегат оценок — тест краснел бы не по своей причине.
_METRIC_SQL_MARKERS = (
"FROM trade_in_estimates",
"FROM listings",
"offer_price_history",
"FROM deals",
)
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
sql = str(stmt)
if "INSERT INTO landing_stats" in sql:
assert params is not None
self.upserts.append(params)
return MagicMock()
if "DELETE FROM landing_stats" in sql:
self.prunes.append(params)
return SimpleNamespace(rowcount=self._prune_rowcount)
if not any(marker in sql for marker in self._METRIC_SQL_MARKERS):
return MagicMock()
assert self._rows, f"неожиданный лишний SELECT: {sql[:80]}"
row = self._rows.pop(0)
return SimpleNamespace(first=lambda: row)
def commit(self) -> None:
self.committed += 1
def rollback(self) -> None: # pragma: no cover — путь ошибки здесь не гоняется
pass
def _rows(**overrides: Any) -> list[Any]:
"""Пять агрегатов в порядке вызова. Значения — прод-срез на 29.08.2026."""
base: dict[str, Any] = {
"estimates": SimpleNamespace(total=1123, period_days=94.0),
"analogs": SimpleNamespace(n=975, median=Decimal("12")),
"listing_age": SimpleNamespace(n=25943, median=Decimal("26")),
"price": SimpleNamespace(n=6276, n_cut=3018, median_pct_per_month=Decimal("-2.174")),
"deals": SimpleNamespace(n=18657),
}
base.update(overrides)
return [base["estimates"], base["analogs"], base["listing_age"], base["price"], base["deals"]]
def _by_metric(metrics: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
return {m["metric"]: m for m in metrics}
# ── 1. Значения ──────────────────────────────────────────────────────────────
def test_all_metrics_computed_from_aggregates() -> None:
"""Каждая витринная цифра — ровно то, что следует из выборки."""
got = _by_metric(ls.collect_landing_metrics(_FakeSession(_rows())))
assert got["estimates_total"]["value_num"] == 1123.0
assert got["estimates_total"]["sample_n"] == 1123
assert got["estimates_period_days"]["value_num"] == 94.0
assert got["analogs_median"]["value_num"] == 12.0
assert got["analogs_median"]["sample_n"] == 975
assert got["listing_age_median_days"]["value_num"] == 26.0
assert got["deals_total_12m"]["value_num"] == 18657.0
# 3018/6276 = 48.087...% → 48.1 после округления до десятых.
assert got["price_cut_share_pct"]["value_num"] == 48.1
assert got["price_cut_share_pct"]["sample_n"] == 6276
assert got["price_cut_median_pct_per_month"]["value_num"] == -2.17
# Медиана считается ТОЛЬКО по снижавшим — и выборка у неё их, а не общая.
assert got["price_cut_median_pct_per_month"]["sample_n"] == 3018
def test_share_uses_full_observed_denominator_not_only_cutters() -> None:
"""Знаменатель доли — все наблюдавшиеся, а не только снижавшие.
Если считать от снижавших, доля всегда 100% ровно тот дефект, который на
проде давал 85% вместо 48% (в выборку попадали только менявшие цену).
"""
rows = _rows(price=SimpleNamespace(n=200, n_cut=50, median_pct_per_month=Decimal("-3")))
got = _by_metric(ls.collect_landing_metrics(_FakeSession(rows)))
assert got["price_cut_share_pct"]["value_num"] == 25.0
def test_every_metric_carries_sample_n() -> None:
"""Цифра без размера выборки неотличима от литерала, ради замены которого
вся эта таблица и заведена."""
for m in ls.collect_landing_metrics(_FakeSession(_rows())):
assert isinstance(m["sample_n"], int) and m["sample_n"] > 0, m["metric"]
assert m["note"], m["metric"]
def test_listing_age_note_says_exposure_not_time_to_sell() -> None:
"""Величина по построению — экспозиция ЕЩЁ ВИСЯЩЕГО объявления. Названная
«сроком продажи», она врёт (и врёт в выгодную сторону)."""
got = _by_metric(ls.collect_landing_metrics(_FakeSession(_rows())))
note = got["listing_age_median_days"]["note"]
assert "ЭКСПОЗИЦИЯ" in note
assert "НЕ срок продажи" in note
def test_forecast_accuracy_and_time_to_sell_are_never_produced() -> None:
"""Этих величин в данных нет; их считает бэктест со своими допущениями."""
names = {m["metric"] for m in ls.collect_landing_metrics(_FakeSession(_rows()))}
assert not {n for n in names if "accuracy" in n or "time_to_sell" in n or "days_to_sell" in n}
# ── 2. Нет входа — нет строки ────────────────────────────────────────────────
@pytest.mark.parametrize(
("kwargs", "absent"),
[
({"estimates": SimpleNamespace(total=0, period_days=None)}, "estimates_total"),
({"analogs": SimpleNamespace(n=0, median=None)}, "analogs_median"),
({"listing_age": SimpleNamespace(n=0, median=None)}, "listing_age_median_days"),
(
{"price": SimpleNamespace(n=0, n_cut=0, median_pct_per_month=None)},
"price_cut_share_pct",
),
({"deals": SimpleNamespace(n=0)}, "deals_total_12m"),
],
)
def test_empty_input_writes_no_row_instead_of_zero(kwargs: dict[str, Any], absent: str) -> None:
"""Ноль читается как измеренный ноль («никто не снижал цену») — а измерения
не было. Строки просто нет, фронт не рисует блок."""
names = {m["metric"] for m in ls.collect_landing_metrics(_FakeSession(_rows(**kwargs)))}
assert absent not in names
def test_single_estimate_gives_no_period_metric() -> None:
"""Период между первым и последним расчётом при одном расчёте — 0 дней,
что является артефактом единственной точки, а не сроком работы."""
rows = _rows(estimates=SimpleNamespace(total=1, period_days=0.0))
names = {m["metric"] for m in ls.collect_landing_metrics(_FakeSession(rows))}
assert "estimates_total" in names
assert "estimates_period_days" not in names
def test_no_cutters_leaves_share_but_drops_median() -> None:
"""Никто не снижал — доля 0% ИЗМЕРЕНА (наблюдения были), а медианы снижения
не существует: писать её нулём значило бы выдумать «снижают на 0%»."""
rows = _rows(price=SimpleNamespace(n=120, n_cut=0, median_pct_per_month=None))
got = _by_metric(ls.collect_landing_metrics(_FakeSession(rows)))
assert got["price_cut_share_pct"]["value_num"] == 0.0
assert "price_cut_median_pct_per_month" not in got
def test_refresh_upserts_every_metric_and_commits() -> None:
db = _FakeSession(_rows())
counters = ls.refresh_landing_stats(db, run_id=1) # type: ignore[arg-type]
assert counters["metrics_written"] == len(db.upserts) == 7
# >=1, а не ==1: runs_mod коммитит свои heartbeat/mark_done по той же сессии.
assert db.committed >= 1
assert {u["metric"] for u in db.upserts} == {
"estimates_total",
"estimates_period_days",
"analogs_median",
"listing_age_median_days",
"price_cut_share_pct",
"price_cut_median_pct_per_month",
"deals_total_12m",
}
def test_metric_that_stopped_computing_is_deleted_not_left_stale() -> None:
"""Пропал вход у метрики — строка УДАЛЯЕТСЯ, а не доживает со старым
computed_at: иначе ручка отдаёт её неотличимо от посчитанной сегодня.
Здесь сделок нет (`deals.n = 0`), значит `deals_total_12m` в наборе не
появляется и именно её обязан вынести DELETE, оставив ровно посчитанные.
"""
rows = _rows(deals=SimpleNamespace(n=0))
db = _FakeSession(rows, prune_rowcount=1)
counters = ls.refresh_landing_stats(db, run_id=1) # type: ignore[arg-type]
assert len(db.prunes) == 1, "чистка протухших метрик не выполнена"
kept = set(db.prunes[0]["kept"]) # type: ignore[index]
assert kept == {u["metric"] for u in db.upserts}
assert "deals_total_12m" not in kept, "метрика без входа осталась бы на витрине"
assert counters["metrics_removed"] == 1
def test_totally_empty_run_keeps_the_showcase_instead_of_wiping_it() -> None:
"""Разом пропали ВСЕ входы — это похоже на поломку прогона (пустая или
недоступная база), а не на пять одновременных «данных больше нет». По такому
признаку витрина не стирается: DELETE не выполняется вовсе."""
empty = _rows(
estimates=SimpleNamespace(total=0, period_days=None),
analogs=SimpleNamespace(n=0, median=None),
listing_age=SimpleNamespace(n=0, median=None),
price=SimpleNamespace(n=0, n_cut=0, median_pct_per_month=None),
deals=SimpleNamespace(n=0),
)
db = _FakeSession(empty)
counters = ls.refresh_landing_stats(db, run_id=1) # type: ignore[arg-type]
assert db.upserts == []
assert db.prunes == [], "пустой прогон стёр бы всю витрину"
assert counters["metrics_written"] == 0
assert counters["metrics_removed"] == 0
# ── 3. Границы выборки, которые исполняет Postgres ───────────────────────────
def test_price_sql_takes_domklik_only() -> None:
"""avito/yandex сюда попасть не могут: у первого нет стартовой цены в
истории, второй сеет синтетическую пару со сдвигом в сутки."""
sql = str(ls._PRICE_MOVES_SQL)
assert "source = 'domklik'" in sql
assert "avito" not in sql and "yandex" not in sql
def test_price_sql_keeps_single_row_listings_in_denominator() -> None:
"""Знаменатель доли снижений включает объявления с ОДНОЙ записью истории.
Это тот самый дефект, из-за которого на проде получалось бы 84.8% вместо
48.1%: у domklik триггер пишет стартовую цену, поэтому одна запись означает
«цену не менял» наблюдение, а не отсутствие данных. Выкинув такие строки,
считаешь долю снижавших ТОЛЬКО среди менявших цену, то есть почти единицу.
Гейт текстовый, а не прогон на живой базе: DATABASE_URL в CI
заглушка (deploy-tradein.yml: `test:` job), Postgres в тестовой джобе нет,
и живой тест по образцу test_purge_expired_trade_in_data.py тут молча
скипался бы то есть не гейтил бы ничего. Пин проверяет две половины
дефекта: (1) однострочные попадают в `moved` через ветку CASE со значением
0 («не снижал»), а не отбрасываются; (2) нигде в запросе нет фильтра по
числу записей, который бы их отсёк.
"""
sql = str(ls._PRICE_MOVES_SQL)
case = re.search(r"CASE\b(?P<body>.*?)\bEND\b", sql, re.S | re.I)
assert case is not None, "исчезла ветка для однострочных — они больше не «не снижал»"
body = case.group("body")
assert "n_rows" in body, "ветка перестала различать однострочные записи истории"
assert re.search(r"\b(THEN|ELSE)\s+0\b", body), (
"однострочным объявлениям больше не приписывается изменение 0%"
"они либо выпали из выборки, либо получили выдуманное значение"
)
rest = sql.replace(case.group(0), "")
leftover = re.search(r"n_rows\s*(>=|>|<|<>|=|!=)", rest)
assert leftover is None, (
f"появился фильтр по числу записей истории вне ветки CASE ({leftover.group(0)!r}) — "
"он выкидывает не менявших цену из знаменателя, доля вырастет с ~48% до ~85%"
)
assert not re.search(r"\bHAVING\b", rest, re.I), (
"HAVING в агрегате истории отсекает однострочные ещё до знаменателя"
)
def test_price_sql_keeps_span_and_outlier_gates() -> None:
sql = str(ls._PRICE_MOVES_SQL)
assert "span_days" in sql, "исчез порог наблюдения — короткоживущие дадут ложное «не снижал»"
assert "max_abs_pct" in sql, "исчезла отсечка аномалий — перевыставленные объекты как торг"
assert ls._PRICE_SPAN_DAYS == 14
assert ls._PRICE_MAX_ABS_PCT == 30
def test_city_scoped_metrics_are_parameterised_by_ekb() -> None:
for sql in (str(ls._LISTING_AGE_SQL), str(ls._DEALS_SQL)):
assert "CAST(:city AS text)" in sql
assert ls.EKB == "Екатеринбург"
def test_analogs_median_excludes_estimates_without_analogs() -> None:
"""n_analogs=0 — это отказ расчёта, а не «ноль аналогов»; в медиане он
занизил бы величину наблюдением, где мерить было нечего."""
assert "n_analogs > 0" in str(ls._ANALOGS_SQL)
# ── Миграция ─────────────────────────────────────────────────────────────────
def test_migration_275_is_idempotent_and_registers_the_job() -> None:
sql = _MIGRATION_275.read_text("utf-8")
assert "CREATE TABLE IF NOT EXISTS landing_stats" in sql
assert "metric text PRIMARY KEY" in sql
assert "ON CONFLICT (source) DO NOTHING" in sql
assert "'landing_stats_refresh'" in sql
def test_migration_275_has_no_psycopg_cast_trap() -> None:
"""`:x::type` psycopg v3 разбирает как именованный параметр — в проекте
разрешён только CAST(:x AS type)."""
assert not re.search(r":\w+::", _MIGRATION_275.read_text("utf-8"))
def test_task_is_registered_in_the_scheduler_registry() -> None:
"""Без Handler'а строка расписания резолвится в никуда и джоба не бежит."""
from app.services.product_handlers import build_product_handlers
handlers = build_product_handlers(MagicMock())
assert "landing_stats_refresh" in handlers
# ── 4. Публичная ручка ───────────────────────────────────────────────────────
_STAT_ROWS = [
SimpleNamespace(
metric="estimates_total",
value_num=Decimal("1123"),
value_text=None,
sample_n=1123,
note="Расчётов сделано",
computed_at=datetime(2026, 8, 29, 5, 0, tzinfo=UTC),
),
SimpleNamespace(
metric="price_cut_share_pct",
value_num=Decimal("48.1"),
value_text=None,
sample_n=6276,
note="Только Домклик",
computed_at=datetime(2026, 8, 29, 5, 0, tzinfo=UTC),
),
]
def _client(rows: list[Any]) -> TestClient:
"""Приложение с РЕАЛЬНЫМ rbac_guard — тем же, что вешает app/main.py."""
app = FastAPI()
app.middleware("http")(rbac_guard)
app.include_router(public_mera.router, prefix=PREFIX)
db = MagicMock()
db.execute.return_value.fetchall.return_value = rows
def _override_db():
yield db
app.dependency_overrides[get_db] = _override_db
return TestClient(app)
@pytest.fixture(autouse=True)
def _reset_stats_limiter():
public_mera._stats_limiter._hits.clear()
yield
public_mera._stats_limiter._hits.clear()
def test_stats_path_is_public_in_rbac() -> None:
assert f"{PREFIX}/stats" in _PUBLIC_PATHS, (
"без строки в rbac._PUBLIC_PATHS анониму прилетит 401 и лэндинг останется без чисел"
)
def test_anonymous_gets_stats_keyed_by_metric() -> None:
resp = _client(_STAT_ROWS).get(f"{PREFIX}/stats")
assert resp.status_code == 200
body = resp.json()
assert set(body) == {"estimates_total", "price_cut_share_pct"}
assert body["estimates_total"]["value"] == 1123.0
assert body["price_cut_share_pct"]["value"] == 48.1
assert body["price_cut_share_pct"]["sample_n"] == 6276
assert body["price_cut_share_pct"]["note"] == "Только Домклик"
assert body["estimates_total"]["computed_at"].startswith("2026-08-29T05:00")
def test_empty_table_is_a_valid_answer_not_an_error() -> None:
"""Состояние сразу после накатки миграции: задача ещё не отрабатывала.
500 здесь сломал бы страницу целиком ради отсутствующего блока."""
resp = _client([]).get(f"{PREFIX}/stats")
assert resp.status_code == 200
assert resp.json() == {}
def test_metric_without_numeric_value_falls_back_to_text_then_null() -> None:
rows = [
SimpleNamespace(
metric="period_label",
value_num=None,
value_text="май–август 2026",
sample_n=1123,
note=None,
computed_at=datetime(2026, 8, 29, tzinfo=UTC),
),
SimpleNamespace(
metric="nothing_measured",
value_num=None,
value_text=None,
sample_n=None,
note=None,
computed_at=datetime(2026, 8, 29, tzinfo=UTC),
),
]
body = _client(rows).get(f"{PREFIX}/stats").json()
assert body["period_label"]["value"] == "май–август 2026"
assert body["nothing_measured"]["value"] is None
def test_stats_rate_limited_per_ip() -> None:
client = _client(_STAT_ROWS)
codes = [client.get(f"{PREFIX}/stats").status_code for _ in range(public_mera._STATS_LIMIT + 1)]
assert codes[-1] == 429
assert set(codes[:-1]) == {200}

View file

@ -0,0 +1,800 @@
"""Роутер платежей (app/api/v1/payments.py): идемпотентность выдачи, подпись,
kill-switch, capability-ссылка.
ПОЧЕМУ здесь свой мини-эмулятор БД, а не MagicMock. Проверяемое свойство
«повторная нотификация НЕ создаёт вторую выдачу» целиком держится на UNIQUE
из миграции 233 плюс `ON CONFLICT DO NOTHING`. MagicMock отдаёт то, что ему
скажут, поэтому такой тест был бы зелёным по построению: он не покраснел бы,
если убрать `ON CONFLICT` или заменить его на «SELECT, потом INSERT».
`_FakeDb` ниже объявляет UNIQUE-ключи ОТДЕЛЬНО от проверяемого SQL ровно
теми колонками, что записаны в миграции, и ведёт себя как Postgres: дубль
без `ON CONFLICT` падает ошибкой, дубль с `ON CONFLICT DO NOTHING` не
возвращает строку.
Фальсификация проверена руками (каждый раз краснеет ИМЕННО тот тест, который
про это свойство, и по значению, а не по ImportError):
- убрать `ON CONFLICT DO NOTHING` из INSERT в `payment_entitlements`
`test_retry_after_crash_between_issue_and_processed_does_not_double_issue`
падает 500 вместо "OK";
- убрать его же из INSERT в `payment_notifications` падают все три теста
про идемпотентность;
- отключить проверку подписи `test_notification_with_invalid_token_is_rejected`;
- отключить проверку срока `test_report_link_rejects_expired_token`;
- убрать `ON CONFLICT DO NOTHING` из INSERT в `payments`
`test_parallel_checkout_does_not_create_second_payment` краснеет
_UniqueViolationError вместо 409;
- растянуть `_ABANDONED_AFTER_MINUTES` до бесконечности (= убрать границу по
времени) `test_abandoned_checkout_does_not_lock_the_buyer_out` получает в
ответе мёртвую ссылку красное ПО ЗНАЧЕНИЮ;
- убрать `_assert_estimate_access` из checkout `test_checkout_rejects_foreign_estimate`
видит 200 и созданный платёж вместо 404.
SQLite вместо этого не годится: NULLS NOT DISTINCT, jsonb, make_interval и
CAST(:x AS uuid) там не существуют, а настоящий Postgres в юнит-тестах этого
репозитория не поднимается (см. tests/conftest.py DATABASE_URL заглушка).
"""
from __future__ import annotations
import os
import re
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from pydantic import SecretStr # noqa: E402
_PASSWORD = "test-terminal-password"
_ORDER_ID = "mera-0123456789abcdef0123456789abcdef"
_PAYMENT_ID = "3000000001"
_PAYMENT_UUID = "22222222-2222-2222-2222-222222222222"
_ESTIMATE_UUID = "33333333-3333-3333-3333-333333333333"
_AMOUNT = 15_000
_REPO_ROOT = Path(__file__).resolve().parents[1].parent
_MIGRATION = _REPO_ROOT / "backend" / "data" / "sql" / "233_payments.sql"
_CONTENT_TS = _REPO_ROOT / "frontend" / "src" / "app" / "mera-public" / "content.ts"
class _UniqueViolationError(RuntimeError):
"""Стенд-in для psycopg UniqueViolation — INSERT без ON CONFLICT в дубль."""
class _FakeDb:
"""Мини-Postgres на словарях: только те statement'ы, что шлёт роутер.
UNIQUE-ключи заданы ЗДЕСЬ, по миграции 233, а не выведены из проверяемого
SQL иначе тест поедет вслед за дефектом вместо того, чтобы его поймать.
NULL считается равным NULL (NULLS NOT DISTINCT), как в миграции.
"""
_NOTIFICATION_KEY = ("tbank_payment_id", "status", "amount_kopecks", "token")
_ENTITLEMENT_KEY = ("payment_id", "kind", "ref_id")
# Частичный UNIQUE миграции 279: ключ (estimate_id, product_code), предикат —
# «живые» статусы. Переписан здесь ПО МИГРАЦИИ, а не импортирован из
# payments.py: иначе тест поехал бы вслед за дефектом. Совпадение списка с
# кодом отдельно гейтит test_live_status_predicate_matches_code.
_LIVE_PAYMENT_KEY = ("estimate_id", "product_code")
_LIVE_STATUSES = frozenset(
{
"NEW",
"FORM_SHOWED",
"PREAUTHORIZING",
"AUTHORIZING",
"AUTHORIZED",
"3DS_CHECKING",
"3DS_CHECKED",
"CHECKING",
"CHECKED",
"PROCESSING",
"CONFIRMING",
}
)
def __init__(self) -> None:
self.notifications: list[SimpleNamespace] = []
self.entitlements: list[SimpleNamespace] = []
self.payments: list[SimpleNamespace] = [
SimpleNamespace(
id=_PAYMENT_UUID,
order_id=_ORDER_ID,
status="NEW",
amount_kopecks=_AMOUNT,
estimate_id=_ESTIMATE_UUID,
created_by=None,
# product_code=None — эта строка обслуживает тесты нотификаций и
# не должна попадать в ключ живого платежа checkout-тестов.
product_code=None,
payment_url=None,
created_at=datetime.now(tz=UTC),
)
]
self.estimate_created_by: str | None = None
self.retain_until_updates: list[str] = []
self.commits = 0
# -- SQLAlchemy-совместимая поверхность -------------------------------
def execute(self, statement: Any, params: dict[str, Any] | None = None) -> Any:
sql = " ".join(str(statement).split())
params = params or {}
if "INSERT INTO payment_notifications" in sql:
return self._insert_notification(sql, params)
if "FROM payment_notifications" in sql and sql.startswith("SELECT"):
return _Result(self._find_notification(params))
if "UPDATE payment_notifications" in sql:
for row in self.notifications:
if row.id == params["id"] and row.processed_at is None:
row.processed_at = datetime.now(tz=UTC)
return _Result(None)
if "INSERT INTO payment_entitlements" in sql:
return self._insert_entitlement(sql, params)
if "FROM payment_entitlements" in sql and sql.startswith("SELECT"):
return _Result(
next(
(
row
for row in self.entitlements
if row.kind == params["kind"] and row.subject == params["token"]
),
None,
)
)
if "INSERT INTO payments" in sql:
return self._insert_payment(sql, params)
if "FROM payments" in sql and sql.startswith("SELECT"):
if "estimate_id" in params:
return _Result(self._find_live_payment(params))
return _Result(
next((p for p in self.payments if p.order_id == params.get("order_id")), None)
)
if "UPDATE payments" in sql and "DEADLINE_EXPIRED" in sql:
return _Result(self._expire_abandoned(params))
if "UPDATE payments" in sql:
for payment in self.payments:
if payment.order_id == params["order_id"] and "status" in params:
payment.status = params["status"]
if "payment_url" in params:
payment.payment_url = params["payment_url"]
return _Result(None)
if "FROM trade_in_estimates" in sql and sql.startswith("SELECT"):
if params.get("id") != _ESTIMATE_UUID:
return _Result(None)
return _Result(SimpleNamespace(id=_ESTIMATE_UUID, created_by=self.estimate_created_by))
if "UPDATE trade_in_estimates" in sql:
self.retain_until_updates.append(params["id"])
return _Result(None)
raise AssertionError(f"неожиданный SQL в тесте: {sql[:120]}")
def commit(self) -> None:
self.commits += 1
def close(self) -> None:
pass
# -- эмуляция UNIQUE ---------------------------------------------------
def _insert_notification(self, sql: str, params: dict[str, Any]) -> _Result:
key = tuple(
params[name]
for name in ("payment_id", "status", "amount", "token") # порядок = _NOTIFICATION_KEY
)
if any(self._key_of(row, self._NOTIFICATION_KEY) == key for row in self.notifications):
return self._conflict(sql)
row = SimpleNamespace(
id=len(self.notifications) + 1,
order_id=params["order_id"],
tbank_payment_id=params["payment_id"],
status=params["status"],
amount_kopecks=params["amount"],
token=params["token"],
token_valid=params["token_valid"],
processed_at=None,
)
self.notifications.append(row)
return _Result(row)
def _insert_payment(self, sql: str, params: dict[str, Any]) -> _Result:
"""Ведёт себя как Postgres с частичным UNIQUE миграции 279.
Конфликт наступает только когда УЖЕ есть строка с той же парой
(estimate_id, product_code) И статусом из предиката ровно как у
частичного индекса. Без `ON CONFLICT DO NOTHING` в SQL исключение.
"""
key = (params["estimate_id"], params["product_code"])
if key[0] is not None and any(
self._key_of(row, self._LIVE_PAYMENT_KEY) == key and row.status in self._LIVE_STATUSES
for row in self.payments
):
return self._conflict(sql)
row = SimpleNamespace(
id=f"pay-{len(self.payments) + 1}",
order_id=params["order_id"],
status="NEW",
amount_kopecks=params["amount"],
estimate_id=params["estimate_id"],
created_by=params["created_by"],
product_code=params["product_code"],
payment_url=None,
created_at=datetime.now(tz=UTC),
)
self.payments.append(row)
return _Result(row)
def _find_live_payment(self, params: dict[str, Any]) -> SimpleNamespace | None:
return next(
(
row
for row in self.payments
if self._key_of(row, self._LIVE_PAYMENT_KEY)
== (params["estimate_id"], params["product_code"])
and row.payment_url is not None
and row.status in set(params["reusable"])
),
None,
)
def _expire_abandoned(self, params: dict[str, Any]) -> None:
deadline = datetime.now(tz=UTC) - timedelta(minutes=int(params["mins"]))
for row in self.payments:
if (
row.estimate_id == params["estimate_id"]
and row.product_code == params["product_code"]
and row.status in set(params["abandonable"])
and row.created_at < deadline
):
row.status = "DEADLINE_EXPIRED"
return None
def _insert_entitlement(self, sql: str, params: dict[str, Any]) -> _Result:
key = (params["payment_id"], params["kind"], params["ref_id"])
if any(self._key_of(row, self._ENTITLEMENT_KEY) == key for row in self.entitlements):
return self._conflict(sql)
row = SimpleNamespace(
id=f"ent-{len(self.entitlements) + 1}",
payment_id=params["payment_id"],
subject=params["subject"],
kind=params["kind"],
ref_id=params["ref_id"],
expires_at=datetime.now(tz=UTC) + timedelta(days=int(params["days"])),
)
self.entitlements.append(row)
return _Result(row)
@staticmethod
def _key_of(row: SimpleNamespace, names: tuple[str, ...]) -> tuple[Any, ...]:
return tuple(getattr(row, name) for name in names)
@staticmethod
def _conflict(sql: str) -> _Result:
if "ON CONFLICT DO NOTHING" not in sql:
raise _UniqueViolationError("duplicate key value violates unique constraint")
return _Result(None)
def _find_notification(self, params: dict[str, Any]) -> SimpleNamespace | None:
key = (params["payment_id"], params["status"], params["amount"], params["token"])
return next(
(row for row in self.notifications if self._key_of(row, self._NOTIFICATION_KEY) == key),
None,
)
class _Result:
def __init__(self, row: Any) -> None:
self._row = row
def fetchone(self) -> Any:
return self._row
@pytest.fixture()
def db() -> _FakeDb:
return _FakeDb()
@pytest.fixture()
def client(db: _FakeDb, monkeypatch: pytest.MonkeyPatch) -> TestClient:
from app.api.v1 import payments as payments_module
from app.core.config import settings
from app.core.db import get_db
monkeypatch.setattr(settings, "payments_enabled", True)
monkeypatch.setattr(settings, "tbank_password", SecretStr(_PASSWORD))
monkeypatch.setattr(settings, "tbank_terminal_key", "TERM-TEST")
app = FastAPI()
app.include_router(payments_module.router, prefix="/api/v1/trade-in")
app.dependency_overrides[get_db] = lambda: db
return TestClient(app)
def _signed_notification(**overrides: Any) -> dict[str, Any]:
from app.services.payments.token import sign
body: dict[str, Any] = {
"TerminalKey": "TERM-TEST",
"OrderId": _ORDER_ID,
"PaymentId": _PAYMENT_ID,
"Status": "CONFIRMED",
"Success": True,
"Amount": _AMOUNT,
}
body.update(overrides)
body["Token"] = sign(body, _PASSWORD)
return body
# ── идемпотентность выдачи ───────────────────────────────────────────────────
def test_repeat_notification_issues_only_one_entitlement(client: TestClient, db: _FakeDb) -> None:
"""Ретрай банка (тот же Token) не выдаёт второй отчёт и не рвёт ответ "OK".
Фальсификация (проверено руками): убрать `ON CONFLICT DO NOTHING` из
INSERT в payment_entitlements второй запрос падает _UniqueViolationError и
тест краснеет на status_code 500; заменить дедуп на «SELECT потом INSERT»
и снять UNIQUE len(entitlements) == 2.
"""
body = _signed_notification()
first = client.post("/api/v1/trade-in/payments/notify", json=body)
second = client.post("/api/v1/trade-in/payments/notify", json=body)
assert first.status_code == 200, first.text
assert first.text == "OK"
assert second.status_code == 200, second.text
assert second.text == "OK"
assert len(db.entitlements) == 1, "повторная нотификация выдала второй отчёт"
assert len(db.notifications) == 1, "дубль нотификации записался второй строкой"
assert db.notifications[0].processed_at is not None
assert db.retain_until_updates == [_ESTIMATE_UUID]
def test_unprocessed_duplicate_is_fulfilled_on_retry(client: TestClient, db: _FakeDb) -> None:
"""Строка нотификации есть, а processed_at пуст → выдача ОБЯЗАНА состояться.
Это контракт processed_at из миграции 233: падение процесса между записью
нотификации и выдачей не должно оставить клиента без товара при списанных
деньгах. Красный вариант трактовать существование строки как «уже
обработано» (тогда entitlements пуст).
"""
body = _signed_notification()
db.notifications.append(
SimpleNamespace(
id=1,
order_id=_ORDER_ID,
tbank_payment_id=_PAYMENT_ID,
status="CONFIRMED",
amount_kopecks=_AMOUNT,
token=body["Token"],
token_valid=True,
processed_at=None,
)
)
response = client.post("/api/v1/trade-in/payments/notify", json=body)
assert response.status_code == 200, response.text
assert len(db.entitlements) == 1
assert db.notifications[0].processed_at is not None
def test_retry_after_crash_between_issue_and_processed_does_not_double_issue(
client: TestClient, db: _FakeDb
) -> None:
"""Худший реальный случай: выдача прошла, а processed_at проставить не успели.
Ретрай банка ОБЯЗАН дойти до конца (иначе processed_at не проставится
никогда и так будет каждый час сутки) и при этом не выдать второй отчёт.
Единственное, что здесь работает, UNIQUE (payment_id, kind, ref_id) +
ON CONFLICT DO NOTHING: `_FakeDb` ведёт себя как Postgres и на INSERT без
ON CONFLICT кидает _UniqueViolationError (проверено руками: убрать ON CONFLICT
из INSERT в payment_entitlements 500 вместо "OK", тест краснеет).
"""
body = _signed_notification()
db.notifications.append(
SimpleNamespace(
id=1,
order_id=_ORDER_ID,
tbank_payment_id=_PAYMENT_ID,
status="CONFIRMED",
amount_kopecks=_AMOUNT,
token=body["Token"],
token_valid=True,
processed_at=None,
)
)
_issue_entitlement(db, "already-issued-token", expires_at=None)
response = client.post("/api/v1/trade-in/payments/notify", json=body)
assert response.status_code == 200, response.text
assert response.text == "OK"
assert len(db.entitlements) == 1, "выдан второй отчёт по тому же платежу"
assert db.entitlements[0].subject == "already-issued-token", "токен подменён на новый"
assert db.notifications[0].processed_at is not None
# ── подпись и сумма ──────────────────────────────────────────────────────────
def test_notification_with_invalid_token_is_rejected(client: TestClient, db: _FakeDb) -> None:
body = _signed_notification()
body["Token"] = "deadbeef" * 8 # подпись не от нашего пароля
response = client.post("/api/v1/trade-in/payments/notify", json=body)
assert response.status_code == 403
assert db.entitlements == [], "выдача по неподписанной нотификации"
assert len(db.notifications) == 1, "факт попытки должен остаться в append-only логе"
assert db.notifications[0].token_valid is False
def test_amount_mismatch_is_rejected(client: TestClient, db: _FakeDb) -> None:
"""Подпись Т-Банка не гарантирует сумму (см. docstring notification.py) —
расхождение с суммой заказа обязано быть отказом, а не выдачей."""
response = client.post(
"/api/v1/trade-in/payments/notify", json=_signed_notification(Amount=_AMOUNT + 1)
)
assert response.status_code == 400
assert db.entitlements == []
def test_non_confirmed_status_does_not_fulfill(client: TestClient, db: _FakeDb) -> None:
response = client.post(
"/api/v1/trade-in/payments/notify", json=_signed_notification(Status="AUTHORIZED")
)
assert response.status_code == 200
assert db.entitlements == []
assert db.payments[0].status == "AUTHORIZED"
def test_pre_confirm_notification_does_not_downgrade_confirmed(
client: TestClient, db: _FakeDb
) -> None:
"""Отставший AUTHORIZED не имеет права откатить уже подтверждённый платёж."""
db.payments[0].status = "CONFIRMED"
client.post("/api/v1/trade-in/payments/notify", json=_signed_notification(Status="AUTHORIZED"))
assert db.payments[0].status == "CONFIRMED"
# ── capability-ссылка ────────────────────────────────────────────────────────
def _issue_entitlement(db: _FakeDb, token: str, *, expires_at: datetime | None) -> None:
db.entitlements.append(
SimpleNamespace(
id="ent-1",
payment_id=_PAYMENT_UUID,
subject=token,
kind="report_link",
ref_id=_ESTIMATE_UUID,
expires_at=expires_at,
)
)
def test_report_link_serves_estimate_for_valid_token(
client: TestClient, db: _FakeDb, monkeypatch: pytest.MonkeyPatch
) -> None:
from app.api.v1 import payments as payments_module
from app.schemas.trade_in import AggregatedEstimate
seen: dict[str, Any] = {}
def _fake_loader(_db: Any, estimate_id: Any, **kwargs: Any) -> AggregatedEstimate:
seen["estimate_id"] = str(estimate_id)
seen.update(kwargs)
return AggregatedEstimate(
estimate_id=_ESTIMATE_UUID,
median_price_rub=5_000_000,
range_low_rub=4_500_000,
range_high_rub=5_500_000,
median_price_per_m2=100_000,
confidence="medium",
n_analogs=7,
period_months=6,
analogs=[],
actual_deals=[],
expires_at=datetime.now(tz=UTC) + timedelta(hours=12),
)
monkeypatch.setattr(payments_module, "load_estimate", _fake_loader)
_issue_entitlement(db, "good-token", expires_at=datetime.now(tz=UTC) + timedelta(days=1))
response = client.get("/api/v1/trade-in/r/good-token")
assert response.status_code == 200, response.text
assert seen["estimate_id"] == _ESTIMATE_UUID
assert seen["capability_granted"] is True
assert seen["x_authenticated_user"] is None
def test_report_link_rejects_foreign_token(client: TestClient, db: _FakeDb) -> None:
_issue_entitlement(db, "good-token", expires_at=datetime.now(tz=UTC) + timedelta(days=1))
response = client.get("/api/v1/trade-in/r/someone-elses-token")
assert response.status_code == 404
def test_report_link_rejects_expired_token(client: TestClient, db: _FakeDb) -> None:
_issue_entitlement(db, "stale-token", expires_at=datetime.now(tz=UTC) - timedelta(seconds=1))
response = client.get("/api/v1/trade-in/r/stale-token")
assert response.status_code == 404
def test_issued_token_is_unpredictable(client: TestClient, db: _FakeDb) -> None:
"""Токен — это всё право доступа: он обязан быть случайным, а не производной
от order_id/payment_id (иначе выводится по данным, которые видит покупатель)."""
client.post("/api/v1/trade-in/payments/notify", json=_signed_notification())
token = db.entitlements[0].subject
assert len(token) >= 40
assert _ORDER_ID not in token
assert _PAYMENT_ID not in token
# ── kill-switch ──────────────────────────────────────────────────────────────
def test_endpoints_are_closed_when_payments_disabled(
db: _FakeDb, monkeypatch: pytest.MonkeyPatch
) -> None:
"""PAYMENTS_ENABLED=false — 503 на всех ручках, без падений и без записи в БД."""
from app.api.v1 import payments as payments_module
from app.core.config import settings
from app.core.db import get_db
monkeypatch.setattr(settings, "payments_enabled", False)
app = FastAPI()
app.include_router(payments_module.router, prefix="/api/v1/trade-in")
app.dependency_overrides[get_db] = lambda: db
disabled = TestClient(app)
assert disabled.get("/api/v1/trade-in/r/any-token").status_code == 503
assert disabled.get(f"/api/v1/trade-in/payments/status/{_ORDER_ID}").status_code == 503
assert disabled.post("/api/v1/trade-in/payments/notify", json={}).status_code == 503
assert (
disabled.post(
"/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}
).status_code
== 503
)
assert db.notifications == []
assert db.entitlements == []
# ── периметр и синхронизация констант ────────────────────────────────────────
def test_notify_is_public_and_checkout_is_not() -> None:
from app.core.rbac import _PUBLIC_PATH_PREFIXES, _PUBLIC_PATHS
assert "/api/v1/trade-in/payments/notify" in _PUBLIC_PATHS
assert "/api/v1/trade-in/payments/checkout" not in _PUBLIC_PATHS
assert "/api/v1/trade-in/r/some-token".startswith(_PUBLIC_PATH_PREFIXES)
assert not "/api/v1/trade-in/history".startswith(_PUBLIC_PATH_PREFIXES)
def test_report_link_token_is_redacted_from_sentry_events() -> None:
from app.observability.sentry_scrub import scrub_pii_event
event = {
"request": {"url": "https://meraocenka.ru/api/v1/trade-in/r/s3cr3t-token-value"},
"transaction": "GET /api/v1/trade-in/r/s3cr3t-token-value",
}
scrubbed = scrub_pii_event(event, {}) # type: ignore[arg-type]
assert "s3cr3t-token-value" not in str(scrubbed)
assert "/api/v1/trade-in/r/[REDACTED]" in scrubbed["transaction"] # type: ignore[index]
def test_status_whitelist_matches_migration() -> None:
"""Свой список статусов не должен разъезжаться с CHECK миграции 233:
статус, которого нет в CHECK, уронит INSERT уже на проде."""
from app.api.v1.payments import _KNOWN_STATUSES
source = _MIGRATION.read_text(encoding="utf-8")
check = re.search(
r"ADD CONSTRAINT\s+payments_status_check\s+CHECK \(status IN \((.*?)\)\)", source, re.S
)
assert check is not None, "CHECK payments_status_check не найден в миграции 233"
migration_statuses = set(re.findall(r"'([^']+)'", check.group(1)))
assert _KNOWN_STATUSES == migration_statuses
def test_price_matches_published_offer() -> None:
"""Цена в коде обязана совпадать с ценой, названной в оферте (п. 4.1) —
единственный её источник для юр-текстов, mera-public/content.ts."""
from app.api.v1.payments import _PRODUCTS
match = re.search(r"SERVICE_PRICE_RUB\s*=\s*(\d+)", _CONTENT_TS.read_text(encoding="utf-8"))
assert match is not None, "SERVICE_PRICE_RUB не найден в mera-public/content.ts"
_name, price_kopecks = _PRODUCTS["paid_report"]
assert price_kopecks == int(match.group(1)) * 100
def test_live_status_predicate_matches_code() -> None:
"""Предикат частичного UNIQUE (279) и `_REUSABLE_STATUSES` — один список.
Индекс шире кода запрещает легитимную повторную попытку оплаты; индекс уже
кода пропускает второй холд на карте. И то и другое про деньги, поэтому
дрейф ловит тест, а не внимательность читателя.
"""
from app.api.v1.payments import _REUSABLE_STATUSES
path = _REPO_ROOT / "backend" / "data" / "sql" / "279_payments_live_checkout_uidx.sql"
source = path.read_text(encoding="utf-8")
predicate = re.search(r"AND status IN \((.*?)\)", source, re.S)
assert predicate is not None, "предикат по статусам не найден в миграции 279"
assert set(re.findall(r"'([^']+)'", predicate.group(1))) == set(_REUSABLE_STATUSES)
# ── checkout: один живой платёж на оценку ────────────────────────────────────
@pytest.fixture()
def bank(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
"""Подменяет Т-Банк: собирает вызовы Init и отдаёт предсказуемую ссылку.
Список вызовов не украшение: «второй Init» и есть «второй холд на карте»,
поэтому проверяется именно его длина, а не только тело ответа.
"""
from app.api.v1 import payments as payments_module
calls: list[dict[str, Any]] = []
class _FakeClient:
async def init_payment(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {
"Success": True,
"Status": "NEW",
"PaymentId": f"300000000{len(calls)}",
"PaymentURL": f"https://securepayments.tinkoff.ru/{len(calls)}",
}
monkeypatch.setattr(payments_module, "_client", _FakeClient)
return calls
def _seed_live_payment(db: _FakeDb, *, payment_url: str | None, age: timedelta) -> SimpleNamespace:
row = SimpleNamespace(
id="pay-seed",
order_id="mera-seed",
status="NEW",
amount_kopecks=_AMOUNT,
estimate_id=_ESTIMATE_UUID,
created_by=None,
product_code="paid_report",
payment_url=payment_url,
created_at=datetime.now(tz=UTC) - age,
)
db.payments.append(row)
return row
def _paid_report_rows(db: _FakeDb) -> list[SimpleNamespace]:
return [p for p in db.payments if p.product_code == "paid_report"]
def test_parallel_checkout_does_not_create_second_payment(
client: TestClient, db: _FakeDb, bank: list[dict[str, Any]]
) -> None:
"""Двойной клик: соперник уже вставил строку, но ссылки от банка ещё нет.
Второй запрос обязан отбиться о частичный UNIQUE (миграция 279) и не пойти
в банк второй Init это второй холд на карте покупателя. Ответ 409, а не
выдуманная ссылка.
Фальсификация (проверено): убрать `ON CONFLICT DO NOTHING` из INSERT в
payments _FakeDb кидает _UniqueViolationError, как настоящий Postgres, и
тест краснеет вместо ответа 409; вернуть «SELECT, потом INSERT» без
обработки конфликта вторая строка payments и второй вызов Init.
"""
_seed_live_payment(db, payment_url=None, age=timedelta(seconds=1))
response = client.post(
"/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}
)
assert response.status_code == 409, response.text
assert len(_paid_report_rows(db)) == 1, "параллельный checkout создал второй платёж"
assert bank == [], "второй Init = второй холд на карте покупателя"
def test_repeat_checkout_reuses_live_link(
client: TestClient, db: _FakeDb, bank: list[dict[str, Any]]
) -> None:
"""Честный повтор по живому платежу возвращает ТУ ЖЕ ссылку и не зовёт Init."""
seeded = _seed_live_payment(
db, payment_url="https://securepayments/live", age=timedelta(minutes=5)
)
response = client.post(
"/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}
)
assert response.status_code == 200, response.text
assert response.json()["payment_url"] == "https://securepayments/live"
assert response.json()["order_id"] == seeded.order_id
assert len(_paid_report_rows(db)) == 1
assert bank == []
def test_abandoned_checkout_does_not_lock_the_buyer_out(
client: TestClient, db: _FakeDb, bank: list[dict[str, Any]]
) -> None:
"""Брошенный NEW старше окна не переиспользуется: ссылка у банка протухла.
Без границы по времени покупатель навсегда получал бы одну и ту же мёртвую
ссылку и не мог начать оплату заново нотификации по зависшему NEW может
не прийти вовсе, сам из этого статуса платёж не выйдет.
Фальсификация (проверено руками): убрать UPDATE ... DEADLINE_EXPIRED (или
условие по created_at в нём) в ответе старая мёртвая ссылка
https://securepayments/dead, Init не вызывается: тест краснеет по значению,
а не по исключению.
"""
stale = _seed_live_payment(
db, payment_url="https://securepayments/dead", age=timedelta(hours=2)
)
response = client.post(
"/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}
)
assert response.status_code == 200, response.text
assert response.json()["payment_url"] == "https://securepayments.tinkoff.ru/1"
assert stale.status == "DEADLINE_EXPIRED"
assert len(bank) == 1, "новая попытка оплаты обязана получить свежую ссылку банка"
assert len(_paid_report_rows(db)) == 2
def test_checkout_rejects_foreign_estimate(
client: TestClient, db: _FakeDb, bank: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch
) -> None:
"""IDOR: checkout по чужой оценке — 404, платёж не создаётся.
Дверь здесь не «показать чужой отчёт», а order_id: он же право доступа для
/payments/status/<order_id>, который отдаёт capability-ссылку на отчёт,
как только владелец заплатит.
Фальсификация (проверено руками): убрать вызов `_assert_estimate_access` в
checkout 200 и строка в payments, тест краснеет по значению.
"""
from app.core import auth
monkeypatch.setattr(auth, "get_role", lambda username: "pilot")
db.estimate_created_by = "victim"
response = client.post(
"/api/v1/trade-in/payments/checkout",
json={"estimate_id": _ESTIMATE_UUID},
headers={"X-Authenticated-User": "attacker"},
)
assert response.status_code == 404, response.text
assert _paid_report_rows(db) == []
assert bank == []

View file

@ -26,6 +26,7 @@ from __future__ import annotations
import os
import sys
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch
# Settings требует DATABASE_URL на конструирование — stub до любого app-импорта
@ -75,9 +76,13 @@ def _reset_limiters():
"""
public_mera._suggest_limiter._hits.clear()
public_mera._coverage_limiter._hits.clear()
public_mera._stats_limiter._hits.clear()
public_mera._showcase_limiter._hits.clear()
yield
public_mera._suggest_limiter._hits.clear()
public_mera._coverage_limiter._hits.clear()
public_mera._stats_limiter._hits.clear()
public_mera._showcase_limiter._hits.clear()
@pytest.fixture()
@ -105,9 +110,16 @@ def client() -> TestClient:
# ── 1-2. Периметр и его связка с rbac ────────────────────────────────────────
def test_public_router_exposes_exactly_two_routes() -> None:
def test_public_router_exposes_exactly_the_declared_routes() -> None:
paths = {r.path for r in public_mera.router.routes}
assert paths == {"/suggest", "/coverage"}, (
assert paths == {
"/suggest",
"/coverage",
"/stats",
"/showcase",
"/estimate",
"/estimate/read",
}, (
"изменился набор публичных (анонимных) ручек МЕРЫ. Это не рефакторинг: "
"всё под /api/public/ проксируется на meraocenka.ru целиком и доступно "
"без идентичности. Обнови тест ОСОЗНАННО вместе с rbac._PUBLIC_PATHS."
@ -155,6 +167,107 @@ def test_anonymous_gets_suggest(client: TestClient) -> None:
assert resp.json() == {"items": []}
_SHOWCASE_ROW = {
"district": None,
"rooms": 2,
"area_m2": 54.0,
"floor": 5,
"total_floors": None,
"deal_quarter": "II квартал 2026",
"predicted_rub": 6_100_000,
"fact_rub": 5_900_000,
"err_pct": 3.39,
"n_analogs": 41,
"note": "не point-in-time",
}
_SHOWCASE_RUN = {
"computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC),
"considered": 200,
"priced": 173,
"no_prediction": 27,
"incomplete": 4,
"eligible": 169,
"written": 20,
"with_district": 18,
"rejection_rule": "данных нет: нет прогноза / квартала / площади",
}
def _showcase_db(run: dict | None = _SHOWCASE_RUN, rows: list | None = None) -> MagicMock:
db = MagicMock()
chain = db.execute.return_value.mappings.return_value
chain.first.return_value = run
chain.all.return_value = [_SHOWCASE_ROW] if rows is None else rows
return db
def test_anonymous_gets_showcase(client: TestClient) -> None:
"""Витрина открыта анониму и отдаёт то, что лежит в таблице.
Пустое поле района проходит НАСКВОЗЬ как null: витрина не имеет права
подставить правдоподобный район там, где его не удалось определить.
"""
client.app.dependency_overrides[get_db] = lambda: _showcase_db()
resp = client.get(f"{PREFIX}/showcase")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["computed_at"].startswith("2026-08-29T10:00")
assert body["deals"][0]["district"] is None
assert body["deals"][0]["fact_rub"] == 5_900_000
# Адреса в контракте ручки нет вовсе — в `deals` дом известен у 2.7% строк.
assert "address" not in body["deals"][0]
def test_showcase_carries_counters_so_20_rows_cannot_read_as_all_there_was(
client: TestClient,
) -> None:
"""Счётчики прогона доезжают до фронта, а не остаются в логе бэкенда.
Без них «20 отличных строк» неотличимо от «столько и было»: посетитель не
может отличить выборку из работы оценщика от её лучшего хвоста. Здесь
показано 20 из 169 годных и оба числа обязаны быть в ответе, вместе с
правилом, по которому отсеяно остальное.
Ломать так: убрать `stats` из `ShowcaseResponse` (или перестать его
заполнять) тест покраснеет на отсутствующем ключе, а не на форме.
"""
client.app.dependency_overrides[get_db] = lambda: _showcase_db()
body = client.get(f"{PREFIX}/showcase").json()
stats = body["stats"]
assert stats is not None, "витрина отдаёт строки без счётчиков — подписаться нечем"
assert stats["considered"] == 200
assert stats["eligible"] == 169
assert stats["written"] == 20
assert stats["eligible"] - stats["written"] == 149, "не видно, сколько годных не влезло"
assert stats["no_prediction"] == 27
assert stats["rejection_rule"], "правило отбраковки не подписано"
def test_showcase_without_a_run_shows_nothing_and_says_so(client: TestClient) -> None:
"""Пересчёта не было — ни строк, ни счётчиков, и это штатный ответ.
Обратная сторона предыдущего теста: `stats` не выдумывается там, где
прогона не было. Заодно это гейт на связку «строки берутся ИЗ прогона»:
строки в таблице есть, но прогона нет показывать их не из чего.
"""
client.app.dependency_overrides[get_db] = lambda: _showcase_db(run=None)
body = client.get(f"{PREFIX}/showcase").json()
assert body == {"computed_at": None, "deals": [], "stats": None}
def test_showcase_rate_limited_per_ip(client: TestClient) -> None:
db = _showcase_db(rows=[])
client.app.dependency_overrides[get_db] = lambda: db
codes = [
client.get(f"{PREFIX}/showcase").status_code for _ in range(public_mera._SHOWCASE_LIMIT + 1)
]
assert codes[: public_mera._SHOWCASE_LIMIT] == [200] * public_mera._SHOWCASE_LIMIT
assert codes[-1] == 429, f"бюджет витрины не сработал: {codes}"
def test_suggest_is_post_so_address_never_lands_in_access_log() -> None:
"""Адрес едет ТЕЛОМ, а не в query.

View file

@ -0,0 +1,384 @@
"""Анонимный расчёт /api/public/mera/estimate* — что здесь запинено и почему.
1. СОГЛАСИЕ ДО ЗАПИСИ. Отказ без согласия обязан случиться ДО того, как
адрес физлица дойдёт до БД. Поэтому проверяется не только код 422, но и
то, что расчёт вообще не запускался и в сессию не ушло ни одного запроса:
«422, но строка записана» выглядит в логах как успех приватности и им не
является.
2. ФЛАГ. Выключенный `public_estimate_enabled` обязан давать 404 иначе
мерж этого кода сам по себе открывает наружу запись ПДн.
3. ТОКЕН. В БД уходит ХЭШ, а не токен; выборка отфильтрована по сроку
жизни; «нет такого» и «протух» неотличимы снаружи.
4. БЕСПЛАТНАЯ ЧАСТЬ. Публичный ответ не содержит ни одного платного поля.
Проверяется набором ключей на равенство: любое добавленное поле роняет
тест, в том числе случайно добавленное платное.
5. ДЕЛЕГАЦИЯ. Весь анти-абузный рассказ ручки (анонимная квота на связку
cookie+IP, семафор одновременности, 503 вместо 502, consent-гейт) держится
ровно на ОДНОМ факте: в `app.api.v1.trade_in.estimate` уходит
`x_authenticated_user=None`. `AsyncMock` съедает любую сигнатуру, поэтому
«расчёт вызвали» тут ничего не значит проверяются фактические kwargs
вызова, причём запрос идёт С заголовком `X-Authenticated-User`: подмена
`None` на чтение заголовка обязана красить тест, иначе публичная ручка
молча начнёт считать чужим пользователем и мимо анонимной квоты.
"""
from __future__ import annotations
import hashlib
import os
import sys
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from app.api.public import mera as public_mera # noqa: E402
from app.core.db import get_db # noqa: E402
from app.schemas.trade_in import AggregatedEstimate, CoverageProbeResponse # noqa: E402
PREFIX = "/api/public/mera"
_BODY = {
"address": "Малышева 51",
"area_m2": 54.0,
"rooms": 2,
"city_hint": "Екатеринбург",
"consent": True,
}
_FAKE_COVERAGE = CoverageProbeResponse(
status="ok",
n_listings=34,
median_listing_age_days=44,
n_with_age=6,
radius_m=1000,
city="Екатеринбург",
threshold=10,
)
_TOKEN_EXPIRES = datetime(2026, 9, 5, 12, 0, tzinfo=UTC)
_FAKE_ESTIMATE_ID = "11111111-1111-1111-1111-111111111111"
def _fake_estimate_result() -> MagicMock:
"""Результат закрытого контура. MagicMock, а не собранный AggregatedEstimate:
хендлеру нужны четыре атрибута, а перечисление всех полей платной модели
здесь означало бы, что тест придётся править при каждой правке эстиматора."""
result = MagicMock()
result.estimate_id = _FAKE_ESTIMATE_ID
result.n_analogs = 27
result.target_lat = 56.838
result.target_lon = 60.597
return result
@pytest.fixture(autouse=True)
def _reset_limiters():
public_mera._estimate_limiter._hits.clear()
public_mera._estimate_read_limiter._hits.clear()
public_mera._daily_estimate_limiter._hits.clear()
yield
public_mera._estimate_limiter._hits.clear()
public_mera._estimate_read_limiter._hits.clear()
public_mera._daily_estimate_limiter._hits.clear()
@pytest.fixture()
def db() -> MagicMock:
session = MagicMock()
session.execute.return_value.fetchone.return_value = MagicMock(
public_token_expires_at=_TOKEN_EXPIRES,
n_analogs=27,
lat=56.838,
lon=60.597,
rooms=2,
area_m2=54.0,
)
return session
@pytest.fixture()
def client(db: MagicMock) -> TestClient:
"""Без rbac-мидлвари: узость auth-исключения проверяет соседний
tests/test_public_mera_api.py, здесь предмет сами ручки."""
app = FastAPI()
app.include_router(public_mera.router, prefix=PREFIX)
app.dependency_overrides[get_db] = lambda: db
return TestClient(app)
@pytest.fixture()
def flag_on():
with patch.object(public_mera.settings, "public_estimate_enabled", True):
yield
# ── 1. Согласие фиксируется до первого обращения к БД ────────────────────────
def test_estimate_without_consent_is_422_and_writes_nothing(
client: TestClient, db: MagicMock, flag_on: None
) -> None:
body = {k: v for k, v in _BODY.items() if k != "consent"}
with patch.object(public_mera, "estimate", AsyncMock()) as estimate_mock:
resp = client.post(f"{PREFIX}/estimate", json=body)
assert resp.status_code == 422, resp.text
assert not estimate_mock.called, (
"расчёт запустился без согласия — адрес физлица дошёл бы до "
"trade_in_estimates раньше, чем человек что-либо разрешил"
)
assert db.execute.call_args_list == [], (
"в БД ушёл запрос при отсутствии согласия: проверка согласия сдвинулась "
"ПОСЛЕ работы с данными"
)
def test_estimate_with_consent_false_is_also_422(client: TestClient, flag_on: None) -> None:
"""`consent: false` — это осознанный отказ, а не «поле не прислали»."""
with patch.object(public_mera, "estimate", AsyncMock()) as estimate_mock:
resp = client.post(f"{PREFIX}/estimate", json={**_BODY, "consent": False})
assert resp.status_code == 422, resp.text
assert not estimate_mock.called
# ── 2. Флаг ──────────────────────────────────────────────────────────────────
def test_estimate_is_404_while_flag_is_off(client: TestClient) -> None:
with patch.object(public_mera, "estimate", AsyncMock()) as estimate_mock:
resp = client.post(f"{PREFIX}/estimate", json=_BODY)
assert resp.status_code == 404, resp.text
assert not estimate_mock.called
def test_estimate_read_is_404_while_flag_is_off(client: TestClient, db: MagicMock) -> None:
resp = client.post(f"{PREFIX}/estimate/read", json={"token": "x" * 32})
assert resp.status_code == 404, resp.text
assert db.execute.call_args_list == []
def test_flag_default_is_off() -> None:
"""Дефолт — выключено: мерж кода не должен открывать запись ПДн наружу."""
from app.core.config import Settings
assert Settings.model_fields["public_estimate_enabled"].default is False
# ── 3. Токен ─────────────────────────────────────────────────────────────────
def test_estimate_stores_token_hash_not_token(
client: TestClient, db: MagicMock, flag_on: None
) -> None:
with (
patch.object(public_mera, "estimate", AsyncMock(return_value=_fake_estimate_result())),
patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE),
):
resp = client.post(f"{PREFIX}/estimate", json=_BODY)
assert resp.status_code == 200, resp.text
token = resp.json()["token"]
assert len(token) >= 32, "короткий токен перебираем"
params = db.execute.call_args_list[0].args[1]
assert token not in str(params), "сам токен уехал в БД — дамп базы открывает чужие расчёты"
assert params["token_hash"] == hashlib.sha256(token.encode()).hexdigest()
assert params["id"] == _FAKE_ESTIMATE_ID, (
"токен вешается не на ту строку: UPDATE обязан идти по id ТОЛЬКО ЧТО "
"посчитанной оценки (result.estimate_id), иначе ссылка либо ведёт в чужой "
"расчёт, либо не ведёт никуда"
)
def test_read_filters_by_expiry_and_hash(client: TestClient, db: MagicMock, flag_on: None) -> None:
"""ЧТО проверено: в тексте отправленного SQL присутствует предикат срока жизни,
а в параметры уехал ХЭШ токена, не сам токен.
ЧЕГО НЕ проверено: что протухший токен действительно не читается. Сессия здесь
MagicMock, запрос не исполняется: SQL не разбирается, `fetchone()` отдаёт строку
из фикстуры независимо от WHERE. Поэтому зелёными останутся, например, предикат,
перенесённый туда, где он ничего не отсекает, сравнение NOW() с полем не того
типа и любая ошибка в самом сравнении текст-то совпадает.
Поведенческой версии нет намеренно: она требует живого Postgres с миграцией 278
(образец self-skip-теста tests/test_purge_expired_trade_in_data.py::_live_session),
а писать её вслепую, ни разу не прогнав, значит завести ещё один тест, зелёный
по построению. Появится доступный Postgres этот тест заменяется на вставку
двух строк (свежий токен и просроченный) с проверкой 200 против 404.
"""
token = "t" * 40
with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE):
resp = client.post(f"{PREFIX}/estimate/read", json={"token": token})
assert resp.status_code == 200, resp.text
sql = str(db.execute.call_args_list[0].args[0])
assert "public_token_expires_at > NOW()" in sql, (
"из выборки пропал срок жизни ссылки — протухший токен снова читает расчёт"
)
params = db.execute.call_args_list[0].args[1]
assert params["token_hash"] == hashlib.sha256(token.encode()).hexdigest()
def test_unknown_or_expired_token_is_404(client: TestClient, db: MagicMock, flag_on: None) -> None:
"""Чужой и протухший токен неотличимы: 404 в обоих случаях.
Мок сессии отдаёт None ровно так же, как реальный SELECT с предикатом
`public_token_expires_at > NOW()` то есть и для несуществующего токена,
и для просроченного.
"""
db.execute.return_value.fetchone.return_value = None
resp = client.post(f"{PREFIX}/estimate/read", json={"token": "z" * 40})
assert resp.status_code == 404, resp.text
assert "не найден" in resp.json()["detail"].lower()
# ── 4. В публичном ответе нет платного ───────────────────────────────────────
# Поля AggregatedEstimate, которые продукт продаёт. Список не для красоты: он
# сверяется с реальной моделью ниже, поэтому переименование поля в эстиматоре
# не оставит здесь мёртвую строку-обманку.
_PAID_FIELDS = {
"median_price_rub",
"range_low_rub",
"range_high_rub",
"median_price_per_m2",
"market_percentile",
"analogs",
"actual_deals",
"expected_sold_price_rub",
"est_days_on_market",
"price_trend",
}
_FREE_FIELDS = {"token", "token_expires_at", "n_analogs", "coverage"}
def test_paid_field_names_still_exist_in_estimator_model() -> None:
"""Контроль самого контроля: если поле переименовали, тест ниже сравнивал бы
публичный ответ с несуществующими именами и был бы зелёным по построению."""
missing = _PAID_FIELDS - set(AggregatedEstimate.model_fields)
assert not missing, f"эти поля исчезли из AggregatedEstimate, обнови список: {missing}"
def test_public_result_model_exposes_only_free_fields() -> None:
assert set(public_mera.PublicEstimateResult.model_fields) == _FREE_FIELDS, (
"изменился состав публичного ответа. Любое поле отсюда видит любой аноним "
"без оплаты — правь ОСОЗНАННО вместе с этим тестом"
)
def test_public_estimate_response_leaks_no_paid_field(client: TestClient, flag_on: None) -> None:
with (
patch.object(public_mera, "estimate", AsyncMock(return_value=_fake_estimate_result())),
patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE),
):
resp = client.post(f"{PREFIX}/estimate", json=_BODY)
body = resp.json()
assert set(body) == _FREE_FIELDS, f"публичный ответ отдаёт лишнее: {set(body) - _FREE_FIELDS}"
assert not _PAID_FIELDS & set(body)
# Вложенный coverage тоже без цен — по построению CoverageProbeResponse,
# но проверяем, а не верим: он мог обрасти полем «медиана ₽/м²».
assert not _PAID_FIELDS & set(body["coverage"])
assert body["n_analogs"] == 27
assert body["coverage"]["median_listing_age_days"] == 44
def test_read_returns_same_free_shape(client: TestClient, flag_on: None) -> None:
"""POST и повторное чтение обязаны отдавать ОДНУ форму: разойдись они —
фронт после перезагрузки страницы показал бы не то же самое."""
with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE):
resp = client.post(f"{PREFIX}/estimate/read", json={"token": "t" * 40})
assert resp.status_code == 200, resp.text
assert set(resp.json()) == _FREE_FIELDS
assert resp.json()["n_analogs"] == 27
# ── 5. Делегация в закрытый контур ───────────────────────────────────────────
def test_estimate_delegates_anonymously_despite_auth_header(
client: TestClient, db: MagicMock, flag_on: None
) -> None:
"""Единственный тест, который смотрит НА АРГУМЕНТЫ делегации, а не на факт вызова.
Запрос идёт с `X-Authenticated-User: admin` то есть ровно тем заголовком,
который в закрытом контуре означает «это авторизованный пользователь». Ручка
обязана всё равно позвать эстиматор анонимом: `x_authenticated_user=None`
включает там consent-гейт и анонимную квоту на связку cookie+IP. Пробрось
сюда заголовок и публичная форма начнёт считать от чужого имени в обход
квоты, а все остальные тесты этого файла останутся зелёными: `AsyncMock`
принимает любую сигнатуру.
"""
with (
patch.object(
public_mera, "estimate", AsyncMock(return_value=_fake_estimate_result())
) as estimate_mock,
patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE),
):
resp = client.post(
f"{PREFIX}/estimate", json=_BODY, headers={"X-Authenticated-User": "admin"}
)
assert resp.status_code == 200, resp.text
call = estimate_mock.await_args
assert call is not None, "делегации не было вовсе"
assert call.args == (), (
"аргументы поехали позиционно — сверка по именам ниже перестала что-либо "
"проверять; зови эстиматор с ключевыми словами"
)
assert "x_authenticated_user" in call.kwargs, (
"аргумент исчез из вызова: у эстиматора он по умолчанию None, но тогда "
"гарантия анонимности держится на чужом дефолте, а не на этой ручке"
)
assert call.kwargs["x_authenticated_user"] is None, (
"публичная ручка передала пользователя в закрытый контур: consent-гейт и "
"анонимная квота выключаются, аноним считает от чужого имени "
f"(пришло: {call.kwargs['x_authenticated_user']!r})"
)
# Остальное едет тем же вызовом: сессия — та, что отдана зависимостью (иначе
# UPDATE токена ниже пишет в другую транзакцию), тело — то, что прислал аноним.
assert call.kwargs["db"] is db
assert call.kwargs["payload"].address == _BODY["address"]
assert call.kwargs["payload"].area_m2 == _BODY["area_m2"]
assert call.kwargs["payload"].rooms == _BODY["rooms"]
# ── 6. Бюджеты ───────────────────────────────────────────────────────────────
def test_estimate_rate_limited_per_ip(client: TestClient, flag_on: None) -> None:
with (
patch.object(public_mera, "estimate", AsyncMock(return_value=_fake_estimate_result())),
patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE),
):
codes = [
client.post(f"{PREFIX}/estimate", json=_BODY).status_code
for _ in range(public_mera._ESTIMATE_LIMIT + 1)
]
assert codes[:-1] == [200] * public_mera._ESTIMATE_LIMIT
assert codes[-1] == 429, f"бюджет не сработал: {codes}"
def test_daily_budget_stops_estimates_for_everyone(client: TestClient, flag_on: None) -> None:
"""Общий потолок — про сумму по всем IP, а не про одного клиента."""
for _ in range(public_mera._DAILY_ESTIMATE_BUDGET):
public_mera._daily_estimate_limiter.record(public_mera._ESTIMATE_GLOBAL_KEY)
with patch.object(public_mera, "estimate", AsyncMock()) as estimate_mock:
resp = client.post(f"{PREFIX}/estimate", json=_BODY)
assert resp.status_code == 429, resp.text
assert int(resp.headers["Retry-After"]) > 0
assert not estimate_mock.called

View file

@ -68,6 +68,7 @@ _PRODUCT_SOURCES: set[str] = {
"sber_index_pull",
"rosreestr_quarter_poll",
"deals_freshness_monitor",
"landing_stats_refresh",
"newbuilding_enrich",
"yandex_newbuilding_sweep",
"geoportal_coords_backfill",