Compare commits
152 commits
828e32e9ab
...
3f87873d7b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f87873d7b | ||
| 4d31a0ee82 | |||
| e17687aed7 | |||
| f2cbd76ae0 | |||
| 29db137375 | |||
| 55c25416f6 | |||
| 82d8db9f42 | |||
| 0b84f0b661 | |||
| 8423af5dd5 | |||
| 9d9457f67d | |||
| 74344f7b8b | |||
| 20ec6a5d33 | |||
| 0ed0140c9e | |||
| 1307d55da6 | |||
| 405d2f2eec | |||
| 5ee4126ed0 | |||
| a227877905 | |||
|
|
ab5c4b86cd | ||
| 272abac4d2 | |||
| 72472c2783 | |||
| 84a65d40dd | |||
| 12c189ac27 | |||
| 27e199e370 | |||
| 9cd6db023b | |||
| 08bb9d6549 | |||
| 7cd8c63b89 | |||
| f45ce3be2d | |||
| 687bd38322 | |||
| f3bcb1a25f | |||
| f1f2bca2e9 | |||
| 7b36f86ea6 | |||
| 45924021a7 | |||
| ef82172bd1 | |||
|
|
5ce95a28a8 | ||
|
|
7431615415 | ||
|
|
e6591a450a | ||
| 482deb4864 | |||
| 29f1000228 | |||
| e4680082ea | |||
| 209e4e145f | |||
| c3a35a31d4 | |||
| 9de690ad4f | |||
| 90c3e7e490 | |||
| 1f036e94d1 | |||
| a9096f125a | |||
| 306fd82836 | |||
| de4b2a4ae5 | |||
| 05bc321728 | |||
| 69ba6002da | |||
| 339713c1aa | |||
| 5046ac7b4e | |||
| a034d7a1c1 | |||
| 0de22f4bc9 | |||
| 8def690b00 | |||
| 896243cf3f | |||
| 604ce14858 | |||
| d605f0b938 | |||
| b1498a9ca7 | |||
| 79f7b8fff3 | |||
|
|
48664dfe0e | ||
| 76c3f5c110 | |||
|
|
5ff06d25b4 | ||
| ac870b0c58 | |||
| 0535fa209a | |||
| 2496670859 | |||
| d3d7464267 | |||
| eb98852ddf | |||
| d87c9fa191 | |||
|
|
4ee4d4b8e2 | ||
| a091da1aa8 | |||
|
|
881730bf20 | ||
| 9f51c98ff4 | |||
| 2d62b87cf3 | |||
| 91423e0b53 | |||
| a398b17e6d | |||
|
|
3ee99efaa4 | ||
|
|
b93bee5393 | ||
| c86a5378ef | |||
| 0dc6f12630 | |||
| 4aec49f7fb | |||
| f0968c8513 | |||
| 90e328df66 | |||
|
|
dccd2d4272 | ||
| 64a7975549 | |||
|
|
00d1f78668 | ||
|
|
6820337da0 | ||
| a32ccabd0d | |||
| 2a1577738a | |||
| 5f71fc670f | |||
| 02b256288d | |||
| 3527d2b608 | |||
| 3231233b73 | |||
| 6cf9172d96 | |||
| a52b9d07cf | |||
| b8afe13ae4 | |||
| 0144092856 | |||
| 663a831775 | |||
| 1ba1a55770 | |||
| 9d8114158b | |||
| 627e163103 | |||
| 396d8a74b6 | |||
| 9e5e9fca08 | |||
| cb64796b44 | |||
| c66e18cd13 | |||
| 90c193f898 | |||
| bc5233de15 | |||
| 58dcd76d2e | |||
| 44470f7310 | |||
| 27272762ef | |||
| a7ca0e9ee8 | |||
| e1c26c212a | |||
| fb5ec56a54 | |||
| 3d38d589d0 | |||
| f76485781b | |||
|
|
1a577fe748 | ||
| f5b39e6fc9 | |||
|
|
3fd6550a16 | ||
| 5e92810d72 | |||
| 6d76328168 | |||
| 0ed0f97ae2 | |||
| 3c5f535e6c | |||
| e4ac0365cf | |||
| 4d0795ae7a | |||
| 673c02e5d6 | |||
| 77336d351c | |||
| ab01f7cc48 | |||
| 807d586627 | |||
| 3e1b9a8b0d | |||
| 43aaf91b97 | |||
| 46bbb79881 | |||
| bb6e5c7e41 | |||
| 15ca70ad70 | |||
| 9f9086fa4d | |||
| 4b4ab8b34c | |||
| 0815319e1c | |||
| 4e9e4f558e | |||
| 77ae08f207 | |||
| ef8609d725 | |||
| 301fbed0d7 | |||
| b88535425e | |||
| c232772e70 | |||
| 9b9f299922 | |||
| c9f71da484 | |||
| 96d62e418b | |||
| 63ea44fdd2 | |||
| 40fdf11f19 | |||
| d173163025 | |||
| b800760c24 | |||
| 837ad8cfd4 | |||
| 7d154de1f7 | |||
| 0b54b96984 | |||
|
|
5626d9e720 |
318 changed files with 43815 additions and 7665 deletions
|
|
@ -49,6 +49,7 @@ cd frontend && npm install --legacy-peer-deps --no-audit --no-fund
|
|||
- Pre-push check: `git diff main..HEAD -- frontend/package.json frontend/package-lock.json` — если только один из двух тронут → STOP, regen lock.
|
||||
- Imports без deps entry (TypeScript авто-resolve через transitive) — **latent bomb** до first `npm ci`.
|
||||
- Reference incident: PR #344 (2026-05-17) добавил `lucide-react` без regen lockfile → deploy #135 fail → P0 hotfix PR #345 (commit `6ee20294f2`).
|
||||
- **То же правило для `tradein-mvp/frontend/`** (#2770): там теперь тоже tracked `package-lock.json` + `npm ci` в Dockerfile и в `ci-tradein.yml`. До #2770 лока не было вовсе (лежал `pnpm-lock.yaml`, из которого никто не ставил), и состав зависимостей прод-образа определялся датой сборки.
|
||||
|
||||
## Prettier / lint
|
||||
|
||||
|
|
|
|||
|
|
@ -16,11 +16,43 @@ paths:
|
|||
-- Контекст: что делает файл, зачем, порядок применения, dependencies.
|
||||
BEGIN;
|
||||
|
||||
SET LOCAL lock_timeout = '5s'; -- если ниже есть блокирующий DDL, см. § lock_timeout
|
||||
|
||||
-- DDL здесь (idempotent)
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
## lock_timeout при блокирующем DDL (обязательно)
|
||||
|
||||
Любой `ALTER TABLE` / `DROP INDEX` / `CREATE INDEX` (без `CONCURRENTLY`) /
|
||||
`REFRESH MATERIALIZED VIEW` / `TRUNCATE` обязан нести `SET LOCAL lock_timeout = '5s';`
|
||||
сразу после `BEGIN`. Гейт: `scripts/check-migration-lock-timeout.py` (бежит в `ci.yml`
|
||||
на каждом PR) — проверяет и наличие, и место (внутри транзакции, ДО первого DDL).
|
||||
|
||||
**Почему.** Дорого не удержание лока, а ожидание его выдачи. 2026-08-07 `DROP INDEX`
|
||||
на таблице в 1061 строку ждал ACCESS EXCLUSIVE 29 минут за чужой аналитической
|
||||
psql-сессией. Ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами → за
|
||||
ним начинают ждать обычные SELECT приложения. `lock_timeout` ограничивает только
|
||||
ожидание, на работу под локом не влияет. Срабатывание = красный деплой (честный
|
||||
отказ, повторить позже) вместо тихой очереди перед приложением.
|
||||
|
||||
**Значение 5 s:** снизу ограничено `deadlock_timeout` (1 s на проде) — автоотмена
|
||||
мешающего autovacuum срабатывает только после того, как ждущий отстоял эту секунду,
|
||||
поэтому 1-2 s гонялись бы с рутинным autovacuum. Сверху — столько максимум простоит
|
||||
очередь запросов приложения.
|
||||
|
||||
**`CONCURRENTLY`-формы — НАОБОРОТ, без lock_timeout** (и гейт их не требует):
|
||||
`CREATE INDEX CONCURRENTLY` ждёт завершения параллельных транзакций через
|
||||
VirtualXactLock, это ожидание тоже под `lock_timeout`, и таймаут обрывает построение,
|
||||
оставляя невалидный индекс. По той же причине НЕ задавать `lock_timeout` глобально
|
||||
в раннере. И только `SET LOCAL`, не голый `SET`: голый доживёт до конца сессии и
|
||||
обрежет `CONCURRENTLY` ниже по файлу.
|
||||
|
||||
Невалидные индексы (след оборванного CIC) ловит проверка после цикла миграций в
|
||||
`deploy.yml` / `deploy-tradein.yml`: re-run миграции их НЕ чинит — `CREATE INDEX
|
||||
CONCURRENTLY IF NOT EXISTS` тихо пропускает битый индекс как существующий.
|
||||
|
||||
## Idempotency (обязательно)
|
||||
|
||||
- `CREATE TABLE IF NOT EXISTS`
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ jobs:
|
|||
outputs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
browser: ${{ steps.filter.outputs.browser }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
|
|
@ -54,21 +55,115 @@ jobs:
|
|||
frontend:
|
||||
- 'tradein-mvp/frontend/**'
|
||||
- '.forgejo/workflows/ci-tradein.yml'
|
||||
browser:
|
||||
# Сайдкар — сервис ВНЕ uv-воркспейса (tradein-mvp/pyproject.toml
|
||||
# members = backend + packages/*), со своим Dockerfile и без pyproject,
|
||||
# поэтому и фильтр отдельный: backend-гейт его тестов не видел вовсе.
|
||||
- 'tradein-mvp/browser/**'
|
||||
- '.forgejo/workflows/ci-tradein.yml'
|
||||
|
||||
backend-tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
# Postgres-сервис (#2745). ДО него лэйн был mock-only: DATABASE_URL указывал на
|
||||
# заведомо мёртвый `localhost:5432/test`, и девять тестов с `_live_session()`
|
||||
# self-skip'ались — в CI они не бежали НИ РАЗУ. Так и разъехался со схемой
|
||||
# test_house_dedup_merge (#2740: houses.url стал NOT NULL), а
|
||||
# test_gar_flats_loader вообще падал до первого утверждения (#2744).
|
||||
#
|
||||
# Замер перед включением: полный сьют на mock-лэйне 122с / 3858 passed / 10 skipped,
|
||||
# тот же сьют против живой БД — 106с / 3867 passed / 1 skipped. Живая БД не
|
||||
# медленнее, поэтому НЕ добавляем второй job, а чиним этот: один прогон, на
|
||||
# девять реальных проверок больше. Накладные — только подъём контейнера и
|
||||
# bootstrap схемы (219 файлов, ~20с).
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./tradein-mvp/backend
|
||||
env:
|
||||
# psycopg v3 требует parseable URL на импорте; реального коннекта нет —
|
||||
# DB-тесты мокаются (mirror deploy-tradein.yml test-job).
|
||||
DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test
|
||||
# Имя контейнера уникально на прогон: параллельные PR не дерутся за него.
|
||||
CI_PG: ci-pg-tradein-${{ github.run_id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Поднять Postgres и собрать схему tradein
|
||||
working-directory: .
|
||||
# ПОЧЕМУ НЕ `services:` И ПОЧЕМУ БЕЗ ПУБЛИКАЦИИ ПОРТА.
|
||||
# Раннер запускает и job, и сервис-контейнеры с `--network host` (видно в
|
||||
# логе прогона: `docker create image=... network="host"`), а на 5432 того
|
||||
# же хоста слушает ПРОДОВЫЙ Postgres. Попытка через `services:` +
|
||||
# `ports: 5432:5432` кончилась тем, что сервис-контейнер не смог занять
|
||||
# порт, а psql из job'а ушёл В ПРОД и получил
|
||||
# `password authentication failed for user "tradein"`. То есть
|
||||
# `localhost:5432` из job'а на этом раннере — боевая база, а не тестовая.
|
||||
# Поэтому контейнер поднимаем сами, в bridge-сети, БЕЗ публикации порта,
|
||||
# и ходим по его собственному IP: прод недостижим в принципе, параллельные
|
||||
# прогоны не конфликтуют, psql берём из самого контейнера.
|
||||
#
|
||||
# ОДИН шаг, а не два: между шагами контейнер успевал исчезнуть, и
|
||||
# bootstrap падал на `container is not running`.
|
||||
#
|
||||
# `pg_isready -h 127.0.0.1`, а НЕ через unix-сокет: на время initdb образ
|
||||
# поднимает ВРЕМЕННЫЙ сервер с listen_addresses='' — по сокету он уже
|
||||
# отвечает «accepting connections», хотя снаружи БД ещё не существует, а
|
||||
# впереди рестарт. Проба по TCP зеленеет только на настоящем сервере —
|
||||
# том самом, к которому пойдут тесты.
|
||||
#
|
||||
# postgis, не plain postgres: tests/tasks/test_cadastral_geo_match.py
|
||||
# проверяет KNN по geometry (PostGIS_Version() в connectivity-probe).
|
||||
# Имя БД ОБЯЗАНО отличаться от `test`: `_live_session()` считает DSN с
|
||||
# `localhost:5432/test` заглушкой и вернул бы None — контейнер поднялся
|
||||
# бы, а тесты всё равно скипались.
|
||||
run: |
|
||||
set -u
|
||||
docker rm -f "$CI_PG" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$CI_PG" \
|
||||
-e POSTGRES_DB=tradein -e POSTGRES_USER=tradein -e POSTGRES_PASSWORD=tradein \
|
||||
postgis/postgis:16-3.4
|
||||
|
||||
ready=""
|
||||
for _ in $(seq 1 45); do
|
||||
if docker exec "$CI_PG" pg_isready -h 127.0.0.1 -U tradein -q 2>/dev/null; then
|
||||
ready=1; break
|
||||
fi
|
||||
[ "$(docker inspect -f '{{.State.Status}}' "$CI_PG" 2>/dev/null)" = "running" ] || break
|
||||
sleep 2
|
||||
done
|
||||
if [ -z "$ready" ]; then
|
||||
echo "::error::Postgres не поднялся; статус=$(docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' "$CI_PG" 2>&1)"
|
||||
docker logs --tail 50 "$CI_PG" 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CI_PG")
|
||||
[ -n "$ip" ] || { echo "::error::не удалось узнать IP контейнера $CI_PG"; exit 1; }
|
||||
echo "DATABASE_URL=postgresql+psycopg://tradein:tradein@${ip}:5432/tradein" >> "$GITHUB_ENV"
|
||||
echo "✓ Postgres на ${ip}:5432 (контейнер $CI_PG)"
|
||||
|
||||
# Тот же порядок и тот же строгий режим, что в deploy-tradein.yml:
|
||||
# `ls | sort` + ON_ERROR_STOP=on, падение любой миграции → job RED.
|
||||
# Никаких «применилось как получилось»: схема в CI либо та же, что на
|
||||
# проде, либо гейта нет.
|
||||
docker exec -i "$CI_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -q -c \
|
||||
"CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE ROLE gendesign_reader;"
|
||||
for sql_file in $(ls -1 tradein-mvp/backend/data/sql/*.sql | sort); do
|
||||
fname=$(basename "$sql_file")
|
||||
# ЕДИНСТВЕННОЕ исключение, и оно названо вслух: 077 — не DDL, а
|
||||
# backfill, читающий foreign table gendesign_rosreestr_deals из БД
|
||||
# ДРУГОГО стека через postgres_fdw. В CI второй БД нет, USER MAPPING
|
||||
# создать не из чего. На пустых таблицах backfill всё равно no-op.
|
||||
if [ "$fname" = "077_dedup_hash_plain_key_backfill.sql" ]; then
|
||||
echo "⚠ пропускаю $fname — postgres_fdw к БД gendesign, которой в CI нет"
|
||||
continue
|
||||
fi
|
||||
docker exec -i "$CI_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -q < "$sql_file" \
|
||||
|| { echo "::error::миграция $fname не применилась"; docker logs --tail 20 "$CI_PG" 2>&1 || true; exit 1; }
|
||||
done
|
||||
echo "✓ схема собрана: $(docker exec "$CI_PG" psql -U tradein -d tradein -tAc \
|
||||
"SELECT count(*) FROM information_schema.tables WHERE table_schema='public'") таблиц"
|
||||
|
||||
- name: Install uv
|
||||
# Официальный standalone-инсталлер. НЕ astral-sh/setup-uv — он ломается
|
||||
# на Forgejo-runner с PEP 668 externally-managed-environment (#666 CI).
|
||||
|
|
@ -96,15 +191,87 @@ jobs:
|
|||
run: uv sync --frozen
|
||||
|
||||
- name: Run pytest (tradein-mvp/backend)
|
||||
# DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает
|
||||
# ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state
|
||||
# leak из другого test-модуля, pre-existing. Второй исторический deselect
|
||||
# (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в
|
||||
# полном прогоне (проверено локально: 2947 passed / 1 failed). Список обязан
|
||||
# совпадать с test-job в deploy-tradein.yml.
|
||||
run: |
|
||||
uv run pytest -q \
|
||||
--deselect "tests/test_search_api.py::test_search_cache_hit"
|
||||
# БЕЗ deselect'ов — сьют гоняется целиком (#2722).
|
||||
#
|
||||
# Здесь два года жил `--deselect tests/test_search_api.py::test_search_cache_hit`
|
||||
# с объяснением «падает ТОЛЬКО в whole-suite ordering, в изоляции проходит —
|
||||
# global-state leak из другого модуля». Объяснение было неверным в обеих
|
||||
# половинах: тест падал и в изоляции тоже (401 vs 200), потому что он —
|
||||
# единственный HTTP-тест в своём файле — ходил в /api/v1/search БЕЗ заголовка
|
||||
# X-Authenticated-User, а RBAC-гард отвечает на такое 401 (ровно то, что
|
||||
# фиксирует tests/test_estimate_idor.py). Причина была в тесте, а не в порядке;
|
||||
# заголовок добавлен, deselect снят, полный прогон зелёный.
|
||||
#
|
||||
# Не добавлять сюда новые deselect'ы: молча выключенный тест — это тот же
|
||||
# класс дефекта, что каталог вне пайплайна (#2722). Тест либо чинится, либо
|
||||
# помечается xfail с причиной В КОДЕ, где её видно рядом с самим тестом.
|
||||
#
|
||||
# NB: в deploy-tradein.yml (post-merge test-job) свой экземпляр этого
|
||||
# deselect'а — он остаётся до #2680, который правит тот файл. Расхождение
|
||||
# безвредно: pre-merge гейт тест гоняет, post-merge просто пропустит зелёный.
|
||||
#
|
||||
# `-rs` (#2745) — КАЖДЫЙ пропуск печатает свою причину в лог job'а. Без него
|
||||
# `-q` рисует пропуск точкой `s`, неотличимой на глаз от прогона: ровно так
|
||||
# девять DB-тестов «шли зелёными», ничего не проверяя. Пропуск, который не
|
||||
# называет себя вслух, со временем перестаёт быть верным.
|
||||
run: uv run pytest -q -rs
|
||||
|
||||
- name: Снести тестовый Postgres
|
||||
# if: always() — контейнер уходит и когда сьют красный, и когда прогон
|
||||
# отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры.
|
||||
if: always()
|
||||
working-directory: .
|
||||
run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true
|
||||
|
||||
# Тесты браузерного сайдкара (#2722). До этого job'а они не бежали НИГДЕ:
|
||||
# ci-tradein гейтил только backend/frontend, deploy-tradein — тоже, а каталог
|
||||
# вне uv-воркспейса, так что и `uv run pytest` из backend их не собирал. Итог:
|
||||
# 4 теста лежали красными на main (с 2026-06-20 и 2026-07-02), файл при этом
|
||||
# правился, и никто не узнал. Починка — PR #2724, этот job закрывает причину.
|
||||
#
|
||||
# Почему НЕ переиспользуем backend-job:
|
||||
# 1. сайдкар не член воркспейса → `uv sync --frozen` его не ставит;
|
||||
# 2. aiohttp (единственная не-stdlib зависимость сьюта) нет в tradein-mvp/uv.lock;
|
||||
# 3. разный scope paths-filter: правка browser/ не должна гонять backend-сьют.
|
||||
browser-tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.browser == 'true'
|
||||
# Сьют идёт ~15с. Лимит — страховка от зависшего теста: у сайдкара нет своего
|
||||
# pyproject, а значит и pytest-timeout'а backend'а (timeout=120). Дешевле
|
||||
# взять нативный job-таймаут, чем тащить плагин ради одного каталога.
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./tradein-mvp/browser
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
# 3.12 — как в browser/Dockerfile (FROM python:3.12-slim).
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install test deps
|
||||
# ВЕСЬ список: pytest + aiohttp. Ни playwright, ни camoufox, ни закачки
|
||||
# Firefox — camoufox импортируется ЛЕНИВО внутри _launch_browser
|
||||
# (server.py, `from camoufox.async_api import AsyncCamoufox`), а сами тесты
|
||||
# мокают _ensure_browser/_do_fetch и грузят server.py по пути через importlib.
|
||||
# pytest-asyncio тоже НЕ нужен: ни одного `async def test_` — каждый тест сам
|
||||
# крутит asyncio.run(). Проверено локально на venv ровно из этих двух пакетов.
|
||||
#
|
||||
# aiohttp без пина — ровно как в browser/Dockerfile (`pip install ... aiohttp`),
|
||||
# то есть гейт видит ту же версию, что уедет в образ. Пин здесь означал бы
|
||||
# проверку версии, которой в проде нет.
|
||||
run: pip install pytest aiohttp
|
||||
|
||||
- name: Run pytest (tradein-mvp/browser)
|
||||
# Каталог без pyproject/pytest.ini → дефолтная конфигурация, ничего
|
||||
# не deselect'ится. Ожидание: 108 passed, 0 failed, 0 skipped.
|
||||
# `-rs`: если однажды появится пропуск, он назовёт причину в логе, а не
|
||||
# растворится в строке точек.
|
||||
run: pytest -q -rs
|
||||
|
||||
frontend-checks:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -118,25 +285,42 @@ jobs:
|
|||
|
||||
- name: Set up Node
|
||||
# Node 20 — major из tradein-mvp/frontend/Dockerfile (node:20-alpine).
|
||||
# npm-кэш setup-node НЕ настраиваем: в tradein-mvp/frontend нет
|
||||
# package-lock.json (Dockerfile ставит через npm install), а cache=npm
|
||||
# без lockfile падает. Кэш wheels/node тут не критичен для type-check/lint.
|
||||
# cache: npm включён с #2770 — package-lock.json теперь tracked.
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: tradein-mvp/frontend/package-lock.json
|
||||
|
||||
- name: Install deps (npm install, no lockfile)
|
||||
# ТОЧНЫЕ флаги из tradein-mvp/frontend/Dockerfile (deps stage):
|
||||
# --legacy-peer-deps — Tailwind/React 19 peer-dep mismatches;
|
||||
# --no-audit --no-fund — тише и быстрее в CI. `install` (не `ci`):
|
||||
# в tradein-mvp/frontend НЕТ package-lock.json (есть pnpm-lock.yaml, но
|
||||
# Dockerfile ставит именно npm install) → `npm ci` упал бы.
|
||||
run: npm install --legacy-peer-deps --no-audit --no-fund
|
||||
- name: Install deps (npm ci)
|
||||
# ТОЧНЫЕ флаги из tradein-mvp/frontend/Dockerfile (deps stage), чтобы гейт
|
||||
# видел то же дерево, что уедет в образ. `ci`, а не `install` (#2770): до
|
||||
# него лока не было вовсе (лежал мёртвый pnpm-lock.yaml, из которого никто
|
||||
# не ставил), и версии в CI и в прод-образе выбирались независимо по дате
|
||||
# сборки — гейт проверял не тот код, который деплоится.
|
||||
#
|
||||
# Правишь package.json — регенерируй лок в том же PR: `npm ci` требует
|
||||
# точного match и иначе роняет и этот job, и build образа.
|
||||
run: npm ci --legacy-peer-deps --no-audit --no-fund
|
||||
|
||||
- name: Type-check (tsc --noEmit)
|
||||
# Blocking: любая TS-ошибка → job RED.
|
||||
run: npm run type-check
|
||||
|
||||
- name: Run tests (vitest)
|
||||
# Blocking (#2766). До этого шага у tradein-фронта не бежало НИ ОДНОЙ
|
||||
# проверки поведения: лэйн гейтил только типы и статический анализ, а оба
|
||||
# молчат про то, что видит пользователь — пустое поле, погашенное число,
|
||||
# отказ по частоте. Инфраструктура не изобретена, а взята у соседнего
|
||||
# frontend/ (vitest + jsdom + testing-library), где сьют живёт давно.
|
||||
#
|
||||
# Пропусков в сьюте нет и быть не должно: сторож пропусков
|
||||
# (tests/skip_allowlist.txt) — pytest-only, у vitest такого нет, поэтому
|
||||
# пропуск здесь стал бы ровно тем незаметным «зелёным», который #2722
|
||||
# запретил на бэкенде. Тест либо чинится, либо помечается `.fails`
|
||||
# с причиной В КОДЕ.
|
||||
run: npm test
|
||||
|
||||
- name: Lint (next lint)
|
||||
# Blocking: любая ESLint-ошибка → job RED.
|
||||
run: npm run lint
|
||||
|
|
|
|||
|
|
@ -12,8 +12,18 @@ name: CI
|
|||
# единственный real-Postgres тест (tests/sql/ mv_layout) self-skip'ается через
|
||||
# connectivity-probe. PDF-тесты (WeasyPrint) РЕАЛЬНО ИДУТ здесь (libpango
|
||||
# установлен ниже), тогда как на macOS-dev они runtime-skip'аются.
|
||||
# FUTURE: добавить `postgis/postgis:16-3.4` service + гонять mv_layout — см.
|
||||
# .github/workflows/ci.yml как образец service-блока.
|
||||
#
|
||||
# FUTURE: захочется добавить сюда живой postgis и гонять mv_layout — ⚠️ НЕ через
|
||||
# `services:` с публикацией порта (#2757). Раннер запускает и job, и сервис-
|
||||
# контейнеры с `--network host`, а на 5432 этого же хоста слушает БОЕВОЙ
|
||||
# Postgres: контейнер порт не займёт, а `localhost:5432` из job'а — это прод.
|
||||
# В #2745 так и вышло, спасло только несовпадение пароля. Образец правильного
|
||||
# способа (docker run в bridge-сети БЕЗ публикации, готовность по TCP, коннект
|
||||
# по IP контейнера) — в .forgejo/workflows/ci-tradein.yml, шаг «Поднять Postgres
|
||||
# и собрать схему tradein». В .github/workflows/ci.yml лежит ровно анти-пример
|
||||
# (`ports: 5432:5432`) — он безвреден только потому, что GitHub Actions у нас не
|
||||
# исполняется; копировать оттуда нельзя. Гейт ниже (Guard: host-port collisions)
|
||||
# уронит сборку, если такая публикация всё же появится.
|
||||
on:
|
||||
# ТОЛЬКО pull_request — НЕТ push-триггера на feature-ветки (CI-шторм #1709).
|
||||
# WHY: раньше был и push: [feat/**,fix/**,...]. Каждый коммит в ветку с открытым
|
||||
|
|
@ -45,6 +55,25 @@ jobs:
|
|||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: "Guard: host-port collisions in workflows (#2757)"
|
||||
# Шагом в changes-job, а не отдельным job'ом: этот job и так бежит на
|
||||
# КАЖДОМ PR и уже сделал checkout — гейт стоит ~1с и не занимает
|
||||
# дефицитный слот раннера. Падение = merge заблокирован.
|
||||
# python3 есть в образе раннера (catthehacker/ubuntu:act-latest, 3.12.3).
|
||||
run: |
|
||||
python3 scripts/check-workflow-ports.py --selftest
|
||||
python3 scripts/check-workflow-ports.py
|
||||
|
||||
- name: "Guard: блокирующий DDL без lock_timeout (#2752)"
|
||||
# Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR,
|
||||
# включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow —
|
||||
# фильтруется только job backend-tests). Это важно: миграции лежат в ДВУХ
|
||||
# каталогах, и гейт, видимый лишь одному лэйну, пропускал бы половину.
|
||||
run: |
|
||||
python3 scripts/check-migration-lock-timeout.py --selftest
|
||||
python3 scripts/check-migration-lock-timeout.py
|
||||
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
|
|
@ -69,6 +98,20 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
# Postgres-сервис (#2745). Раньше DATABASE_URL указывал на заведомо мёртвый
|
||||
# хост, и весь tests/sql/ (10 тестов: #17 velocity-alerts, #99 ДДУ-индикатор,
|
||||
# #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки
|
||||
# не бежали ни разу с момента написания.
|
||||
#
|
||||
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
|
||||
# таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему —
|
||||
# проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не
|
||||
# нужен, в отличие от tradein-лэйна.
|
||||
#
|
||||
# TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/
|
||||
# (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по
|
||||
# SSH-туннелю. Пустой контейнер дал бы там красноту на пустом месте, поэтому
|
||||
# integration остаётся честно пропущенным — с причиной в логе (`-rs`).
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
|
|
@ -76,14 +119,53 @@ jobs:
|
|||
# TESTING=1 активирует RBAC-bypass (app/main.py rbac_guard пропускает
|
||||
# запросы при settings.testing=True) — иначе 401 на всём /api/v1.
|
||||
TESTING: "1"
|
||||
# Stub DSN: psycopg v3 требует parseable URL на импорте; реального коннекта
|
||||
# нет — DB-тесты мокаются, real-DB тест (tests/sql/) self-skip'ается через
|
||||
# connectivity-probe к этому хосту (5432 недоступен → skip).
|
||||
DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
# Имя контейнера уникально на прогон: параллельные PR не дерутся за него.
|
||||
CI_PG: ci-pg-backend-${{ github.run_id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Поднять Postgres для тестов
|
||||
working-directory: .
|
||||
# ПОЧЕМУ НЕ `services:` И ПОЧЕМУ БЕЗ ПУБЛИКАЦИИ ПОРТА — подробный разбор в
|
||||
# ci-tradein.yml (тот же раннер). Кратко: job и сервис-контейнеры идут с
|
||||
# `--network host`, а на 5432 этого хоста слушает ПРОДОВЫЙ Postgres, то
|
||||
# есть `localhost:5432` из job'а — боевая база. Поднимаем контейнер сами,
|
||||
# в bridge-сети, без публикации порта, ходим по его IP.
|
||||
#
|
||||
# `pg_isready -h 127.0.0.1`, а не через unix-сокет: по сокету отвечает
|
||||
# ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД
|
||||
# ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере.
|
||||
#
|
||||
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
|
||||
# таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы
|
||||
# здесь не нужен вовсе, в отличие от tradein-лэйна.
|
||||
run: |
|
||||
set -u
|
||||
docker rm -f "$CI_PG" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$CI_PG" \
|
||||
-e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \
|
||||
postgres:16
|
||||
|
||||
ready=""
|
||||
for _ in $(seq 1 45); do
|
||||
if docker exec "$CI_PG" pg_isready -h 127.0.0.1 -U gendesign -q 2>/dev/null; then
|
||||
ready=1; break
|
||||
fi
|
||||
[ "$(docker inspect -f '{{.State.Status}}' "$CI_PG" 2>/dev/null)" = "running" ] || break
|
||||
sleep 2
|
||||
done
|
||||
if [ -z "$ready" ]; then
|
||||
echo "::error::Postgres не поднялся; статус=$(docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' "$CI_PG" 2>&1)"
|
||||
docker logs --tail 50 "$CI_PG" 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CI_PG")
|
||||
[ -n "$ip" ] || { echo "::error::не удалось узнать IP контейнера $CI_PG"; exit 1; }
|
||||
echo "DATABASE_URL=postgresql+psycopg://gendesign:gendesign@${ip}:5432/gendesign_ci" >> "$GITHUB_ENV"
|
||||
echo "✓ Postgres на ${ip}:5432 (контейнер $CI_PG)"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
|
|
@ -136,10 +218,13 @@ jobs:
|
|||
# но --ignore — belt-and-suspenders на случай сбора фикстур).
|
||||
# tests/integration self-skip'ается через requires_test_db (skipif на
|
||||
# TEST_DATABASE_URL, который тут не задан) → НЕ игнорим, оно чисто skip'ается.
|
||||
# tests/sql/ mv_layout self-skip'ается через Postgres-connectivity probe
|
||||
# (5432 недоступен в этом mock-lane) → SKIP. Это intended.
|
||||
# tests/sql/ теперь РЕАЛЬНО ИДУТ — postgres-контейнер выше (#2745).
|
||||
# PDF-тесты ИДУТ (libpango выше). Target: 0 failed, skips OK.
|
||||
#
|
||||
# `-rs` (#2745): каждый оставшийся пропуск печатает причину. Под `-q` без
|
||||
# него пропуск неотличим от прогона — именно так проверка тихо перестаёт
|
||||
# исполняться и об этом узнают, когда на неё надо опереться (#2722/#2729/#2740).
|
||||
#
|
||||
# Coverage-gate (#68): --cov=app меряет покрытие пакета app/.
|
||||
# --cov-fail-under=65 → job RED если покрытие упало ниже baseline
|
||||
# (измерено 2026-06: mock-lane сьют ~71%, см. [tool.coverage] в pyproject;
|
||||
|
|
@ -148,7 +233,7 @@ jobs:
|
|||
# coverage.xml — артефакт для будущего Codecov/Coveralls upload (#68 badge).
|
||||
# term-missing → видно непокрытые строки прямо в job-логе.
|
||||
run: |
|
||||
uv run pytest -q --ignore=tests/smoke \
|
||||
uv run pytest -q -rs --ignore=tests/smoke \
|
||||
--cov=app \
|
||||
--cov-report=term-missing:skip-covered \
|
||||
--cov-report=xml:coverage.xml \
|
||||
|
|
@ -169,6 +254,13 @@ jobs:
|
|||
echo "$report"
|
||||
fi
|
||||
|
||||
- name: Снести тестовый Postgres
|
||||
# if: always() — контейнер уходит и когда сьют красный, и когда прогон
|
||||
# отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры.
|
||||
if: always()
|
||||
working-directory: .
|
||||
run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true
|
||||
|
||||
frontend-tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
|
|
|
|||
|
|
@ -28,12 +28,28 @@ jobs:
|
|||
frontend: ${{ steps.set-all.outputs.frontend || steps.filter.outputs.frontend }}
|
||||
browser: ${{ steps.set-all.outputs.browser || steps.filter.outputs.browser }}
|
||||
infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }}
|
||||
scraper: ${{ steps.set-all.outputs.scraper || steps.filter.outputs.scraper }}
|
||||
# Отдельного `scraper`-признака больше нет (#2679) — см. SCRAPER_RECREATE
|
||||
# в job deploy: scraper/tgbot бегут ТОТ ЖЕ образ, что и backend.
|
||||
app_version: ${{ steps.build-meta.outputs.app_version }}
|
||||
build_sha: ${{ steps.build-meta.outputs.build_sha }}
|
||||
build_date: ${{ steps.build-meta.outputs.build_date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Версия продукта «Мера» (tradein-mvp/VERSION — единственный источник
|
||||
# правды, см. tradein-mvp/CHANGELOG.md) + короткий SHA + дата сборки —
|
||||
# проброшены как build-args в build-backend/build-frontend ниже (см.
|
||||
# tradein-mvp/backend/Dockerfile + tradein-mvp/frontend/Dockerfile).
|
||||
# Считается ОДИН раз здесь, а не в каждой job отдельно.
|
||||
- name: Resolve build metadata (APP_VERSION / BUILD_SHA / BUILD_DATE)
|
||||
id: build-meta
|
||||
run: |
|
||||
echo "app_version=$(tr -d '[:space:]' < tradein-mvp/VERSION)" >> "$GITHUB_OUTPUT"
|
||||
echo "build_sha=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"
|
||||
echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Resolve base SHA: read last-successfully-deployed SHA from the VPS host file.
|
||||
# The file is written by the deploy job on every successful deploy.
|
||||
# Fail-safe: if we cannot read the file, or the SHA is not an ancestor of HEAD,
|
||||
|
|
@ -91,7 +107,6 @@ jobs:
|
|||
echo "frontend=true" >> "$GITHUB_OUTPUT"
|
||||
echo "browser=true" >> "$GITHUB_OUTPUT"
|
||||
echo "infra=true" >> "$GITHUB_OUTPUT"
|
||||
echo "scraper=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Cumulative diff: compare deployed SHA → HEAD so that a fast chain of merges
|
||||
# (e.g. backend #1829 then frontend #1830) doesn't lose earlier changes.
|
||||
|
|
@ -107,28 +122,40 @@ jobs:
|
|||
# scheduler_main импортирует пакет) — kit-only изменение обязано
|
||||
# пересобрать образ, иначе деплой рестартует контейнеры на старом.
|
||||
- 'tradein-mvp/packages/scraper-kit/**'
|
||||
# APP_VERSION запекается build-arg'ом в backend-образ (см. build-backend
|
||||
# ниже + backend/Dockerfile + app/core/version.py) — bump версии БЕЗ
|
||||
# правок кода обязан пересобрать образ, иначе GET /version и колонтитул
|
||||
# PDF продолжат отдавать старое значение при формально «успешном» деплое.
|
||||
- 'tradein-mvp/VERSION'
|
||||
frontend:
|
||||
- 'tradein-mvp/frontend/**'
|
||||
# NEXT_PUBLIC_APP_VERSION build-time (см. frontend/Dockerfile) — та же
|
||||
# причина, что у backend выше.
|
||||
- 'tradein-mvp/VERSION'
|
||||
# /versions статически запекает CHANGELOG.md в билд (см.
|
||||
# frontend/src/app/versions/page.tsx) — правка одного файла БЕЗ
|
||||
# frontend/** иначе не долетала бы до образа.
|
||||
- 'tradein-mvp/CHANGELOG.md'
|
||||
browser:
|
||||
- 'tradein-mvp/browser/**'
|
||||
infra:
|
||||
- 'tradein-mvp/docker-compose.prod.yml'
|
||||
- 'tradein-mvp/deploy/**'
|
||||
- '.forgejo/workflows/deploy-tradein.yml'
|
||||
scraper:
|
||||
- 'tradein-mvp/backend/app/services/scrapers/**'
|
||||
- 'tradein-mvp/backend/app/services/scrape_pipeline.py'
|
||||
- 'tradein-mvp/backend/app/services/scheduler.py'
|
||||
- 'tradein-mvp/backend/app/scheduler_main.py'
|
||||
- 'tradein-mvp/backend/app/tasks/**'
|
||||
# #2188: scheduler исполняет matching/dedup при каждом scrape-тике —
|
||||
# без этих путей scraper-контейнер оставался на старом коде
|
||||
# (2026-07-02: fias-dedup доехал до tradein-backend, но не до
|
||||
# tradein-scraper). После USE_KIT_SCHEDULER=true kit-код и есть
|
||||
# scheduler — его правки тоже обязаны пересоздавать контейнер.
|
||||
- 'tradein-mvp/backend/app/services/matching/**'
|
||||
- 'tradein-mvp/backend/app/services/house_dedup_merge.py'
|
||||
- 'tradein-mvp/packages/scraper-kit/**'
|
||||
# УДАЛЁН фильтр `scraper` (#2679, 2026-08-05). Он был allowlist'ом
|
||||
# «файлов, которые исполняет планировщик», и перечислял только то,
|
||||
# что вспомнили. Дважды выстрелило одинаково:
|
||||
# 2026-07-02 (#2188) — fias-dedup доехал до tradein-backend, но не
|
||||
# до tradein-scraper; починили ДОБАВЛЕНИЕМ путей (matching/**,
|
||||
# house_dedup_merge.py) — залатали случай, не механизм;
|
||||
# 2026-08-05 (#2675) — house_imv_backfill.py + product_handlers.py
|
||||
# в списке не значились → планировщик час крутил старый код,
|
||||
# деплой при этом отчитался успехом.
|
||||
# За июнь-август 48% (193 из 402) backend-мержей не попадали ни в
|
||||
# один из путей списка, т.е. половина правок доезжала до scraper'а
|
||||
# только со следующим «удачным» деплоем. Теперь пересоздание
|
||||
# привязано не к списку файлов, а к факту пересборки образа —
|
||||
# см. SCRAPER_RECREATE в job deploy.
|
||||
|
||||
# Quality gate: pytest MUST pass before any image is built/deployed (#666).
|
||||
# Runs the tradein-mvp/backend suite; a red test blocks build + deploy.
|
||||
|
|
@ -164,15 +191,25 @@ jobs:
|
|||
run: uv sync --frozen
|
||||
|
||||
- name: Run pytest (tradein-mvp/backend)
|
||||
# DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает
|
||||
# ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state
|
||||
# leak из другого test-модуля, pre-existing. Второй исторический deselect
|
||||
# (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в полном
|
||||
# прогоне (проверено 2026-07-02: 2947 passed / 1 failed). Список обязан
|
||||
# совпадать с backend-tests в ci-tradein.yml (pre-merge гейт).
|
||||
run: |
|
||||
uv run pytest -q \
|
||||
--deselect "tests/test_search_api.py::test_search_cache_hit"
|
||||
# БЕЗ deselect'ов — сьют гоняется целиком, как в ci-tradein.yml.
|
||||
#
|
||||
# Здесь жил `--deselect tests/test_search_api.py::test_search_cache_hit` с
|
||||
# объяснением «падает ТОЛЬКО в whole-suite ordering, в изоляции проходит —
|
||||
# global-state leak из другого модуля». Объяснение было неверным в обеих
|
||||
# половинах: тест падал и в изоляции тоже (401 vs 200), потому что ходил в
|
||||
# /api/v1/search БЕЗ заголовка X-Authenticated-User, а RBAC-гард отвечает на
|
||||
# такое 401. Причина была в самом тесте; заголовок добавлен в #2729, и в
|
||||
# pre-merge гейте deselect снят тогда же. Здесь строка пережила починку ещё
|
||||
# на месяц — файл был занят открытым #2680. Тот смержен, долг закрыт.
|
||||
#
|
||||
# Не добавлять сюда новые deselect'ы: молча выключенный тест — тот же класс
|
||||
# дефекта, что каталог вне пайплайна (#2722). Тест либо чинится, либо
|
||||
# помечается xfail с причиной В КОДЕ, рядом с самим тестом.
|
||||
#
|
||||
# `-rs`: каждый пропуск печатает причину (#2745). Ожидание в этом лэйне —
|
||||
# 13 пропусков, все объявлены в tests/skip_allowlist.txt; неучтённый
|
||||
# пропуск роняет прогон через хук в tests/conftest.py.
|
||||
run: uv run pytest -q -rs
|
||||
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -201,6 +238,13 @@ jobs:
|
|||
context: ./tradein-mvp
|
||||
file: ./tradein-mvp/backend/Dockerfile
|
||||
push: true
|
||||
# APP_VERSION/BUILD_SHA/BUILD_DATE → runtime env в образе (см.
|
||||
# backend/Dockerfile ARG→ENV) — читает app/core/version.py:
|
||||
# GET /api/v1/trade-in/version + колонтитул PDF-отчёта.
|
||||
build-args: |
|
||||
APP_VERSION=${{ needs.changes.outputs.app_version }}
|
||||
BUILD_SHA=${{ needs.changes.outputs.build_sha }}
|
||||
BUILD_DATE=${{ needs.changes.outputs.build_date }}
|
||||
cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
|
||||
tags: |
|
||||
|
|
@ -226,6 +270,14 @@ jobs:
|
|||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# CHANGELOG.md живёт в tradein-mvp/, ОДИН уровень выше build context
|
||||
# (./tradein-mvp/frontend) — Docker не пускает COPY за пределы контекста,
|
||||
# поэтому копируем внутрь ДО build. /versions статически запекает его
|
||||
# содержимое (см. frontend/src/lib/changelog.ts + Dockerfile builder-stage
|
||||
# комментарий). Не влияет на кэш другого шага — читается только этим.
|
||||
- name: Stage CHANGELOG.md into frontend build context
|
||||
run: cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md
|
||||
|
||||
- name: Build & push tradein-frontend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
|
|
@ -236,9 +288,15 @@ jobs:
|
|||
# (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в
|
||||
# dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница
|
||||
# уходит в notFound (404), не индексируется и не краулится.
|
||||
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
|
||||
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
|
||||
# см. frontend/Dockerfile комментарий у соответствующих ARG).
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_PATH=/trade-in
|
||||
NEXT_PUBLIC_API_BASE_URL=/trade-in
|
||||
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
|
||||
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
|
||||
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
|
||||
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
||||
tags: |
|
||||
|
|
@ -294,21 +352,32 @@ jobs:
|
|||
uses: appleboy/ssh-action@v1.0.3
|
||||
env:
|
||||
IMAGE_TAG: latest
|
||||
# Нужен на VPS, чтобы спросить у демона ID подтянутого образа и не
|
||||
# уходить в drain, когда пересоздавать нечего (см. ниже, #2679).
|
||||
IMAGE_BACKEND: ${{ env.IMAGE_BACKEND }}
|
||||
GHCR_PAT: ${{ secrets.GHCR_PAT }}
|
||||
# Phase 0: generic infra edits (compose / workflow / deploy/**) must NOT
|
||||
# recreate the scraper and SIGKILL a running multi-hour job. Only genuine
|
||||
# scraper-code paths (the `scraper` paths-filter already covers
|
||||
# app/services/scrapers/**, scrape_pipeline.py, scheduler.py,
|
||||
# scheduler_main.py, app/tasks/**) — or a manual workflow_dispatch —
|
||||
# should trigger a scraper recreate. (infra term intentionally dropped.)
|
||||
SCRAPER_CHANGED: ${{ needs.changes.outputs.scraper == 'true' || github.event_name == 'workflow_dispatch' }}
|
||||
# #2679: backend / scraper / tgbot — ОДИН И ТОТ ЖЕ образ
|
||||
# gendesign-tradein-backend (см. docker-compose.prod.yml: три сервиса,
|
||||
# одна строка image, разный command). Значит вопрос «пересоздавать ли
|
||||
# scraper» — это не «трогали ли его файлы», а «мог ли пересобраться
|
||||
# образ». Условие ОБЯЗАНО совпадать с `if:` джобы build-backend:
|
||||
# backend || infra || workflow_dispatch. Ровно тогда в реестре мог
|
||||
# появиться новый :latest, и оставить scraper на старом — значит
|
||||
# оставить планировщик на старом коде (инцидент #2679).
|
||||
#
|
||||
# Раньше здесь стоял «Phase 0»-компромисс: infra-правки намеренно НЕ
|
||||
# пересоздавали scraper, чтобы не убить многочасовой прогон. Компромисс
|
||||
# больше не нужен — с #1951 перед recreate'ом идёт graceful drain
|
||||
# (ждём scrape_runs до 5 мин) + startup-reap осиротевших строк, а сам
|
||||
# `compose up -d` на неизменившемся образе — no-op.
|
||||
SCRAPER_RECREATE: ${{ needs.changes.outputs.backend == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
envs: IMAGE_TAG,GHCR_PAT,SCRAPER_CHANGED,GITHUB_SHA
|
||||
envs: IMAGE_TAG,IMAGE_BACKEND,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA
|
||||
script: |
|
||||
set -euo pipefail
|
||||
cd /opt/gendesign
|
||||
|
|
@ -431,6 +500,35 @@ jobs:
|
|||
done
|
||||
echo "All migrations applied."
|
||||
|
||||
# (3b) Невалидные индексы после цикла (#2752). Оборванный
|
||||
# CREATE INDEX CONCURRENTLY оставляет индекс с indisvalid=false:
|
||||
# планировщик им НЕ пользуется (проверено — Seq Scan), а поддержка
|
||||
# на записи всё равно платится. Молчит это так (воспроизведено на
|
||||
# PostgreSQL 16.4): CIC упал → деплой красный, миграция не помечена
|
||||
# применённой → следующий деплой прогоняет её заново → `CREATE INDEX
|
||||
# CONCURRENTLY IF NOT EXISTS` видит битый индекс, печатает
|
||||
# «relation already exists, skipping», выходит с кодом 0 → миграция
|
||||
# помечается применённой, а индекс остаётся невалидным навсегда.
|
||||
# Поэтому проверка не в каждом файле DO-блоком, а одна здесь: она
|
||||
# ловит и этот путь, и невалидные индексы любого другого
|
||||
# происхождения (отменённый job, ручной CIC оператором).
|
||||
# На 2026-08-07 на проде таких индексов 0 — это профилактика.
|
||||
invalid_idx=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
|
||||
"SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \
|
||||
| tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \
|
||||
|| { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; }
|
||||
if [ -n "$invalid_idx" ]; then
|
||||
echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx"
|
||||
echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой"
|
||||
echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит"
|
||||
echo " (тихо пропускает как существующий). Новый app-код НЕ поднят."
|
||||
echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем"
|
||||
echo " пересоздать индекс и повторить деплой."
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ невалидных индексов нет."
|
||||
|
||||
# Bootstrap gendesign_reader password from env (post-migration, #976).
|
||||
# SQL migration 101_gendesign_reader_role.sql creates role passwordless;
|
||||
# password lives only in /opt/gendesign/tradein-mvp/.env.runtime.
|
||||
|
|
@ -489,8 +587,43 @@ jobs:
|
|||
# с browser/backend/frontend, отдельного graceful-drain не требует.
|
||||
SERVICES="browser backend frontend tgbot"
|
||||
SCRAPER_STOP_TS=""
|
||||
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
|
||||
echo "→ scraper paths changed — waiting for in-flight scrape_runs to drain (up to 5 min)"
|
||||
scraper_stale=""
|
||||
if [ "${SCRAPER_RECREATE:-true}" = "true" ]; then
|
||||
# Пересоздавать нечего — и ждать нечего (#2679). SCRAPER_RECREATE
|
||||
# истинно и на infra-правках (compose / workflow / deploy/**), а те
|
||||
# почти всегда собирают ТОТ ЖЕ образ по кэшу: digest не меняется,
|
||||
# `up -d` выходит no-op — и платить за него пятиминутным drain'ом,
|
||||
# прерывая многочасовой сбор, не за что. Сравниваем, на том ли
|
||||
# образе бежит scraper, что уже лежит в локальном демоне.
|
||||
# ПОРЯДОК ВАЖЕН: только ПОСЛЕ `docker compose pull` (шаг выше) —
|
||||
# до pull'а под тегом :latest ещё старый образ, сравнение всегда
|
||||
# «совпало» и drain пропускался бы как раз тогда, когда он нужен.
|
||||
# Заодно чинит ложный startup-reap: чекпоинт/reap ниже завязаны на
|
||||
# ЭТОТ же признак и больше не выполняются, когда recreate'а не было
|
||||
# (иначе живой прогон с heartbeat старше чекпоинта помечался бы
|
||||
# 'cancelled', продолжая работать).
|
||||
pulled_image=$(docker image inspect -f '{{.Id}}' "$IMAGE_BACKEND:$IMAGE_TAG" 2>/dev/null || echo "")
|
||||
running_image=$(docker inspect -f '{{.Image}}' tradein-scraper 2>/dev/null || echo "")
|
||||
if [ -n "$pulled_image" ] && [ "$pulled_image" = "$running_image" ]; then
|
||||
echo "→ образ scraper'а не изменился ($pulled_image) — пересоздавать нечего,"
|
||||
echo " drain пропускаем, in-flight прогоны не трогаем"
|
||||
else
|
||||
scraper_stale="yes"
|
||||
fi
|
||||
# scraper в $SERVICES в обоих случаях: при совпавшем образе `up -d`
|
||||
# — no-op, но правка самого compose (env/лимиты сервиса) так всё же
|
||||
# доезжает. Ceiling: такой config-only recreate идёт БЕЗ drain'а —
|
||||
# страхуют SIGTERM-drain (#1182) + stop_grace_period 120s, а строку
|
||||
# прогона подчистит периодический 6h zombie-reaper.
|
||||
SERVICES="$SERVICES scraper"
|
||||
else
|
||||
echo "→ backend-образ в этом деплое не пересобирался — tradein-scraper не трогаем"
|
||||
echo " (сверка образов ниже всё равно проверит, что он не отстал)"
|
||||
fi
|
||||
|
||||
if [ -n "$scraper_stale" ]; then
|
||||
echo "→ новый backend-образ — scraper пересоздаётся вместе с backend (#2679);"
|
||||
echo " ждём слива in-flight scrape_runs (до 5 мин)"
|
||||
drained=""
|
||||
for i in $(seq 1 30); do
|
||||
# NB: не сливать "psql не ответил" с "0 running" — иначе неудачный
|
||||
|
|
@ -527,15 +660,11 @@ jobs:
|
|||
SCRAPER_STOP_TS="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc "SELECT NOW();" 2>/dev/null | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" || SCRAPER_STOP_TS=""
|
||||
echo "→ scraper checkpoint ts (DB clock): ${SCRAPER_STOP_TS:-unknown}"
|
||||
|
||||
SERVICES="$SERVICES scraper"
|
||||
else
|
||||
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
|
||||
fi
|
||||
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps $SERVICES
|
||||
|
||||
if [ "${SCRAPER_CHANGED:-true}" = "true" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then
|
||||
if [ -n "$scraper_stale" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then
|
||||
echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом"
|
||||
# NB: psql `-c` НЕ поддерживает `:'var'`-подстановку (переменная доходит до
|
||||
# сервера как литерал → syntax error, см. комментарий выше про TRADEIN_READER_PASSWORD)
|
||||
|
|
@ -653,7 +782,7 @@ jobs:
|
|||
# снижает шанс поймать контейнер ровно в момент between-restarts
|
||||
# промежуточного "running" внутри crash-loop.
|
||||
# tgbot пересоздаётся на КАЖДОМ деплое (безусловно в $SERVICES);
|
||||
# scraper — только когда SCRAPER_CHANGED (см. блок выше) — поэтому
|
||||
# scraper — только когда SCRAPER_RECREATE (см. блок выше) — поэтому
|
||||
# проверяем только то, что реально входит в текущий $SERVICES.
|
||||
for svc in tgbot scraper; do
|
||||
case " $SERVICES " in
|
||||
|
|
@ -683,6 +812,49 @@ jobs:
|
|||
echo "→ tradein-$svc running."
|
||||
done
|
||||
|
||||
# Сверка образов backend-семейства (#2679) — последняя проверка перед
|
||||
# маркером «задеплоено». backend/scraper/tgbot бегут ОДИН образ
|
||||
# gendesign-tradein-backend; backend пересоздаётся на каждом деплое
|
||||
# (безусловно в $SERVICES) и потому всегда несёт свежий :latest —
|
||||
# он и есть эталон. Если у scraper или tgbot image ID другой, значит
|
||||
# контейнер остался на старом коде, а деплой без этой проверки
|
||||
# отчитался бы успехом: ровно инцидент 2026-08-05 (#2675 доехал до
|
||||
# tradein-backend, ff98603ba3cc; tradein-scraper остался на
|
||||
# da26154c64a6 часовой давности — а планировщик, единственный
|
||||
# исполнитель домовой оценки, живёт именно там).
|
||||
# Падаем, а не warning'уем: расхождение = правка не работает, и
|
||||
# узнать об этом надо в момент деплоя, а не через месяц. exit 1 идёт
|
||||
# ДО записи .tradein-deployed-sha → следующий прогон возьмёт ту же
|
||||
# базу и пересоберёт всё накопленное (тот же приём, что в health-check).
|
||||
# «Контейнера нет» и «контейнер отстал» — разные аварии и чинятся
|
||||
# по-разному, поэтому сообщения различаются явно.
|
||||
backend_image=$(docker inspect -f '{{.Image}}' tradein-backend 2>/dev/null || echo "")
|
||||
image_mismatch=""
|
||||
if [ -z "$backend_image" ]; then
|
||||
echo "ERROR: контейнера tradein-backend нет — сверять образы не с чем."
|
||||
image_mismatch="yes"
|
||||
fi
|
||||
for svc in scraper tgbot; do
|
||||
svc_image=$(docker inspect -f '{{.Image}}' "tradein-$svc" 2>/dev/null || echo "")
|
||||
if [ -z "$svc_image" ]; then
|
||||
echo "ERROR: контейнера tradein-$svc НЕТ (удалён или не создавался) — это не отставший"
|
||||
echo " образ, а неполный стек: сервис не работает вообще."
|
||||
image_mismatch="yes"
|
||||
elif [ -n "$backend_image" ] && [ "$svc_image" != "$backend_image" ]; then
|
||||
echo "ERROR: tradein-$svc ОТСТАЛ: работает на $svc_image, tradein-backend — на $backend_image"
|
||||
image_mismatch="yes"
|
||||
fi
|
||||
done
|
||||
if [ -n "$image_mismatch" ]; then
|
||||
echo "ERROR: backend-семейство не на одном образе — деплой FAILED (#2679)."
|
||||
echo " Лечение вручную (поднимет отсутствующие, пересоздаст отставшие):"
|
||||
echo " docker compose -p gendesign-tradein \\"
|
||||
echo " -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \\"
|
||||
echo " up -d --force-recreate --no-deps backend scraper tgbot"
|
||||
exit 1
|
||||
fi
|
||||
echo "→ образы совпадают: backend/scraper/tgbot на $backend_image."
|
||||
|
||||
# Cleanup старых образов
|
||||
for repo in ghcr.io/lekss361/gendesign-tradein-backend \
|
||||
ghcr.io/lekss361/gendesign-tradein-frontend; do
|
||||
|
|
|
|||
|
|
@ -309,6 +309,31 @@ jobs:
|
|||
done
|
||||
echo "All migrations applied."
|
||||
|
||||
# Невалидные индексы после цикла (#2752). Оборванный CREATE INDEX
|
||||
# CONCURRENTLY оставляет индекс с indisvalid=false: планировщик им НЕ
|
||||
# пользуется, а re-run миграции его не чинит — `CREATE INDEX
|
||||
# CONCURRENTLY IF NOT EXISTS` печатает «relation already exists,
|
||||
# skipping» и выходит с кодом 0, после чего миграция помечается
|
||||
# применённой, а индекс остаётся битым навсегда (воспроизведено на
|
||||
# PostgreSQL 16.4). В data/sql 5 файлов с CREATE INDEX CONCURRENTLY.
|
||||
# Одна проверка здесь вместо DO-блока в каждом файле; на 2026-08-07
|
||||
# на проде таких индексов 0 — это профилактика.
|
||||
invalid_idx=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \
|
||||
"SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \
|
||||
| tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \
|
||||
|| { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; }
|
||||
if [ -n "$invalid_idx" ]; then
|
||||
echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx"
|
||||
echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой"
|
||||
echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит"
|
||||
echo " (тихо пропускает как существующий). Новый app-код НЕ поднят."
|
||||
echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем"
|
||||
echo " пересоздать индекс и повторить деплой."
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ невалидных индексов нет."
|
||||
|
||||
# Set tradein_fdw_reader password from env (post-migration bootstrap).
|
||||
# SQL migration 100_tradein_fdw_role.sql creates role passwordless;
|
||||
# password lives only in /opt/gendesign/backend/.env.runtime.
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ SCRAPE_KN_JITTER_SECONDS=1800
|
|||
SCRAPE_KN_DEFAULT_REGIONS=66
|
||||
# Путь к Playwright storage_state.json (commited в git, обновляется --save-state).
|
||||
SCRAPE_KN_STATE_PATH=data/playwright_state.json
|
||||
# DEPRECATED 2026-05-23: app-level admin auth removed (PR #436, Caddy basic_auth достаточен).
|
||||
# Reinstate: revert changes in admin_*.py чтобы вернуть AdminTokenAuth dep.
|
||||
# Переменная сохранена в core/deps.py для быстрого rollback.
|
||||
SCRAPE_ADMIN_TOKEN=
|
||||
# SCRAPE_ADMIN_TOKEN удалён в #2775. App-level admin-auth сняли ещё в PR #437,
|
||||
# а поле держали «для быстрого rollback» — за полтора месяца у него не появилось
|
||||
# ни одного вызывающего. `/api/v1/admin/*` закрыт middleware rbac_guard
|
||||
# (app/main.py, role != admin → 403) + Caddy basic_auth (PR #426).
|
||||
|
|
|
|||
|
|
@ -2189,12 +2189,21 @@ def analyze_parcel(
|
|||
_effective_weights = {**_POI_WEIGHTS, **_inline_weights}
|
||||
_weights_source = "inline"
|
||||
else:
|
||||
_effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
|
||||
_weights_source = (
|
||||
"profile"
|
||||
if profile_id is not None
|
||||
else ("user_default" if profile_user_id is not None else "system")
|
||||
)
|
||||
# Метка — из РЕЗУЛЬТАТА резолва, не из того, что клиент прислал (#2811):
|
||||
# profile_id мог не найтись (нет owner'а в запросе / чужой / удалён), и
|
||||
# тогда веса системные или дефолтные, а не профильные.
|
||||
_resolved = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
|
||||
_effective_weights = _resolved.weights
|
||||
_weights_source = _resolved.source
|
||||
|
||||
# «Что просили» vs «что получилось»: profile_id echo'ит запрос, флаг говорит,
|
||||
# был ли запрос удовлетворён. Отдельное поле, а не подмена source на "system" —
|
||||
# иначе пропадёт разница «профиль не запрашивали» / «запрашивали, но не нашли».
|
||||
# None когда profile_id не передавали; False когда передали, но применилось
|
||||
# другое (не найден / чужой / перебит inline-весами).
|
||||
_requested_profile_applied: bool | None = (
|
||||
None if profile_id is None else _weights_source == "profile"
|
||||
)
|
||||
|
||||
# 4) Scoring: weighted sum с distance decay
|
||||
score = 0.0
|
||||
|
|
@ -4085,9 +4094,12 @@ def analyze_parcel(
|
|||
# (None когда вердикт позитивный / нет площади / считать нечего). caveat внутри.
|
||||
"program_alternatives": program_alternatives,
|
||||
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
|
||||
# source — что ФАКТИЧЕСКИ применилось; requested_profile_applied — был ли
|
||||
# удовлетворён запрошенный profile_id (#2811). None = профиль не запрашивали.
|
||||
"weights_profile": {
|
||||
"source": _weights_source,
|
||||
"profile_id": profile_id,
|
||||
"requested_profile_applied": _requested_profile_applied,
|
||||
"user_id": profile_user_id,
|
||||
"weights_applied": _effective_weights,
|
||||
"inline_weights": _inline_weights,
|
||||
|
|
@ -4203,6 +4215,7 @@ def analyze_parcel(
|
|||
"profile_user_id": profile_user_id,
|
||||
"inline_weights": _inline_weights,
|
||||
"weights_source": _weights_source,
|
||||
"requested_profile_applied": _requested_profile_applied,
|
||||
"x_session_id": _session_id,
|
||||
},
|
||||
district=_district_name,
|
||||
|
|
|
|||
|
|
@ -146,9 +146,6 @@ class Settings(BaseSettings):
|
|||
# Path to a pre-captured Playwright storage_state.json (committed in repo,
|
||||
# used by worker to skip cold-start WAF challenge).
|
||||
scrape_kn_state_path: str = "data/playwright_state.json"
|
||||
# Token to authorize ad-hoc /api/v1/admin/scrape/* trigger calls.
|
||||
# Empty string = endpoint disabled.
|
||||
scrape_admin_token: str = ""
|
||||
|
||||
# ── #1945 KN-loader anti-ban (throttle + optional proxy) ──────────────────
|
||||
# DOM.РФ WAF банит IP по volume/rate (HTTP 403 «Доступ заблокирован», БЕЗ
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
"""Shared FastAPI dependencies."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def verify_admin_token(
|
||||
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||
) -> None:
|
||||
"""Verify admin token header. Raises 503 if not configured, 401 if invalid or missing."""
|
||||
if not settings.scrape_admin_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="admin disabled — set SCRAPE_ADMIN_TOKEN",
|
||||
)
|
||||
if x_admin_token != settings.scrape_admin_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid admin token")
|
||||
|
||||
|
||||
AdminTokenAuth = Annotated[None, Depends(verify_admin_token)]
|
||||
|
|
@ -48,7 +48,7 @@ from app.core import auth_db
|
|||
from app.core.audit_middleware import audit_log_middleware
|
||||
from app.core.auth import get_role
|
||||
from app.core.config import settings
|
||||
from app.observability.sentry_scrub import scrub_sensitive_query
|
||||
from app.observability.sentry_scrub import scrub_event
|
||||
from app.services.auth_session import resolve_session_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -75,6 +75,11 @@ if not any(getattr(_h, "_gd_app_stream", False) for _h in _app_logger.handlers):
|
|||
# (middleware, маршруты) видели активный client с самого старта процесса.
|
||||
# GlitchTip не поддерживает profiling — profiles_sample_rate=0.0.
|
||||
if settings.glitchtip_dsn:
|
||||
# before_send И before_send_transaction — ОБА на scrub_event (#2457-review):
|
||||
# Starlette-интеграция кладёт request.data на transaction-scope так же, как
|
||||
# на error-scope, поэтому голый scrub_sensitive_query (только URL) на
|
||||
# before_send_transaction оставлял бы PII-канал открытым при любом
|
||||
# glitchtip_traces_sample_rate > 0 (см. sentry_scrub.py module docstring).
|
||||
sentry_sdk.init(
|
||||
dsn=settings.glitchtip_dsn,
|
||||
environment=settings.environment,
|
||||
|
|
@ -82,8 +87,14 @@ if settings.glitchtip_dsn:
|
|||
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
||||
profiles_sample_rate=0.0,
|
||||
send_default_pii=False,
|
||||
before_send=scrub_sensitive_query,
|
||||
before_send_transaction=scrub_sensitive_query,
|
||||
# Локальные переменные кадров стека НЕ уходят в мониторинг (#2753).
|
||||
# Дефолт SDK — True: при любом исключении кадр несёт значения аргументов
|
||||
# (телефон заявки, адрес, токен) под ПРОИЗВОЛЬНЫМИ именами, а scrub_event
|
||||
# сверяет ИМЕНА ключей — такое он не ловит по построению. То есть это не
|
||||
# дополнительная мера, а условие, без которого скраб не полон.
|
||||
include_local_variables=False,
|
||||
before_send=scrub_event,
|
||||
before_send_transaction=scrub_event,
|
||||
integrations=[
|
||||
StarletteIntegration(),
|
||||
FastApiIntegration(),
|
||||
|
|
|
|||
|
|
@ -1,17 +1,72 @@
|
|||
"""Хук before_send_transaction для GlitchTip/Sentry SDK.
|
||||
"""Хуки before_send / before_send_transaction для GlitchTip/Sentry SDK.
|
||||
|
||||
Redact-ит api keys / tokens из URL-spans перед отправкой — чтобы
|
||||
секреты (apiKey=..., api_key=..., token=...) не утекали в GlitchTip
|
||||
через HttpxIntegration performance-spans.
|
||||
`scrub_sensitive_query` — redact-ит api keys / tokens из URL-spans перед
|
||||
отправкой — чтобы секреты (apiKey=..., api_key=..., token=...) не утекали в
|
||||
GlitchTip через HttpxIntegration performance-spans.
|
||||
|
||||
`scrub_pii_event` — redact-ит consumer-PII (client_name / client_phone /
|
||||
client_email / phone / email / name / company / message) из events перед
|
||||
отправкой. `send_default_pii=False` в sentry_sdk.init (проверено на
|
||||
sentry-sdk 2.58) НЕ покрывает эти поля — это user-data, попадающий в
|
||||
request.data / extra / contexts (pilot-заявки — `PilotRequestInput` в
|
||||
`app/api/v1/pilot.py` несёт все 6 полей включая свободный текст `company`/
|
||||
`message`, куда чаще всего прилетают телефоны/имена/адреса; чат — свободный
|
||||
вопрос в `app/schemas/chat.py`), а не PII-заголовки/cookies, которые режет
|
||||
сам флаг. Портировано из trade-in (`tradein-mvp/backend/app/observability/
|
||||
sentry_scrub.py`, #396) — тот же набор ключей (client_name/client_phone/
|
||||
client_email — Птица их не использует сегодня, но одинаковый механизм на
|
||||
оба продукта проще сопровождать), плюс `company`/`message`, специфичные для
|
||||
`PilotRequestInput` (#2457-review).
|
||||
|
||||
`scrub_event` — composed-хендлер (PII-scrub + URL-secret redact), которым
|
||||
надо вешать ОБА канала — `before_send` И `before_send_transaction`.
|
||||
Starlette-интеграция кладёт тело запроса в `request_info["data"]` на
|
||||
transaction-scope точно так же, как на error-scope (scope-обработчики для
|
||||
transactions НЕ пропускаются — пропуск бывает только на availability-чеках).
|
||||
Если повесить PII-scrub только на `before_send`, а `before_send_transaction`
|
||||
оставить на голом `scrub_sensitive_query` — PII продолжит течь через
|
||||
transaction-канал при любом `glitchtip_traces_sample_rate > 0` (#2457-review,
|
||||
воспроизведено: pilot-заявка с реальными данными → ~1/20 попадает в
|
||||
транзакцию с полным телом).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from sentry_sdk.integrations.logging import ignore_logger
|
||||
from sentry_sdk.types import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Собственный сбой скраба НЕ должен становиться событием мониторинга (#2753).
|
||||
# LoggingIntegration (event_level=ERROR) превратила бы строку журнала об отказе
|
||||
# в новое событие, которое снова пойдёт через этот же обработчик; при
|
||||
# детерминированном сбое это рекурсия — защиты от неё в SDK нет (проверено:
|
||||
# 1000+ вложенных трассировок за минуту, процесс не завершается). Диагностика
|
||||
# остаётся в stdout контейнера: текст трассировки значений переменных не несёт.
|
||||
ignore_logger(__name__)
|
||||
|
||||
_REDACTED = "[REDACTED]"
|
||||
# Ключи consumer-PII (нижний регистр; сверка case-insensitive). Набор МЕРЫ
|
||||
# (client_name/client_phone/client_email/phone/email/name, #396) + company/
|
||||
# message — специфичные для PilotRequestInput (app/api/v1/pilot.py) поля
|
||||
# свободного текста (#2457-review).
|
||||
_PII_KEYS = frozenset(
|
||||
{
|
||||
"client_name",
|
||||
"client_phone",
|
||||
"client_email",
|
||||
"phone",
|
||||
"email",
|
||||
"name",
|
||||
"company",
|
||||
"message",
|
||||
}
|
||||
)
|
||||
|
||||
_SENSITIVE_PARAM_RE = re.compile(
|
||||
r"((?:api[_-]?[Kk]ey|token|access[_-]?token|secret)=)([^&\s]+)",
|
||||
re.IGNORECASE,
|
||||
|
|
@ -47,3 +102,63 @@ def scrub_sensitive_query(event: Event, _hint: dict[str, Any]) -> Event | None:
|
|||
request["url"] = _redact(request["url"])
|
||||
|
||||
return event
|
||||
|
||||
|
||||
def _scrub(obj: Any) -> None:
|
||||
"""Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place)."""
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
if isinstance(key, str) and key.lower() in _PII_KEYS:
|
||||
obj[key] = _REDACTED
|
||||
else:
|
||||
_scrub(value)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
_scrub(item)
|
||||
|
||||
|
||||
def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
|
||||
"""Redact consumer-PII (см. `_PII_KEYS`) из event (error ИЛИ transaction)
|
||||
перед отправкой в GlitchTip.
|
||||
|
||||
Обходит `request.data` / `extra` / `contexts` рекурсивно (dict/list),
|
||||
заменяет значения PII-ключей на [REDACTED] in-place. Возвращает event
|
||||
(не None) — иначе SDK дропнет отчёт целиком.
|
||||
"""
|
||||
if not isinstance(event, dict):
|
||||
return event
|
||||
request = event.get("request")
|
||||
if isinstance(request, dict):
|
||||
_scrub(request.get("data"))
|
||||
_scrub(event.get("extra"))
|
||||
_scrub(event.get("contexts"))
|
||||
return event
|
||||
|
||||
|
||||
def scrub_event(event: Event, hint: dict[str, Any]) -> Event | None:
|
||||
"""Composed `before_send` / `before_send_transaction` handler: PII-scrub +
|
||||
URL query-secret redact. Вешать ОДИНАКОВО на оба канала — см. module
|
||||
docstring (#2457-review): transaction-scope несёт `request.data` точно так
|
||||
же, как error-scope.
|
||||
|
||||
try/except — предохранитель: sentry_sdk оборачивает вызов `before_send` в
|
||||
`capture_internal_exceptions`, который при исключении внутри хендлера
|
||||
ТОЛЬКО логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что
|
||||
редактор упал, — event просто не уйдёт). Наблюдаемость важнее полноты
|
||||
покрытия редактора: лучше отправить событие в состоянии "сколько успели
|
||||
отредактировать до сбоя", чем не отправить вообще и молча остаться без
|
||||
сигнала в мониторинге.
|
||||
"""
|
||||
try:
|
||||
scrub_pii_event(event, hint)
|
||||
scrub_sensitive_query(event, hint)
|
||||
except Exception as exc:
|
||||
# Ни трассировки, ни str(exc): и то и другое способно нести значения из
|
||||
# ЕЩЁ НЕ ОЧИЩЕННОГО event — то есть страховка утекла бы ровно то, что
|
||||
# защищает (#2753). Имя класса исключения данных не несёт. Событием
|
||||
# мониторинга эта строка не станет — см. ignore_logger выше.
|
||||
logger.error(
|
||||
"sentry_scrub.scrub_event: handler failed (%s), sending event as-is",
|
||||
type(exc).__name__,
|
||||
)
|
||||
return event
|
||||
|
|
|
|||
|
|
@ -869,7 +869,7 @@ def _build_financial_cascade(doc: _DocxDocument, financial: dict[str, Any]) -> N
|
|||
["Земля", _fmt_money_signed(financial.get("land_rub"))],
|
||||
["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))],
|
||||
["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))],
|
||||
["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))],
|
||||
["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))],
|
||||
["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))],
|
||||
["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))],
|
||||
["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))],
|
||||
|
|
|
|||
|
|
@ -1338,7 +1338,7 @@ def _build_financial_cascade(financial: dict[str, Any]) -> str:
|
|||
["Земля", _fmt_money_signed(financial.get("land_rub"))],
|
||||
["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))],
|
||||
["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))],
|
||||
["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))],
|
||||
["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))],
|
||||
["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))],
|
||||
["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))],
|
||||
["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))],
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ API surface:
|
|||
- create_profile(db, payload) → WeightProfile
|
||||
- update_profile(db, user_id, profile_id, payload) → WeightProfile | None
|
||||
- delete_profile(db, user_id, profile_id) → bool
|
||||
- resolve_weights(db, user_id, profile_id) → dict[str, float]
|
||||
- resolve_weights(db, user_id, profile_id) → ResolvedWeights(weights, source)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -19,7 +19,7 @@ import json
|
|||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import text
|
||||
|
|
@ -346,13 +346,34 @@ def delete_profile(db: Any, user_id: str, profile_id: int) -> bool:
|
|||
return True
|
||||
|
||||
|
||||
def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dict[str, float]:
|
||||
"""Вернуть эффективные веса для analyze_parcel.
|
||||
class ResolvedWeights(NamedTuple):
|
||||
"""Веса + КАКОЙ источник фактически применился (#2811).
|
||||
|
||||
Лестница приоритетов ниже по построению стирает разницу между «взял, что
|
||||
просили» и «не нашёл, взял что было» — а метка в ответе /analyze строится
|
||||
именно на этой разнице. Поэтому источник возвращается вместе с весами, а не
|
||||
выводится вызывающим из своих же входных параметров. NamedTuple, а не голый
|
||||
dict: старый вызов `w = resolve_weights(...); w["school"]` падает громко,
|
||||
молча «весами» этот объект не притворится.
|
||||
"""
|
||||
|
||||
weights: dict[str, float]
|
||||
source: str # "profile" | "user_default" | "system"
|
||||
|
||||
|
||||
def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> ResolvedWeights:
|
||||
"""Вернуть эффективные веса для analyze_parcel + фактический их источник.
|
||||
|
||||
Порядок приоритетов:
|
||||
1. profile_id задан → загрузить именно этот профиль
|
||||
2. user_id задан → загрузить default-профиль пользователя
|
||||
3. Иначе → вернуть системные значения _SYSTEM_POI_WEIGHTS
|
||||
1. profile_id задан → загрузить именно этот профиль → source="profile"
|
||||
2. user_id задан → загрузить default-профиль пользователя → source="user_default"
|
||||
3. Иначе → системные значения _SYSTEM_POI_WEIGHTS → source="system"
|
||||
|
||||
Запрошенный, но НЕ применённый profile_id — не тишина: warning с
|
||||
идентификаторами (см. ниже). HTTP-статус на этом не меняем: profile_id для
|
||||
/analyze — необязательный модификатор, а не адресуемый ресурс; 404 превратил
|
||||
бы гонку «профиль удалили между списком и анализом» в отказ вместо честно
|
||||
помеченного ответа. Клиенту хватает source + requested_profile_not_found.
|
||||
"""
|
||||
if profile_id is not None and user_id is not None:
|
||||
profile = get_profile(db, user_id, profile_id)
|
||||
|
|
@ -360,13 +381,26 @@ def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dic
|
|||
logger.debug(
|
||||
"resolve_weights: user=%s profile_id=%s → custom weights", user_id, profile_id
|
||||
)
|
||||
return dict(profile.weights)
|
||||
return ResolvedWeights(dict(profile.weights), "profile")
|
||||
|
||||
resolved = ResolvedWeights(dict(_SYSTEM_POI_WEIGHTS), "system")
|
||||
if user_id is not None:
|
||||
profile = get_default_profile(db, user_id)
|
||||
if profile is not None and profile.weights:
|
||||
logger.debug("resolve_weights: user=%s → default profile weights", user_id)
|
||||
return dict(profile.weights)
|
||||
resolved = ResolvedWeights(dict(profile.weights), "user_default")
|
||||
|
||||
logger.debug("resolve_weights: returning system defaults")
|
||||
return dict(_SYSTEM_POI_WEIGHTS)
|
||||
if profile_id is not None:
|
||||
# Сюда попадаем, если запрошенный профиль не применился: owner не передан
|
||||
# (первая ветка требует ОБА аргумента), профиль чужой/удалён, либо weights
|
||||
# пустые. Раньше это был logger.debug, которого на проде нет, — и оценка
|
||||
# молча считалась не по тем весам (#2811, ранее #2788).
|
||||
logger.warning(
|
||||
"resolve_weights: запрошенный profile_id=%s (user_id=%r) НЕ применён — "
|
||||
"фактический источник весов %r",
|
||||
profile_id,
|
||||
user_id,
|
||||
resolved.source,
|
||||
)
|
||||
else:
|
||||
logger.debug("resolve_weights: источник весов %s", resolved.source)
|
||||
return resolved
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration
|
|||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||
|
||||
from app.core.config import settings
|
||||
from app.observability.sentry_scrub import scrub_sensitive_query
|
||||
from app.observability.sentry_scrub import scrub_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -23,6 +23,11 @@ logger = logging.getLogger(__name__)
|
|||
# чтобы события из тасков попадали в GlitchTip. SDK безопасен для двойного
|
||||
# вызова — повторный sentry_sdk.init() в одном процессе заменяет клиента.
|
||||
if settings.glitchtip_dsn:
|
||||
# before_send И before_send_transaction — ОБА на scrub_event (#2457-review,
|
||||
# см. app/main.py и sentry_scrub.py module docstring): до этого фикса worker
|
||||
# вообще не скрабил error-события (тут before_send не было), а
|
||||
# before_send_transaction был на голом scrub_sensitive_query (только URL) —
|
||||
# оба канала пропускали PII.
|
||||
sentry_sdk.init(
|
||||
dsn=settings.glitchtip_dsn,
|
||||
environment=settings.environment,
|
||||
|
|
@ -30,7 +35,12 @@ if settings.glitchtip_dsn:
|
|||
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
||||
profiles_sample_rate=0.0,
|
||||
send_default_pii=False,
|
||||
before_send_transaction=scrub_sensitive_query,
|
||||
# Локальные переменные кадров стека НЕ уходят в мониторинг (#2753) — см.
|
||||
# app/main.py: скраб сверяет ИМЕНА ключей, а имя переменной произвольно.
|
||||
# В воркере вектор шире: задачи держат в кадрах сырые ответы источников.
|
||||
include_local_variables=False,
|
||||
before_send=scrub_event,
|
||||
before_send_transaction=scrub_event,
|
||||
integrations=[
|
||||
CeleryIntegration(monitor_beat_tasks=True),
|
||||
SqlalchemyIntegration(),
|
||||
|
|
|
|||
146
backend/tests/_sentry_wiring_probe.py
Normal file
146
backend/tests/_sentry_wiring_probe.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""Проба проводки GlitchTip: запускается ОТДЕЛЬНЫМ процессом из test_sentry_init.py.
|
||||
|
||||
Зачем подпроцесс. `app/main.py` и `app/workers/celery_app.py` зовут
|
||||
`sentry_sdk.init()` на импорте модуля и только при непустом `GLITCHTIP_DSN`. В
|
||||
процессе pytest этот путь недостижим (модуль уже в `sys.modules`, DSN пуст), а
|
||||
если бы и был достижим — глобальный клиент SDK остался бы живым для всех
|
||||
последующих тестов. Отдельный процесс даёт настоящую инициализацию и умирает
|
||||
вместе с ней.
|
||||
|
||||
Наружу не уходит ничего: `capture_envelope` подменяется ДО первого события, а
|
||||
DSN в тесте указывает на несуществующий хост. Значения-маркеры генерируются
|
||||
случайно на каждый запуск — кадр стека несёт не только переменные, но и строки
|
||||
исходника, поэтому литерал в коде пробы сделал бы проверку вечно красной.
|
||||
|
||||
stdout — одна строка JSON: counts / scrub_handler_entries / markers / payloads
|
||||
(тело каждого канала отдельно — см. `main`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import io
|
||||
import itertools
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import sentry_sdk
|
||||
|
||||
_FAILURE_CAP = 3
|
||||
|
||||
|
||||
def _fresh(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def _leaking_event(markers: dict[str, str]) -> dict[str, Any]:
|
||||
"""Событие с ПДн в трёх местах, которые закрывает scrub_event."""
|
||||
return {
|
||||
"message": "sentry-wiring-probe",
|
||||
"level": "error",
|
||||
"request": {
|
||||
"data": {"phone": markers["phone"], "message": markers["free_text"]},
|
||||
"url": f"https://example.invalid/probe?api_key={markers['url_secret']}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main(module: str) -> int:
|
||||
importlib.import_module(module) # ← здесь отрабатывает sentry_sdk.init()
|
||||
|
||||
client = sentry_sdk.get_client()
|
||||
sent: list[str] = []
|
||||
|
||||
def _record(envelope: Any) -> None:
|
||||
buf = io.BytesIO()
|
||||
envelope.serialize_into(buf)
|
||||
sent.append(buf.getvalue().decode("utf-8", "replace"))
|
||||
|
||||
client.transport.capture_envelope = _record # type: ignore[union-attr,method-assign]
|
||||
|
||||
markers = {
|
||||
"phone": _fresh("probe-phone"),
|
||||
"free_text": _fresh("probe-free-text"),
|
||||
"url_secret": _fresh("probe-url-secret"),
|
||||
"local_var": _fresh("probe-local-var"),
|
||||
}
|
||||
|
||||
# 1. Канал ошибок (before_send).
|
||||
sentry_sdk.capture_event(_leaking_event(markers))
|
||||
after_error = len(sent)
|
||||
|
||||
# 2. Канал транзакций (before_send_transaction) — Starlette кладёт
|
||||
# request.data на transaction-scope так же, как на error-scope.
|
||||
transaction = _leaking_event(markers)
|
||||
transaction["type"] = "transaction"
|
||||
transaction["transaction"] = "sentry-wiring-probe-tx"
|
||||
transaction["contexts"] = {"trace": {"trace_id": "0" * 32, "span_id": "0" * 16}}
|
||||
transaction["start_timestamp"] = "2026-01-01T00:00:00.000000Z"
|
||||
transaction["timestamp"] = "2026-01-01T00:00:01.000000Z"
|
||||
transaction["spans"] = []
|
||||
sentry_sdk.capture_event(transaction)
|
||||
after_transaction = len(sent)
|
||||
|
||||
# 3. Локальные переменные кадра стека (include_local_variables). Имя
|
||||
# переменной произвольное — ключевой скраб такое не ловит по построению.
|
||||
def _raise_with_local() -> None:
|
||||
applicant_note = markers["local_var"] # noqa: F841 — ради кадра стека
|
||||
raise RuntimeError("sentry-wiring-probe boom")
|
||||
|
||||
try:
|
||||
_raise_with_local()
|
||||
except RuntimeError:
|
||||
sentry_sdk.capture_exception()
|
||||
after_exception = len(sent)
|
||||
|
||||
# 4. Сбой самого скраба не должен порождать ВТОРОЕ событие: иначе строка
|
||||
# журнала об отказе уходит в мониторинг через LoggingIntegration, снова
|
||||
# попадает в скраб, снова падает — рекурсия (#2753; на коде до фикса
|
||||
# проверено: не завершается, 1000+ вложенных трассировок за минуту).
|
||||
# Считаем ВХОДЫ в обработчик; после _FAILURE_CAP перестаём падать, иначе
|
||||
# проба на сломанном коде висела бы вместо того, чтобы честно покраснеть.
|
||||
from app.observability import sentry_scrub
|
||||
|
||||
original = sentry_scrub.scrub_pii_event
|
||||
entries: list[int] = []
|
||||
|
||||
def _boom(*_a: Any, **_kw: Any) -> Any:
|
||||
entries.append(1)
|
||||
if len(entries) > _FAILURE_CAP:
|
||||
return None
|
||||
raise RuntimeError("sentry-wiring-probe scrubber failure")
|
||||
|
||||
sentry_scrub.scrub_pii_event = _boom # type: ignore[assignment]
|
||||
try:
|
||||
# Без маркеров: это событие по замыслу уходит НЕОЧИЩЕННЫМ ("as-is").
|
||||
sentry_sdk.capture_event({"message": "sentry-wiring-probe-failure", "level": "error"})
|
||||
finally:
|
||||
sentry_scrub.scrub_pii_event = original # type: ignore[assignment]
|
||||
after_scrub_failure = len(sent)
|
||||
|
||||
# Тело каждого канала — отдельно: иначе утечка из одного (напр. локальные
|
||||
# переменные шага 3 несут те же маркеры, что тело запроса шага 1) красит
|
||||
# чужую проверку и мешает понять, что именно сломано.
|
||||
bounds = [0, after_error, after_transaction, after_exception, after_scrub_failure]
|
||||
names = ["error", "transaction", "exception", "scrub_failure"]
|
||||
spans = dict(zip(names, itertools.pairwise(bounds), strict=True))
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"counts": {name: end - start for name, (start, end) in spans.items()},
|
||||
"scrub_handler_entries": len(entries),
|
||||
"markers": markers,
|
||||
"payloads": {
|
||||
name: "\n".join(sent[start:end]) for name, (start, end) in spans.items()
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1]))
|
||||
|
|
@ -5,36 +5,16 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
# scrape_cadastre зависит от app.scrapers.nspd_bulk_client (PR 2/5).
|
||||
# Используем importlib.util.find_spec вместо прямого import — иначе
|
||||
# `import app.workers...` пересоздаёт `app` как Python package и
|
||||
# перебивает FastAPI instance, привязанный в строке выше → AttributeError
|
||||
# на app.dependency_overrides.
|
||||
import importlib.util
|
||||
# NB: `import app.workers...` здесь запрещён — он пересоздаёт `app` как Python
|
||||
# package и перебивает FastAPI instance, привязанный ниже → AttributeError на
|
||||
# app.dependency_overrides. Патчим воркер строкой, через patch("app.workers...").
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
_SCRAPE_CADASTRE_AVAILABLE = (
|
||||
importlib.util.find_spec("app.workers.tasks.scrape_cadastre") is not None
|
||||
)
|
||||
|
||||
requires_scrape_cadastre = pytest.mark.skipif(
|
||||
not _SCRAPE_CADASTRE_AVAILABLE,
|
||||
reason=(
|
||||
"app.scrapers.nspd_bulk_client отсутствует — нужно смержить main (PR #170). "
|
||||
"Команда: git rebase origin/main"
|
||||
),
|
||||
)
|
||||
|
||||
# Токен для тестов (не реальный)
|
||||
ADMIN_TOKEN = "test-admin-token"
|
||||
ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN}
|
||||
|
||||
|
||||
def _make_mock_db(quarters: list[str] | None = None, job_row: dict[str, Any] | None = None):
|
||||
"""Создать mock db session с преднастроенными ответами."""
|
||||
|
|
@ -113,8 +93,6 @@ def _make_sample_job_row() -> dict[str, Any]:
|
|||
# ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_scrape_cadastre
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_create_job_pilot_returns_job_id() -> None:
|
||||
"""POST /cadastre/jobs scope=pilot → job_id + targets_total."""
|
||||
quarters_50 = [f"66:41:{i:07d}" for i in range(50)]
|
||||
|
|
@ -131,7 +109,6 @@ def test_create_job_pilot_returns_job_id() -> None:
|
|||
response = client.post(
|
||||
"/api/v1/admin/cadastre/jobs",
|
||||
json={"scope": "pilot"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
|
|
@ -144,8 +121,6 @@ def test_create_job_pilot_returns_job_id() -> None:
|
|||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@requires_scrape_cadastre
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_create_job_manual_list() -> None:
|
||||
"""POST /cadastre/jobs scope=manual_list с явным списком."""
|
||||
db = _make_mock_db()
|
||||
|
|
@ -164,7 +139,6 @@ def test_create_job_manual_list() -> None:
|
|||
"scope": "manual_list",
|
||||
"quarters": ["66:41:0303161", "66:41:0303162"],
|
||||
},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
|
|
@ -174,8 +148,6 @@ def test_create_job_manual_list() -> None:
|
|||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@requires_scrape_cadastre
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_create_job_manual_list_empty_quarters_returns_400() -> None:
|
||||
"""scope=manual_list без quarters → 400."""
|
||||
db = _make_mock_db()
|
||||
|
|
@ -189,14 +161,12 @@ def test_create_job_manual_list_empty_quarters_returns_400() -> None:
|
|||
response = client.post(
|
||||
"/api/v1/admin/cadastre/jobs",
|
||||
json={"scope": "manual_list"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_list_jobs_returns_list() -> None:
|
||||
"""GET /cadastre/jobs → список jobs."""
|
||||
db = _make_mock_db(job_row=_make_sample_job_row())
|
||||
|
|
@ -213,7 +183,7 @@ def test_list_jobs_returns_list() -> None:
|
|||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/admin/cadastre/jobs", headers=ADMIN_HEADERS)
|
||||
response = client.get("/api/v1/admin/cadastre/jobs")
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert isinstance(body, list)
|
||||
|
|
@ -225,7 +195,6 @@ def test_list_jobs_returns_list() -> None:
|
|||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_get_job_not_found_returns_404() -> None:
|
||||
"""GET /cadastre/jobs/9999 → 404."""
|
||||
db = MagicMock()
|
||||
|
|
@ -239,13 +208,12 @@ def test_get_job_not_found_returns_404() -> None:
|
|||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/admin/cadastre/jobs/9999", headers=ADMIN_HEADERS)
|
||||
response = client.get("/api/v1/admin/cadastre/jobs/9999")
|
||||
assert response.status_code == 404
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_cancel_job_success() -> None:
|
||||
"""POST /cadastre/jobs/42/cancel → {cancelled: true}."""
|
||||
db = MagicMock()
|
||||
|
|
@ -262,7 +230,6 @@ def test_cancel_job_success() -> None:
|
|||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/admin/cadastre/jobs/42/cancel",
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
|
|
@ -272,7 +239,6 @@ def test_cancel_job_success() -> None:
|
|||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_cancel_job_not_found_returns_404() -> None:
|
||||
"""POST /cadastre/jobs/9999/cancel когда job не найден → 404."""
|
||||
db = MagicMock()
|
||||
|
|
@ -289,27 +255,7 @@ def test_cancel_job_not_found_returns_404() -> None:
|
|||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/admin/cadastre/jobs/9999/cancel",
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason=(
|
||||
"X-Admin-Token gate удалён в #437 (refactor(security): убрать X-Admin-Token — "
|
||||
"Caddy basic_auth + RBAC middleware достаточны). Admin endpoint'ы больше не "
|
||||
"несут verify_admin_token dependency; защита — на уровне Caddy/RBAC. В test-mode "
|
||||
"RBAC bypass'ится (CI-rehab 1/3), поэтому 401/503 здесь больше недостижим без "
|
||||
"реверта security-решения #437. Тест проверял удалённое поведение."
|
||||
)
|
||||
)
|
||||
def test_create_job_no_token_returns_401() -> None:
|
||||
"""Без X-Admin-Token → 401/503 (устарело: токен-гейт удалён в #437)."""
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/admin/cadastre/jobs",
|
||||
json={"scope": "pilot"},
|
||||
)
|
||||
assert response.status_code in (401, 503)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@
|
|||
- валидный запрос без year → scope all_years_2022_2026, task_id в ответе
|
||||
- валидный запрос с year=2026 → scope year_2026
|
||||
- year < 2022 или > 2030 → 422
|
||||
- отсутствие X-Admin-Token → 401/503
|
||||
|
||||
Авторизация здесь НЕ проверяется и никогда не проверялась: строка «отсутствие
|
||||
X-Admin-Token → 401/503» стояла в этом докстринге, но соответствующего теста в
|
||||
файле нет. Гейт `/api/v1/admin/*` — middleware `rbac_guard` (app/main.py),
|
||||
покрыт tests/test_rbac.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -16,8 +20,6 @@ from fastapi.testclient import TestClient
|
|||
|
||||
from app.main import app
|
||||
|
||||
ADMIN_TOKEN = "test-admin-token"
|
||||
ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN}
|
||||
ENDPOINT = "/api/v1/admin/scrape/ekburg-permits"
|
||||
|
||||
|
||||
|
|
@ -27,7 +29,6 @@ def _mock_task(task_id: str = "fake-task-id-123") -> MagicMock:
|
|||
return result
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_trigger_refresh_all_returns_task_id() -> None:
|
||||
"""POST без year → refresh_all queued, scope=all_years_2022_2026."""
|
||||
mock_result = _mock_task("task-all-001")
|
||||
|
|
@ -38,7 +39,7 @@ def test_trigger_refresh_all_returns_task_id() -> None:
|
|||
):
|
||||
mock_refresh_all.apply_async.return_value = mock_result
|
||||
client = TestClient(app)
|
||||
response = client.post(ENDPOINT, json={}, headers=ADMIN_HEADERS)
|
||||
response = client.post(ENDPOINT, json={})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
|
|
@ -47,7 +48,6 @@ def test_trigger_refresh_all_returns_task_id() -> None:
|
|||
assert "queued_at" in body
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_trigger_refresh_year_returns_task_id() -> None:
|
||||
"""POST year=2026 → refresh_year queued, scope=year_2026."""
|
||||
mock_result = _mock_task("task-year-002")
|
||||
|
|
@ -58,7 +58,7 @@ def test_trigger_refresh_year_returns_task_id() -> None:
|
|||
):
|
||||
mock_refresh_year.apply_async.return_value = mock_result
|
||||
client = TestClient(app)
|
||||
response = client.post(ENDPOINT, json={"year": 2026}, headers=ADMIN_HEADERS)
|
||||
response = client.post(ENDPOINT, json={"year": 2026})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
|
|
@ -67,25 +67,8 @@ def test_trigger_refresh_year_returns_task_id() -> None:
|
|||
|
||||
|
||||
@pytest.mark.parametrize("bad_year", [2021, 2031, 1999, 9999])
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_trigger_invalid_year_returns_422(bad_year: int) -> None:
|
||||
"""year вне диапазона [2022, 2030] → 422 Unprocessable Entity."""
|
||||
client = TestClient(app)
|
||||
response = client.post(ENDPOINT, json={"year": bad_year}, headers=ADMIN_HEADERS)
|
||||
response = client.post(ENDPOINT, json={"year": bad_year})
|
||||
assert response.status_code == 422, f"year={bad_year} должен возвращать 422"
|
||||
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason=(
|
||||
"X-Admin-Token gate удалён в #437 (refactor(security): убрать X-Admin-Token — "
|
||||
"Caddy basic_auth + RBAC middleware достаточны). trigger_ekburg_permits больше не "
|
||||
"несёт verify_admin_token dependency; защита — на уровне Caddy/RBAC. В test-mode "
|
||||
"RBAC bypass'ится (CI-rehab 1/3), поэтому 401/503 здесь больше недостижим без "
|
||||
"реверта security-решения #437. Тест проверял удалённое поведение."
|
||||
)
|
||||
)
|
||||
def test_trigger_no_token_returns_401_or_503() -> None:
|
||||
"""Без X-Admin-Token → 401 или 503 (устарело: токен-гейт удалён в #437)."""
|
||||
client = TestClient(app)
|
||||
response = client.post(ENDPOINT, json={})
|
||||
assert response.status_code in (401, 503), response.text
|
||||
|
|
|
|||
|
|
@ -316,3 +316,77 @@ def test_analyze_inline_weights_beats_profile_id() -> None:
|
|||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
||||
|
||||
def test_analyze_missing_profile_is_not_labelled_profile() -> None:
|
||||
"""#2811: profile_id задан, профиль НЕ найден → метка НЕ смеет быть 'profile'.
|
||||
|
||||
Три способа промахнуться мимо профиля (все три воспроизведены живым запросом
|
||||
на проде 2026-08-10): owner не передан вовсе, чужой профиль, удалённый id.
|
||||
В mock-БД профилей нет — значит применились системные веса, и ответ обязан
|
||||
это признать, а не утверждать, что считал по профилю.
|
||||
"""
|
||||
from app.core.db import get_db
|
||||
from app.services.site_finder.weight_profiles import _SYSTEM_POI_WEIGHTS
|
||||
|
||||
for qs in ("profile_id=999999", "profile_id=999999&profile_user_id=nobody"):
|
||||
db = _make_db_for_analyze() # профилей нет → get_profile/get_default_profile → None
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
_start_patches()
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?{qs}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
wp = resp.json()["weights_profile"]
|
||||
# sanity: веса и правда системные, промах реальный
|
||||
assert wp["weights_applied"]["tram_stop"] == pytest.approx(
|
||||
_SYSTEM_POI_WEIGHTS["tram_stop"]
|
||||
)
|
||||
assert wp["source"] != "profile", (
|
||||
f"?{qs}: применились системные веса, а метка source='profile' — "
|
||||
"ответ утверждает то, чего не было (#2811)"
|
||||
)
|
||||
assert wp["source"] == "system"
|
||||
# «что просили» не теряется: запрошенный id + явный признак промаха
|
||||
assert wp["profile_id"] == 999999
|
||||
assert wp["requested_profile_applied"] is False
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
||||
|
||||
def test_analyze_found_profile_keeps_label_and_flag() -> None:
|
||||
"""Обратная сторона: профиль найден → source='profile', флаг промаха False."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import app.services.site_finder.weight_profiles as wp_module
|
||||
from app.core.db import get_db
|
||||
from app.services.site_finder.weight_profiles import WeightProfile
|
||||
|
||||
profile = WeightProfile(
|
||||
id=7,
|
||||
user_id="user-1",
|
||||
profile_name="test",
|
||||
weights={"tram_stop": -0.4},
|
||||
is_default=False,
|
||||
description=None,
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
db = _make_db_for_analyze()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
_start_patches()
|
||||
original = wp_module.get_profile
|
||||
wp_module.get_profile = lambda _db, uid, pid: profile
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?profile_id=7&profile_user_id=user-1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
wp = resp.json()["weights_profile"]
|
||||
assert wp["source"] == "profile"
|
||||
assert wp["requested_profile_applied"] is True
|
||||
assert wp["weights_applied"]["tram_stop"] == pytest.approx(-0.4)
|
||||
finally:
|
||||
wp_module.get_profile = original
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ NB: RBAC-гейт (app/main.py `rbac_guard`) требует заголовок `
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -40,3 +41,70 @@ def _clear_dependency_overrides():
|
|||
app.dependency_overrides.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Бюджет пропусков (#2745) ──────────────────────────────────────────────────
|
||||
#
|
||||
# Три инцидента за одни сутки (#2722, #2729, #2740) — один класс дефекта:
|
||||
# проверка, которая тихо не исполняется, со временем перестаёт быть верной, и
|
||||
# узнают об этом ровно тогда, когда на неё понадобилось опереться. `-rs` в CI
|
||||
# делает пропуск ГРОМКИМ; этот хук делает его УЧТЁННЫМ — пропущено может быть
|
||||
# только то, что записано в `tests/skip_allowlist.txt`. Любой новый пропуск (в
|
||||
# том числе возникший сам: отвалилась зависимость, исчезла БД, кто-то дописал
|
||||
# skipif «пока починю») роняет прогон, пока его не внесут в список осознанно.
|
||||
#
|
||||
# Список — НАДмножество: покрывает и CI (где есть Postgres и native-libs), и
|
||||
# ноутбук разработчика (где их нет). Запись, чей пропуск не случился, — не
|
||||
# ошибка; ошибка — пропуск без записи.
|
||||
#
|
||||
# Сюда же попадает xfail: pytest рапортует его как skipped, поэтому xfail без
|
||||
# `strict=True` (зеленеет и когда тест внезапно ПРОШЁЛ) тоже придётся объявить.
|
||||
#
|
||||
# Чего хук НЕ видит: `--deselect` / `-k` / `-m` — они убирают тест до прогона,
|
||||
# отчёта о пропуске не возникает вовсе. Против них работает правило «никаких
|
||||
# deselect'ов в workflow» (см. .forgejo/workflows/ci-tradein.yml).
|
||||
|
||||
_SKIP_ALLOWLIST_PATH = Path(__file__).parent / "skip_allowlist.txt"
|
||||
_observed_skips: set[str] = set()
|
||||
|
||||
|
||||
def _allowed_skips() -> set[str]:
|
||||
if not _SKIP_ALLOWLIST_PATH.exists():
|
||||
return set()
|
||||
lines = _SKIP_ALLOWLIST_PATH.read_text(encoding="utf-8").splitlines()
|
||||
return {stripped for line in lines if (stripped := line.split("#", 1)[0].strip())}
|
||||
|
||||
|
||||
def _record_skip(report) -> None:
|
||||
if report.skipped:
|
||||
# nodeid без «[параметров]»: список фиксирует, какая ПРОВЕРКА может
|
||||
# отсутствовать, а не каждую её параметризацию.
|
||||
_observed_skips.add(report.nodeid.split("[", 1)[0])
|
||||
|
||||
|
||||
def pytest_runtest_logreport(report) -> None:
|
||||
_record_skip(report)
|
||||
|
||||
|
||||
def pytest_collectreport(report) -> None:
|
||||
# Модульный pytest.skip(allow_module_level=True) приходит сюда, а не в
|
||||
# logreport, и nodeid у него — путь файла.
|
||||
_record_skip(report)
|
||||
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus) -> None:
|
||||
unlisted = sorted(_observed_skips - _allowed_skips())
|
||||
if not unlisted:
|
||||
return
|
||||
print(
|
||||
f"\nНЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не "
|
||||
f"объявлена в {_SKIP_ALLOWLIST_PATH.name}:"
|
||||
)
|
||||
for nodeid in unlisted:
|
||||
print(f" - {nodeid}")
|
||||
print(
|
||||
"Почини тест либо внеси его в skip_allowlist.txt с причиной — "
|
||||
"пропуск без записи неотличим от пройденной проверки."
|
||||
)
|
||||
if exitstatus == 0:
|
||||
session.exitstatus = 1
|
||||
|
|
|
|||
|
|
@ -197,32 +197,74 @@ class TestParseBabushkinaBlock:
|
|||
assert site.krt_kind == "жилой застройки"
|
||||
|
||||
|
||||
class TestMultiSiteCountFromDump:
|
||||
"""Проверка количества площадок из реального дампа страницы."""
|
||||
# ── Многоблочная страница: два года, три площадки, повторяющийся номер ────────
|
||||
#
|
||||
# Здесь до #2778 стоял TestMultiSiteCountFromDump, читавший
|
||||
# `.playwright-mcp/ekb-docs/krt_page_raw.html`. Дампа нет ни в репозитории, ни у
|
||||
# кого-либо на диске, ни на проде: `ekb_krt_sites.raw_block` хранит не HTML, а
|
||||
# уже очищенный текст (`block_text[:4000]` в ekb_krt_registry.py:338), поэтому
|
||||
# восстановить вход парсера из БД нельзя. Оба теста не исполнялись НИГДЕ.
|
||||
#
|
||||
# Свойство, ради которого они писались, от дампа не зависит и выражается
|
||||
# страницей из блоков, которые уже лежат в этом файле:
|
||||
# • несколько <h2>-секций с годами разбираются в одном проходе;
|
||||
# • нумерация площадок на реальной странице НАЧИНАЕТСЯ ЗАНОВО В КАЖДОМ ГОДУ,
|
||||
# поэтому «2.» встречается дважды — и site_name всё равно обязан быть
|
||||
# уникальным (номер сам по себе ключом не является);
|
||||
# • блоки лежат внутри контейнера <div> — это ветка `_extract_krt_section`
|
||||
# через `find_parent`, которой одиночные блоки выше не касаются вовсе.
|
||||
#
|
||||
# ЧЕГО ЭТА ЗАМЕНА НЕ ПРОВЕРЯЕТ (осознанная потеря, зафиксирована в #2778):
|
||||
# «на живой странице ЕКБ ≥14 площадок». Это утверждение о полноте разбора
|
||||
# реального документа, синтетической страницей оно не заменяется. На проде
|
||||
# ekb_krt_sites = 15 строк, то есть по факту сейчас всё разбирается, но
|
||||
# автоматической проверки этого больше нет — она вернётся, когда в репозитории
|
||||
# появится образец страницы.
|
||||
_MULTISITE_PAGE = (
|
||||
"<div class='content'>"
|
||||
+ _BRUSNIKA_BLOCK
|
||||
+ _BABUSHKINA_BLOCK
|
||||
+ textwrap.dedent(
|
||||
"""\
|
||||
<p> <strong>2. КРТ нежилой застройки в районе ул. Краснофлотцев:</strong></p>
|
||||
<p>Площадь территории: 3,65 га;<br />\
|
||||
Градостроительный потенциал: 117 640 кв. м;<br />\
|
||||
Всего многоквартирных домов: 13.</p>
|
||||
"""
|
||||
)
|
||||
+ "</div>"
|
||||
)
|
||||
|
||||
|
||||
class TestMultiSitePage:
|
||||
"""Разбор страницы из нескольких блоков — без локального дампа."""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def dump_html(self):
|
||||
"""Загружает локальный дамп страницы.
|
||||
def sites(self):
|
||||
return parse_krt_page(_MULTISITE_PAGE)
|
||||
|
||||
Тест пропускается если файл отсутствует (CI без дампа).
|
||||
"""
|
||||
import os
|
||||
def test_all_three_sites_parsed(self, sites):
|
||||
"""Три блока в двух годовых секциях → три площадки."""
|
||||
assert len(sites) == 3, f"Ожидалось 3 площадки, получено {len(sites)}: {sites}"
|
||||
|
||||
dump_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../../../.playwright-mcp/ekb-docs/krt_page_raw.html",
|
||||
)
|
||||
if not os.path.exists(dump_path):
|
||||
pytest.skip("krt_page_raw.html dump not found — skip real-dump test")
|
||||
with open(dump_path, encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
|
||||
def test_site_count_from_dump(self, dump_html):
|
||||
"""Из дампа должно распарситься ≥ 14 площадок (реальных на странице 15)."""
|
||||
sites = parse_krt_page(dump_html)
|
||||
assert len(sites) >= 14, f"Expected ≥ 14 sites, got {len(sites)}"
|
||||
|
||||
def test_no_duplicate_site_names(self, dump_html):
|
||||
sites = parse_krt_page(dump_html)
|
||||
def test_no_duplicate_site_names(self, sites):
|
||||
"""Номер «2.» встречается дважды (2022 и 2024) — имена всё равно уникальны."""
|
||||
names = [s.site_name for s in sites]
|
||||
assert len(names) == len(set(names)), "Дубликаты site_name в результате"
|
||||
assert sum(n.startswith("2.") for n in names) == 2, names
|
||||
assert len(names) == len(set(names)), f"Дубликаты site_name: {names}"
|
||||
|
||||
def test_blocks_do_not_leak_into_each_other(self, sites):
|
||||
"""Границы блоков соблюдены: договор и застройщик остались у своей площадки.
|
||||
|
||||
Регресс, который этим ловится: если block_end съезжает, «Брусника» и
|
||||
договор 1-2/21.7-14 из первого блока прилипают ко второму и третьему.
|
||||
"""
|
||||
with_developer = [s for s in sites if s.developer_name]
|
||||
assert len(with_developer) == 1, [s.developer_name for s in sites]
|
||||
assert "Брусника" in with_developer[0].developer_name
|
||||
assert [s.contract_number for s in sites].count("1-2/21.7-14") == 1
|
||||
|
||||
def test_kinds_are_read_per_block(self, sites):
|
||||
"""Третий блок — «нежилой застройки», не должен унаследовать вид соседа."""
|
||||
kinds = sorted(s.krt_kind for s in sites if s.krt_kind)
|
||||
assert kinds == ["жилой застройки", "жилой застройки", "нежилой застройки"], kinds
|
||||
|
|
|
|||
|
|
@ -202,9 +202,7 @@ class TestParseTable11:
|
|||
# Реальная зона должна быть в результате
|
||||
assert "Зона жилой застройки" in zone_names
|
||||
# Артефакты (zone_name пуст, не is_total) отброшены
|
||||
non_total_no_zone = [
|
||||
r for r in result if not r["zone_name"] and not r.get("is_total")
|
||||
]
|
||||
non_total_no_zone = [r for r in result if not r["zone_name"] and not r.get("is_total")]
|
||||
assert non_total_no_zone == []
|
||||
# Итого-строка осталась
|
||||
assert any(r.get("is_total") for r in result)
|
||||
|
|
@ -483,15 +481,23 @@ class TestParsePptTepMocked:
|
|||
|
||||
|
||||
# ── Тест на реальном PDF-образце (skip в CI) ─────────────────────────────────
|
||||
|
||||
_REAL_PDF_PATH = Path(
|
||||
"C:/Users/user/source/repos/gendesign/.playwright-mcp/ekb-docs/ppt2018_22823_poyasnit_PPT.pdf"
|
||||
#
|
||||
# Путь repo-relative, а не абсолютный `C:/Users/user/source/repos/...`: с
|
||||
# Windows-путём условие пропуска не выполнялось НИ НА ОДНОЙ машине кроме одного
|
||||
# старого дев-бокса, т.е. эти 6 тестов были не «пропущены по среде», а мертвы.
|
||||
# Образец не в git (untracked `.playwright-mcp/`), поэтому в CI пропуск остаётся —
|
||||
# но теперь он снимается тем, что файл кладут в репо-каталог на любой ОС.
|
||||
_REAL_PDF_PATH = (
|
||||
Path(__file__).resolve().parents[4]
|
||||
/ ".playwright-mcp"
|
||||
/ "ekb-docs"
|
||||
/ "ppt2018_22823_poyasnit_PPT.pdf"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _REAL_PDF_PATH.exists(),
|
||||
reason="Реальный PDF-образец отсутствует (только для локального запуска)",
|
||||
reason=f"Реальный PDF-образец отсутствует ({_REAL_PDF_PATH}) — только локальный запуск",
|
||||
)
|
||||
class TestParsePptTepRealPdf:
|
||||
"""Интеграционные тесты на реальном образце ppt2018_22823."""
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@
|
|||
DNS-fail повторяет timeout на каждый analyze.
|
||||
3. ИЗОЛЯЦИЯ ДВУХ КЭШЕЙ: forecast-вызов не отравляет climate-кэш и наоборот (две
|
||||
раздельные таблицы внутри модуля).
|
||||
4. SINGLE-FLIGHT под конкурентностью: 16 потоков на ОДИН ключ при cold-start →
|
||||
ровно ОДИН реальный httpx-вызов (lock + check-then-fetch-then-store).
|
||||
4. ШТОРМ НА COLD-START: 16 потоков на ОДИН ключ → сеть зовётся не больше раза на
|
||||
поток, все получают одно и то же значение, и шторм заканчивается сложившимся
|
||||
кэшем. Не «ровно один вызов»: single-flight'а тут нет и он снят сознательно
|
||||
(#1370, см. сам тест).
|
||||
5. ИСТЕЧЕНИЕ TTL: подменяем `weather_cache._now`, проталкиваем время за expires_at
|
||||
→ следующий вызов идёт по сети заново (а не из устаревшего кэша).
|
||||
|
||||
|
|
@ -23,6 +25,7 @@ from __future__ import annotations
|
|||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
|
@ -242,9 +245,37 @@ class TestSeparateCachesForForecastAndClimate:
|
|||
|
||||
|
||||
class TestConcurrencySafe:
|
||||
def test_single_flight_cold_start_one_network_call(self) -> None:
|
||||
"""16 потоков на ОДИН ключ при cold-start → ровно один реальный httpx-вызов."""
|
||||
# GET имитирует медленный ответ, чтобы потоки реально гонялись за один lock.
|
||||
def test_cold_start_storm_bounded_and_cache_converges(self) -> None:
|
||||
"""16 потоков на ОДИН ключ при cold-start: сеть зовут не больше раза на поток,
|
||||
все получают одно и то же значение, и после шторма кэш отвечает без сети.
|
||||
|
||||
ЗДЕСЬ СТОЯЛО `get_call_count == 1` («single-flight под lock'ом»), и это
|
||||
было требование, которого код НЕ выполняет и выполнять не собирается:
|
||||
сетевой вызов вынесен ЗА lock сознательно (#1370 — иначе все analyze
|
||||
сериализуются на время httpx-вызова даже для разных координат), а рядом с
|
||||
ним написано, что cold-start на один ключ «может породить несколько
|
||||
параллельных запросов… приемлемо». Тест зеленел не потому, что защита
|
||||
работает, а потому что при GIL первый поток обычно успевал сложить
|
||||
результат раньше остальных.
|
||||
|
||||
Замер 2026-08-07, 200 штормов подряд: при дефолтном
|
||||
`sys.getswitchinterval()` 199 раз вышел 1 вызов и один раз 2 — те самые
|
||||
~0.5%, которыми гейт красил ЧУЖИЕ PR-ы (#2781: «ожидался 1 сетевой вызов,
|
||||
было 2» в диффе про парсер КРТ). При `setswitchinterval(1e-6)`, когда
|
||||
потоки реально чередуются, больше одного вызова дали 197 штормов из 200,
|
||||
и в 173 из них вызовов было все 16. То есть утверждение ложно почти
|
||||
всегда, когда гонка вообще случается, — чинить надо было тест.
|
||||
|
||||
Менять КОД (per-key lock ради настоящего single-flight) сознательно НЕ
|
||||
стали: поведение объявлено приемлемым в #1370 с обоснованием, лишние
|
||||
запросы бывают только на cold-start одного ключа и они идемпотентны.
|
||||
Понадобится — это отдельная задача с отдельным обоснованием, а не
|
||||
побочный эффект правки теста.
|
||||
|
||||
`time.sleep` в ответе делает гонку НЕслучайной: все 16 успевают пройти
|
||||
промах кэша до первой записи. Так тест мерит худший случай той самой
|
||||
уступки, а не везение планировщика.
|
||||
"""
|
||||
start_barrier = threading.Barrier(16)
|
||||
get_call_count = 0
|
||||
get_lock = threading.Lock()
|
||||
|
|
@ -253,8 +284,7 @@ class TestConcurrencySafe:
|
|||
nonlocal get_call_count
|
||||
with get_lock:
|
||||
get_call_count += 1
|
||||
# Микро-задержка — окно для других потоков добраться до lock'а.
|
||||
# Не делаем sleep большим, чтобы тест не висел.
|
||||
time.sleep(0.05) # окно, в котором остальные потоки видят промах
|
||||
return _make_httpx_response(_make_forecast_response())
|
||||
|
||||
client_ctx = MagicMock()
|
||||
|
|
@ -276,11 +306,27 @@ class TestConcurrencySafe:
|
|||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
storm_calls = get_call_count
|
||||
# Шторм закончился — кэш обязан отвечать сам. Патч ещё активен, так что
|
||||
# поход в сеть был бы виден счётчиком, а не отказом коннекта.
|
||||
after_storm = weather_cache.get_weather_cached(56.84, 60.59)
|
||||
|
||||
assert len(results) == 16
|
||||
assert all(r is not None for r in results)
|
||||
# Single-flight под lock'ом + check-then-fetch — РОВНО один реальный вызов.
|
||||
assert get_call_count == 1, f"ожидался 1 сетевой вызов, было {get_call_count}"
|
||||
assert results[0] is not None
|
||||
assert all(r == results[0] for r in results), "потоки увидели РАЗНЫЕ значения"
|
||||
# Потолок — число участников: в сеть идут только промахнувшиеся, по разу
|
||||
# каждый. Больше — значит кто-то фетчит повторно (retry-петля, потерянная
|
||||
# запись в кэш); меньше единицы невозможно, кэш был пуст.
|
||||
assert 1 <= storm_calls <= 16, f"сетевых вызовов {storm_calls} при 16 участниках"
|
||||
# Ключ ОДИН на всех (last-write wins), и цена шторма платится один раз:
|
||||
# следующий вызов идёт из кэша. Это и есть то, что #1370 обещает взамен
|
||||
# снятого single-flight — без этого уступка превращается в дыру.
|
||||
assert list(weather_cache._FORECAST_CACHE) == [weather_cache._round_key(56.84, 60.59)]
|
||||
assert after_storm == results[0]
|
||||
assert get_call_count == storm_calls, (
|
||||
f"после шторма кэш обязан отвечать без сети, а вызовов стало "
|
||||
f"{get_call_count} против {storm_calls}"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
97
backend/tests/skip_allowlist.txt
Normal file
97
backend/tests/skip_allowlist.txt
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# Объявленные пропуски сьюта backend/.
|
||||
#
|
||||
# Пропуск, которого здесь нет, роняет прогон (хук в tests/conftest.py). Смысл: не
|
||||
# запретить пропуски, а запретить НЕЗАМЕТНЫЕ. Проверка, которая тихо перестала
|
||||
# исполняться, со временем расходится с кодом, и узнают об этом ровно тогда,
|
||||
# когда на неё понадобилось опереться (#2722, #2729, #2740).
|
||||
#
|
||||
# Формат: один nodeid в строке, без «[параметров]»; `#` — комментарий.
|
||||
# Прежде чем добавить строку, ответь: почему эту проверку нельзя выполнить ЗДЕСЬ
|
||||
# и где она выполняется вместо этого. Нет ответа — это не пропуск, а дыра.
|
||||
#
|
||||
# Список — НАДмножество сред: на CI (ubuntu + libpango + postgres-сервис) часть
|
||||
# записей не срабатывает, на ноутбуке без них — срабатывает. Запись, чей пропуск
|
||||
# не случился, безвредна; пропуск без записи — нет.
|
||||
|
||||
# ── WeasyPrint: нужны native-либы (Pango/cairo/GObject) ───────────────────────
|
||||
# В CI ЭТИ ТЕСТЫ ИДУТ: ci.yml ставит libcairo2/libpango-1.0-0/libpangoft2-1.0-0.
|
||||
# Записи нужны только для macOS-dev, где libgobject-2.0-0 нет.
|
||||
tests/api/v1/test_parcels_forecast.py::test_export_forecast_pdf_returns_pdf
|
||||
tests/services/exporters/test_report_pdf.py::TestConfidenceSectionParity::test_dict_shaped_factor_produces_valid_pdf
|
||||
tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_accepts_as_dict_input
|
||||
tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_returns_non_trivial_length
|
||||
tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_returns_pdf_magic_bytes
|
||||
tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_empty_report_still_valid_pdf
|
||||
tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_garbage_input_does_not_crash
|
||||
tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_partial_report_some_sections
|
||||
tests/services/generative/test_exporters.py::test_pdf_export_produces_pdf_bytes
|
||||
tests/test_layout_tz_pdf.py
|
||||
|
||||
# ── phantom-column gate: нужна КОПИЯ ПРОДОВОЙ схемы ───────────────────────────
|
||||
# Смысл этих тестов — поймать колонку, которой нет в реальной БД, поэтому пустой
|
||||
# сервис-контейнер их не заменяет: против пустой схемы они дали бы красноту, не
|
||||
# относящуюся к делу. Условие: TEST_DATABASE_URL на SSH-туннель к проду
|
||||
# (`ssh -N gendesign` → localhost:15432), см. tests/integration/conftest.py.
|
||||
# ЗАПУСКАТЬ ВРУЧНУЮ после правок SQL-запросов в app/services/**.
|
||||
tests/integration/test_analyze_parcels_sql.py::TestIrdOverlapSql::test_explain_ird_overlap
|
||||
tests/integration/test_analyze_parcels_sql.py::TestNeighborsSummarySql::test_explain_neighbors_summary
|
||||
tests/integration/test_phantom_columns.py::TestCadGeoTables::test_parcel_centroid_query
|
||||
tests/integration/test_phantom_columns.py::TestDomrfKnFlats::test_avg_price_query
|
||||
tests/integration/test_phantom_columns.py::TestDomrfKnFlats::test_supply_batch_query
|
||||
tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_competitors_full_cte_query
|
||||
tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_competitors_radius_query
|
||||
tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_velocity_competitor_query_columns
|
||||
tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_permits_rns_columns
|
||||
tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_permits_rve_columns
|
||||
tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_recent_permits_query
|
||||
tests/integration/test_phantom_columns.py::TestMvLayoutVelocity::test_velocity_by_room_bucket
|
||||
tests/integration/test_phantom_columns.py::TestObjectiveTables::test_objective_corpus_room_month_columns
|
||||
tests/integration/test_phantom_columns.py::TestObjectiveTables::test_objective_mapping_columns
|
||||
tests/integration/test_phantom_columns.py::TestObjectiveTables::test_sold_count_query
|
||||
|
||||
# ── SQL-логика на живой Postgres ──────────────────────────────────────────────
|
||||
# В CI ЭТИ ТЕСТЫ ИДУТ с #2745 (postgres-сервис в ci.yml); до него не бежали ни
|
||||
# разу с момента написания. Схема не нужна — строят себе временные таблицы.
|
||||
# Локально: docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=... postgres:16
|
||||
tests/sql/test_ddu_price_indicator.py::test_basis_and_previous_index
|
||||
tests/sql/test_ddu_price_indicator.py::test_packaged_deal_bucketed_by_per_unit_area
|
||||
tests/sql/test_ddu_price_indicator.py::test_prev_period_value_skips_filtered_quarter
|
||||
tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_hand_computed_weighted_average
|
||||
tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_no_zero_months_weighted_equals_naive
|
||||
tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_nullif_prevents_division_by_zero
|
||||
tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_weighted_differs_from_naive_sparse_project
|
||||
tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_zero_deal_months_skew_naive_avg
|
||||
tests/sql/test_velocity_alerts.py::test_lookback_anchors_to_latest_data_month
|
||||
tests/sql/test_velocity_alerts.py::test_sharp_drop_is_flagged
|
||||
|
||||
# ── Живая сеть NSPD (nspd.gov.ru) ─────────────────────────────────────────────
|
||||
# Ходят в внешний сервис: в гейте это флейк и зависимость от чужого аптайма.
|
||||
# Запуск вручную: uv run pytest tests/scrapers/ -m slow / -m integration -s
|
||||
tests/scrapers/test_nspd_bulk_client.py::test_list_objects_in_building_real
|
||||
tests/scrapers/test_nspd_bulk_client.py::test_search_by_quarter_empty
|
||||
tests/scrapers/test_nspd_bulk_client.py::test_search_by_quarter_real_ekb
|
||||
tests/scrapers/test_nspd_bulk_client.py::test_wms_feature_info_real
|
||||
tests/scrapers/test_nspd_grid_walk.py::test_live_nspd_grid_walk_skipped
|
||||
|
||||
# ── Образец, которого нет в git ───────────────────────────────────────────────
|
||||
# ДОЛГ, а не норма. Было 8 записей; 2 из них (test_ekb_krt_registry.py::
|
||||
# TestMultiSiteCountFromDump) сняты в #2778: дампа `krt_page_raw.html` не
|
||||
# оказалось НИГДЕ — ни в репозитории, ни на дисках, ни на проде (в БД лежит
|
||||
# `raw_block` = уже очищенный текст, вход парсера из него не восстановить), —
|
||||
# зато свойство, которое они проверяли (несколько блоков на странице, уникальные
|
||||
# site_name), выразимо без дампа и теперь проверяется на многоблочной странице,
|
||||
# собранной из блоков, уже лежащих в том же тест-файле.
|
||||
#
|
||||
# Оставшиеся 6 так не лечатся: они проверяют извлечение таблиц из НАСТОЯЩЕГО PDF
|
||||
# через pdfplumber, соседний класс TestParsePptTep уже мокает pdfplumber целиком.
|
||||
# Нужен файл `ppt2018_22823_poyasnit_PPT.pdf` (или рабочий URL на ГИСОГД ЕКБ) —
|
||||
# запрошен у Anton в #2778, там же список того, что оживёт. Причин не коммитить
|
||||
# нет: официальный градостроительный документ, без персональных данных.
|
||||
# После получения — положить в tests/fixtures/, поправить `_REAL_PDF_PATH`,
|
||||
# убрать эти шесть строк.
|
||||
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_area
|
||||
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_rows
|
||||
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_raw_tables_not_empty
|
||||
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_tep_has_rows
|
||||
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_zone_balance_has_itogo
|
||||
tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_zone_balance_has_rows
|
||||
|
|
@ -6,9 +6,15 @@
|
|||
- GET /{id} → 200 / 404
|
||||
- PUT /{id} → 200 / 404
|
||||
- DELETE /{id} → 204 / 404
|
||||
- 401 при отсутствии X-Admin-Token
|
||||
- 422 при невалидных weights (неизвестная категория, вес вне диапазона)
|
||||
|
||||
Авторизация здесь НЕ проверяется и никогда не проверялась: строка «401 при
|
||||
отсутствии X-Admin-Token» стояла в этом докстринге, но соответствующего теста в
|
||||
файле нет — заголовок просто отправлялся во все запросы и никем не читался
|
||||
(app-level токен снят в PR #437). Гейт `/api/v1/admin/*` живёт в middleware
|
||||
`rbac_guard` (app/main.py, `_ADMIN_API_RE` → 403 для role != admin) и покрыт
|
||||
tests/test_rbac.py.
|
||||
|
||||
Mock-based: get_db переопределяется через dependency override.
|
||||
"""
|
||||
|
||||
|
|
@ -24,9 +30,6 @@ from app.core.db import get_db
|
|||
from app.main import app
|
||||
from app.services.site_finder.weight_profiles import WeightProfile
|
||||
|
||||
_ADMIN_TOKEN = "test-admin-token"
|
||||
_HEADERS = {"X-Admin-Token": _ADMIN_TOKEN}
|
||||
|
||||
_NOW = datetime.now(UTC)
|
||||
|
||||
|
||||
|
|
@ -50,9 +53,7 @@ def _make_profile(
|
|||
|
||||
|
||||
@pytest.fixture()
|
||||
def client_with_token(monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
"""TestClient с переопределённым SCRAPE_ADMIN_TOKEN."""
|
||||
monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN)
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
|
|
@ -76,7 +77,7 @@ def _clear_overrides():
|
|||
# ── GET list ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_list_empty(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GET ?user_id= → 200 + пустой список."""
|
||||
mock = MagicMock()
|
||||
_override_db(mock)
|
||||
|
|
@ -85,10 +86,9 @@ def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPat
|
|||
"app.api.v1.admin_weight_profiles.list_profiles",
|
||||
lambda db, user_id: [],
|
||||
)
|
||||
r = client_with_token.get(
|
||||
r = client.get(
|
||||
"/api/v1/admin/site-finder/weight-profiles",
|
||||
params={"user_id": "user-x"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == []
|
||||
|
|
@ -96,9 +96,7 @@ def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPat
|
|||
_clear_overrides()
|
||||
|
||||
|
||||
def test_list_returns_profiles(
|
||||
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def test_list_returns_profiles(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GET ?user_id= → 200 + список профилей."""
|
||||
profiles = [_make_profile(1, is_default=True), _make_profile(2, profile_name="B")]
|
||||
mock = MagicMock()
|
||||
|
|
@ -108,10 +106,9 @@ def test_list_returns_profiles(
|
|||
"app.api.v1.admin_weight_profiles.list_profiles",
|
||||
lambda db, user_id: profiles,
|
||||
)
|
||||
r = client_with_token.get(
|
||||
r = client.get(
|
||||
"/api/v1/admin/site-finder/weight-profiles",
|
||||
params={"user_id": "user-1"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
|
|
@ -124,7 +121,7 @@ def test_list_returns_profiles(
|
|||
# ── POST create ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_create_then_get(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""POST создаёт профиль, возвращает его со статусом 201."""
|
||||
created = _make_profile(42, profile_name="Семейный", weights={"school": 2.0, "park": 1.5})
|
||||
mock = MagicMock()
|
||||
|
|
@ -134,7 +131,7 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk
|
|||
"app.api.v1.admin_weight_profiles.create_profile",
|
||||
lambda db, payload: created,
|
||||
)
|
||||
r = client_with_token.post(
|
||||
r = client.post(
|
||||
"/api/v1/admin/site-finder/weight-profiles",
|
||||
json={
|
||||
"user_id": "user-1",
|
||||
|
|
@ -142,7 +139,6 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk
|
|||
"weights": {"school": 2.0, "park": 1.5},
|
||||
"is_default": False,
|
||||
},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
|
|
@ -153,30 +149,28 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk
|
|||
_clear_overrides()
|
||||
|
||||
|
||||
def test_create_validation_unknown_category(client_with_token: TestClient) -> None:
|
||||
def test_create_validation_unknown_category(client: TestClient) -> None:
|
||||
"""POST с неизвестной POI-категорией → 422 (Pydantic validation)."""
|
||||
r = client_with_token.post(
|
||||
r = client.post(
|
||||
"/api/v1/admin/site-finder/weight-profiles",
|
||||
json={
|
||||
"user_id": "user-1",
|
||||
"profile_name": "Bad",
|
||||
"weights": {"supermarket": 1.0}, # не в ALLOWED_CATEGORIES
|
||||
},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) -> None:
|
||||
def test_create_validation_weight_out_of_bounds(client: TestClient) -> None:
|
||||
"""POST с весом вне [-2, 3] → 422."""
|
||||
r = client_with_token.post(
|
||||
r = client.post(
|
||||
"/api/v1/admin/site-finder/weight-profiles",
|
||||
json={
|
||||
"user_id": "user-1",
|
||||
"profile_name": "Bad",
|
||||
"weights": {"school": 99.0},
|
||||
},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
|
@ -184,7 +178,7 @@ def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) -
|
|||
# ── GET one ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_get_profile_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GET /{id}?user_id= → 200."""
|
||||
profile = _make_profile(7)
|
||||
mock = MagicMock()
|
||||
|
|
@ -194,10 +188,9 @@ def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.Mo
|
|||
"app.api.v1.admin_weight_profiles.get_profile",
|
||||
lambda db, user_id, profile_id: profile,
|
||||
)
|
||||
r = client_with_token.get(
|
||||
r = client.get(
|
||||
"/api/v1/admin/site-finder/weight-profiles/7",
|
||||
params={"user_id": "user-1"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["id"] == 7
|
||||
|
|
@ -205,9 +198,7 @@ def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.Mo
|
|||
_clear_overrides()
|
||||
|
||||
|
||||
def test_get_profile_not_found(
|
||||
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def test_get_profile_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GET /{id} несуществующего профиля → 404."""
|
||||
mock = MagicMock()
|
||||
_override_db(mock)
|
||||
|
|
@ -216,10 +207,9 @@ def test_get_profile_not_found(
|
|||
"app.api.v1.admin_weight_profiles.get_profile",
|
||||
lambda db, user_id, profile_id: None,
|
||||
)
|
||||
r = client_with_token.get(
|
||||
r = client.get(
|
||||
"/api/v1/admin/site-finder/weight-profiles/999",
|
||||
params={"user_id": "user-1"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 404
|
||||
finally:
|
||||
|
|
@ -229,7 +219,7 @@ def test_get_profile_not_found(
|
|||
# ── PUT update ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_update_profile(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""PUT /{id} → 200 + обновлённый профиль."""
|
||||
updated = _make_profile(3, profile_name="Обновлённый")
|
||||
mock = MagicMock()
|
||||
|
|
@ -239,11 +229,10 @@ def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.Monke
|
|||
"app.api.v1.admin_weight_profiles.update_profile",
|
||||
lambda db, user_id, profile_id, payload: updated,
|
||||
)
|
||||
r = client_with_token.put(
|
||||
r = client.put(
|
||||
"/api/v1/admin/site-finder/weight-profiles/3",
|
||||
params={"user_id": "user-1"},
|
||||
json={"profile_name": "Обновлённый"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["profile_name"] == "Обновлённый"
|
||||
|
|
@ -251,9 +240,7 @@ def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.Monke
|
|||
_clear_overrides()
|
||||
|
||||
|
||||
def test_update_profile_not_found(
|
||||
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def test_update_profile_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""PUT /{id} несуществующего → 404."""
|
||||
mock = MagicMock()
|
||||
_override_db(mock)
|
||||
|
|
@ -262,11 +249,10 @@ def test_update_profile_not_found(
|
|||
"app.api.v1.admin_weight_profiles.update_profile",
|
||||
lambda db, user_id, profile_id, payload: None,
|
||||
)
|
||||
r = client_with_token.put(
|
||||
r = client.put(
|
||||
"/api/v1/admin/site-finder/weight-profiles/999",
|
||||
params={"user_id": "user-1"},
|
||||
json={"profile_name": "X"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 404
|
||||
finally:
|
||||
|
|
@ -276,7 +262,7 @@ def test_update_profile_not_found(
|
|||
# ── DELETE ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_delete_success(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""DELETE /{id} → 204."""
|
||||
mock = MagicMock()
|
||||
_override_db(mock)
|
||||
|
|
@ -285,17 +271,16 @@ def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.Monke
|
|||
"app.api.v1.admin_weight_profiles.delete_profile",
|
||||
lambda db, user_id, profile_id: True,
|
||||
)
|
||||
r = client_with_token.delete(
|
||||
r = client.delete(
|
||||
"/api/v1/admin/site-finder/weight-profiles/5",
|
||||
params={"user_id": "user-1"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 204
|
||||
finally:
|
||||
_clear_overrides()
|
||||
|
||||
|
||||
def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_delete_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""DELETE /{id} несуществующего → 404."""
|
||||
mock = MagicMock()
|
||||
_override_db(mock)
|
||||
|
|
@ -304,10 +289,9 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon
|
|||
"app.api.v1.admin_weight_profiles.delete_profile",
|
||||
lambda db, user_id, profile_id: False,
|
||||
)
|
||||
r = client_with_token.delete(
|
||||
r = client.delete(
|
||||
"/api/v1/admin/site-finder/weight-profiles/999",
|
||||
params={"user_id": "user-1"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 404
|
||||
finally:
|
||||
|
|
@ -318,7 +302,7 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon
|
|||
|
||||
|
||||
def test_list_include_system_calls_with_system(
|
||||
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""GET ?include_system=true вызывает list_profiles_with_system, возвращает presets."""
|
||||
system_profile = _make_profile(
|
||||
|
|
@ -332,10 +316,9 @@ def test_list_include_system_calls_with_system(
|
|||
"app.api.v1.admin_weight_profiles.list_profiles_with_system",
|
||||
lambda db, user_id: [user_profile, system_profile],
|
||||
)
|
||||
r = client_with_token.get(
|
||||
r = client.get(
|
||||
"/api/v1/admin/site-finder/weight-profiles",
|
||||
params={"user_id": "user-1", "include_system": "true"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
|
|
@ -348,7 +331,7 @@ def test_list_include_system_calls_with_system(
|
|||
|
||||
|
||||
def test_list_without_include_system_does_not_call_with_system(
|
||||
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""GET без include_system → list_profiles (только пользовательские профили)."""
|
||||
user_profile = _make_profile(1, user_id="user-1")
|
||||
|
|
@ -364,10 +347,9 @@ def test_list_without_include_system_does_not_call_with_system(
|
|||
"app.api.v1.admin_weight_profiles.list_profiles_with_system",
|
||||
lambda db, user_id: called_with_system.append(True) or [],
|
||||
)
|
||||
r = client_with_token.get(
|
||||
r = client.get(
|
||||
"/api/v1/admin/site-finder/weight-profiles",
|
||||
params={"user_id": "user-1"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()) == 1
|
||||
|
|
@ -399,41 +381,3 @@ def test_list_profiles_with_system_service(monkeypatch: pytest.MonkeyPatch) -> N
|
|||
assert len(captured_params) == 1
|
||||
assert captured_params[0]["system_user_id"] == SYSTEM_USER_ID
|
||||
assert captured_params[0]["user_id"] == "user-test"
|
||||
|
||||
|
||||
# ── Auth ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_TOKEN_REMOVED_REASON = (
|
||||
"App-level X-Admin-Token header удалён 2026-05-23 (см. docstring "
|
||||
"app/api/v1/admin_weight_profiles.py: Caddy basic_auth PR #426 + RBAC достаточны, "
|
||||
"двойная auth избыточна). Endpoint больше не несёт verify_admin_token dependency. "
|
||||
"В test-mode RBAC bypass'ится (CI-rehab 1/3) → 401 здесь недостижим без реверта "
|
||||
"security-решения. Тест проверял удалённое поведение."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skip(reason=_TOKEN_REMOVED_REASON)
|
||||
def test_unauthorized_no_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Запрос без X-Admin-Token → 401 (устарело: токен-гейт удалён 2026-05-23)."""
|
||||
monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN)
|
||||
client = TestClient(app)
|
||||
r = client.get(
|
||||
"/api/v1/admin/site-finder/weight-profiles",
|
||||
params={"user_id": "user-1"},
|
||||
# без headers — нет X-Admin-Token
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.skip(reason=_TOKEN_REMOVED_REASON)
|
||||
def test_unauthorized_wrong_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Неверный X-Admin-Token → 401 (устарело: токен-гейт удалён 2026-05-23)."""
|
||||
monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN)
|
||||
client = TestClient(app)
|
||||
r = client.get(
|
||||
"/api/v1/admin/site-finder/weight-profiles",
|
||||
params={"user_id": "user-1"},
|
||||
headers={"X-Admin-Token": "wrong-token"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
|
|
|||
|
|
@ -2,14 +2,27 @@
|
|||
|
||||
Проверяем что init-блок в main.py / celery_app.py вызывает sentry_sdk.init()
|
||||
только при непустом GLITCHTIP_DSN, что release-fallback работает корректно,
|
||||
и что scrub_sensitive_query redact-ит api keys из URL spans.
|
||||
что scrub_sensitive_query redact-ит api keys из URL spans, что scrub_pii_event
|
||||
redact-ит consumer-PII (client_name/client_phone/client_email/phone/email/name/
|
||||
company/message) из request.data/extra/contexts (#2457-review), и — в конце
|
||||
файла — что до транспорта не доезжают ни ПДн тела запроса, ни значения
|
||||
локальных переменных кадра стека, ни второе событие о сбое самого скраба
|
||||
(#2753, поведение через подставной транспорт вместо поиска подстроки).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import sentry_sdk
|
||||
|
||||
_BACKEND_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_sdk_imports_without_error() -> None:
|
||||
"""Все интеграции импортируются без ModuleNotFoundError."""
|
||||
|
|
@ -156,3 +169,331 @@ def test_scrub_handles_missing_spans() -> None:
|
|||
event: dict = {"request": {"url": "https://example.com"}}
|
||||
result = scrub_sensitive_query(event, {})
|
||||
assert result["request"]["url"] == "https://example.com"
|
||||
|
||||
|
||||
# ── scrub_pii_event (портировано из tradein-mvp, #396 / аудит-фикс) ────────────
|
||||
#
|
||||
# send_default_pii=False в sentry_sdk.init НЕ закрывает consumer-PII, попадающий
|
||||
# в request.data / extra / contexts (лиды pilot.py, chat.py) — это user-data,
|
||||
# а не PII-заголовки/cookies, которые режет сам флаг (проверено на sentry-sdk
|
||||
# 2.58). scrub_pii_event закрывает этот вектор ключ-based редактором.
|
||||
|
||||
|
||||
def test_scrub_pii_redacts_request_data() -> None:
|
||||
"""scrub_pii_event заменяет client_name/phone/email в request.data."""
|
||||
from app.observability.sentry_scrub import scrub_pii_event
|
||||
|
||||
event: dict = {
|
||||
"request": {
|
||||
"data": {
|
||||
"client_name": "Иван Иванов",
|
||||
"phone": "+79991234567",
|
||||
"email": "ivan@example.com",
|
||||
"address": "Екатеринбург, ул. Ленина 1",
|
||||
}
|
||||
}
|
||||
}
|
||||
result = scrub_pii_event(event, {})
|
||||
data = result["request"]["data"]
|
||||
assert data["client_name"] == "[REDACTED]"
|
||||
assert data["phone"] == "[REDACTED]"
|
||||
assert data["email"] == "[REDACTED]"
|
||||
# non-PII поле не трогаем
|
||||
assert data["address"] == "Екатеринбург, ул. Ленина 1"
|
||||
|
||||
|
||||
def test_scrub_pii_redacts_pilot_request_company_and_message() -> None:
|
||||
"""scrub_pii_event заменяет company/message — свободный текст
|
||||
PilotRequestInput (app/api/v1/pilot.py), куда чаще всего прилетают
|
||||
телефоны/имена/адреса, а не только фиксированные name/phone/email
|
||||
(#2457-review)."""
|
||||
from app.observability.sentry_scrub import scrub_pii_event
|
||||
|
||||
event: dict = {
|
||||
"request": {
|
||||
"data": {
|
||||
"company": "ООО Ромашка",
|
||||
"message": "Меня зовут Иван, звоните на +79991234567",
|
||||
"source": "landing",
|
||||
}
|
||||
}
|
||||
}
|
||||
result = scrub_pii_event(event, {})
|
||||
data = result["request"]["data"]
|
||||
assert data["company"] == "[REDACTED]"
|
||||
assert data["message"] == "[REDACTED]"
|
||||
# non-PII поле не трогаем
|
||||
assert data["source"] == "landing"
|
||||
|
||||
|
||||
def test_scrub_pii_redacts_client_prefixed_keys() -> None:
|
||||
"""Полный набор ключей МЕРЫ (client_name/client_phone/client_email, #396) —
|
||||
Птица их сегодня не использует, но одинаковый механизм на оба продукта
|
||||
проще сопровождать (#2457-review)."""
|
||||
from app.observability.sentry_scrub import scrub_pii_event
|
||||
|
||||
event: dict = {
|
||||
"extra": {
|
||||
"client_name": "Иван",
|
||||
"client_phone": "+79991234567",
|
||||
"client_email": "ivan@example.com",
|
||||
}
|
||||
}
|
||||
result = scrub_pii_event(event, {})
|
||||
extra = result["extra"]
|
||||
assert extra["client_name"] == "[REDACTED]"
|
||||
assert extra["client_phone"] == "[REDACTED]"
|
||||
assert extra["client_email"] == "[REDACTED]"
|
||||
|
||||
|
||||
def test_scrub_pii_redacts_extra() -> None:
|
||||
"""scrub_pii_event заменяет PII-ключи в extra, не трогая остальное."""
|
||||
from app.observability.sentry_scrub import scrub_pii_event
|
||||
|
||||
event: dict = {
|
||||
"extra": {
|
||||
"phone": "+79990000000",
|
||||
"email": "x@y.ru",
|
||||
"name": "Пётр",
|
||||
"lead_id": 42,
|
||||
}
|
||||
}
|
||||
result = scrub_pii_event(event, {})
|
||||
extra = result["extra"]
|
||||
assert extra["phone"] == "[REDACTED]"
|
||||
assert extra["email"] == "[REDACTED]"
|
||||
assert extra["name"] == "[REDACTED]"
|
||||
assert extra["lead_id"] == 42
|
||||
|
||||
|
||||
def test_scrub_pii_is_case_insensitive() -> None:
|
||||
"""Сверка PII-ключей case-insensitive."""
|
||||
from app.observability.sentry_scrub import scrub_pii_event
|
||||
|
||||
event: dict = {"extra": {"Client_Name": "Анна", "PHONE": "+7900"}}
|
||||
result = scrub_pii_event(event, {})
|
||||
assert result["extra"]["Client_Name"] == "[REDACTED]"
|
||||
assert result["extra"]["PHONE"] == "[REDACTED]"
|
||||
|
||||
|
||||
def test_scrub_pii_redacts_nested_contexts() -> None:
|
||||
"""scrub_pii_event проходит вложенные dict в contexts, не трогая non-PII."""
|
||||
from app.observability.sentry_scrub import scrub_pii_event
|
||||
|
||||
event: dict = {"contexts": {"trace": {"op": "http"}, "lead": {"email": "z@z.ru"}}}
|
||||
result = scrub_pii_event(event, {})
|
||||
assert result["contexts"]["lead"]["email"] == "[REDACTED]"
|
||||
assert result["contexts"]["trace"]["op"] == "http"
|
||||
|
||||
|
||||
def test_scrub_pii_leaves_non_pii_untouched() -> None:
|
||||
"""scrub_pii_event не трогает event без PII-ключей."""
|
||||
from app.observability.sentry_scrub import scrub_pii_event
|
||||
|
||||
event: dict = {
|
||||
"request": {"data": {"region": "66", "area_sqm": 50}},
|
||||
"extra": {"job": "geocode"},
|
||||
"level": "error",
|
||||
}
|
||||
result = scrub_pii_event(event, {})
|
||||
assert result["request"]["data"] == {"region": "66", "area_sqm": 50}
|
||||
assert result["extra"] == {"job": "geocode"}
|
||||
assert result["level"] == "error"
|
||||
|
||||
|
||||
def test_scrub_pii_handles_missing_sections() -> None:
|
||||
"""scrub_pii_event не падает на пустом event."""
|
||||
from app.observability.sentry_scrub import scrub_pii_event
|
||||
|
||||
result = scrub_pii_event({}, {})
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_scrub_pii_handles_none_and_non_dict_sections() -> None:
|
||||
"""scrub_pii_event не бросает исключений на None/не-dict секциях."""
|
||||
from app.observability.sentry_scrub import scrub_pii_event
|
||||
|
||||
event: dict = {"request": None, "extra": None, "contexts": "not-a-dict"}
|
||||
result = scrub_pii_event(event, {})
|
||||
assert result is event
|
||||
|
||||
|
||||
def test_scrub_pii_returns_event_not_none() -> None:
|
||||
"""before_send должен вернуть event (не None) — иначе SDK дропнет отчёт."""
|
||||
from app.observability.sentry_scrub import scrub_pii_event
|
||||
|
||||
event: dict = {"request": {"data": {"client_name": "X"}}}
|
||||
result = scrub_pii_event(event, {})
|
||||
assert result is not None
|
||||
assert result is event
|
||||
|
||||
|
||||
# ── scrub_event (composed before_send / before_send_transaction handler) ───────
|
||||
#
|
||||
# scrub_event — ЕДИНЫЙ хендлер, которым в main.py/celery_app.py вешаются ОБА
|
||||
# канала (before_send И before_send_transaction). До #2457-review composed-хук
|
||||
# висел только на before_send, а before_send_transaction оставался на голом
|
||||
# scrub_sensitive_query (только URL) — Starlette-интеграция кладёт request.data
|
||||
# на transaction-scope так же, как на error-scope, поэтому PII продолжало течь
|
||||
# через transaction-канал при glitchtip_traces_sample_rate > 0.
|
||||
|
||||
|
||||
def test_scrub_event_composes_pii_and_url_secret_scrub() -> None:
|
||||
"""scrub_event применяет PII-scrub (ключ-based) И URL query-string secret
|
||||
redact (regex) оба разом, не заменяя друг друга — разные классы данных."""
|
||||
from app.observability.sentry_scrub import scrub_event
|
||||
|
||||
event: dict = {
|
||||
"request": {
|
||||
"data": {"client_name": "Иван"},
|
||||
"url": "https://example.com?api_key=supersecret",
|
||||
}
|
||||
}
|
||||
result = scrub_event(event, {})
|
||||
assert result is not None
|
||||
assert result["request"]["data"]["client_name"] == "[REDACTED]"
|
||||
assert "[REDACTED]" in result["request"]["url"]
|
||||
assert "supersecret" not in result["request"]["url"]
|
||||
|
||||
|
||||
def test_scrub_event_returns_event_not_none() -> None:
|
||||
"""scrub_event всегда возвращает event (не None) — иначе SDK дропнет отчёт."""
|
||||
from app.observability.sentry_scrub import scrub_event
|
||||
|
||||
event: dict = {"request": {"data": {"name": "X"}}}
|
||||
result = scrub_event(event, {})
|
||||
assert result is not None
|
||||
assert result is event
|
||||
|
||||
|
||||
def test_scrub_event_survives_scrub_pii_event_exception() -> None:
|
||||
"""try/except в scrub_event — предохранитель: sentry_sdk оборачивает
|
||||
before_send в capture_internal_exceptions, который при исключении ТОЛЬКО
|
||||
логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что редактор упал).
|
||||
Если scrub_pii_event падает — scrub_event обязан вернуть event, а не
|
||||
пробросить исключение дальше (#2457-review)."""
|
||||
from app.observability.sentry_scrub import scrub_event
|
||||
|
||||
event: dict = {"request": {"data": {"client_name": "X"}}}
|
||||
with patch(
|
||||
"app.observability.sentry_scrub.scrub_pii_event",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
result = scrub_event(event, {})
|
||||
assert result is not None
|
||||
assert result is event
|
||||
|
||||
|
||||
def test_scrub_event_survives_scrub_sensitive_query_exception() -> None:
|
||||
"""То же самое для второго шага композиции (URL-secret redact)."""
|
||||
from app.observability.sentry_scrub import scrub_event
|
||||
|
||||
event: dict = {"request": {"data": {"name": "X"}}}
|
||||
with patch(
|
||||
"app.observability.sentry_scrub.scrub_sensitive_query",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
result = scrub_event(event, {})
|
||||
assert result is not None
|
||||
assert result is event
|
||||
|
||||
|
||||
# ── wiring: ПДн не доходят до транспорта (поведение, а не текст исходника) ─────
|
||||
#
|
||||
# До #2753 проводка проверялась поиском подстроки `before_send=scrub_event` в
|
||||
# файле. Такой гейт зелен и на разорванной проводке: обе точки входа несут
|
||||
# многострочные комментарии, где те же подстроки встречаются, — достаточно
|
||||
# удалить сам аргумент, оставив комментарий. Хуже того, подстрока ничего не
|
||||
# говорит о том, ДОШЛИ ли ПДн до транспорта: их можно выпустить и при живом
|
||||
# before_send (локальные переменные кадра стека уходят мимо ключевого скраба).
|
||||
#
|
||||
# Поэтому проверяем поведение: поднимаем настоящую инициализацию в подпроцессе
|
||||
# (`tests/_sentry_wiring_probe.py`), подменяем транспорт и смотрим, что до него
|
||||
# доехало. Наружу не уходит ничего — DSN указывает на несуществующий хост, а
|
||||
# `capture_envelope` подменён до первого события.
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _probe(module: str) -> str:
|
||||
"""Прогнать пробу проводки для точки входа `module`; вернуть JSON-строку."""
|
||||
env = {
|
||||
**os.environ,
|
||||
"TESTING": "1",
|
||||
# Синтаксически валидный DSN на несуществующий хост: init отработает,
|
||||
# сети не будет даже если транспорт когда-нибудь перестанут подменять.
|
||||
"GLITCHTIP_DSN": "https://probe@localhost.invalid/1",
|
||||
# Явно: у запуска скрипта в sys.path[0] попадает КАТАЛОГ СКРИПТА (tests/),
|
||||
# и без этого `import app` уехал бы в editable-установку пакета — то есть
|
||||
# проба мерила бы чужое дерево, а не то, что рядом с ней лежит.
|
||||
"PYTHONPATH": os.pathsep.join([str(_BACKEND_ROOT), os.environ.get("PYTHONPATH", "")]),
|
||||
}
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(_BACKEND_ROOT / "tests" / "_sentry_wiring_probe.py"), module],
|
||||
cwd=_BACKEND_ROOT,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
check=False,
|
||||
)
|
||||
assert proc.returncode == 0, f"проба упала: {proc.stderr[-3000:]}"
|
||||
return proc.stdout.strip().splitlines()[-1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"])
|
||||
def test_pii_never_reaches_transport(module: str) -> None:
|
||||
"""Оба канала (error И transaction) отдают транспорту событие без ПДн.
|
||||
|
||||
Красный, если из `sentry_sdk.init()` убрать `before_send` ИЛИ
|
||||
`before_send_transaction` — комментарий с теми же словами не спасает.
|
||||
"""
|
||||
probe = json.loads(_probe(module))
|
||||
markers = probe["markers"]
|
||||
|
||||
for channel in ("error", "transaction"):
|
||||
payload = probe["payloads"][channel]
|
||||
# Контроль «событие вообще доехало»: без него проверка была бы зелёной
|
||||
# и на пробе, которая молча ничего не отправила.
|
||||
assert probe["counts"][channel] == 1, f"{module}/{channel}: событие не доехало"
|
||||
assert "[REDACTED]" in payload, f"{module}/{channel}: скраб не отработал"
|
||||
|
||||
leaked = [key for key in ("phone", "free_text", "url_secret") if markers[key] in payload]
|
||||
assert leaked == [], f"{module}/{channel}: до транспорта дошли ПДн — {leaked}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"])
|
||||
def test_local_variables_never_reach_transport(module: str) -> None:
|
||||
"""`include_local_variables=False`: значения локальных переменных кадра стека
|
||||
не уходят в мониторинг (#2753).
|
||||
|
||||
Ключевой скраб такое не ловит по построению — имя переменной произвольно,
|
||||
а сверка идёт по именам. Красный, если флаг убрать из `sentry_sdk.init()`
|
||||
(в sentry-sdk он по умолчанию `True`).
|
||||
"""
|
||||
probe = json.loads(_probe(module))
|
||||
payload = probe["payloads"]["exception"]
|
||||
|
||||
assert probe["counts"]["exception"] == 1
|
||||
assert "sentry-wiring-probe boom" in payload, "событие с исключением не доехало"
|
||||
|
||||
assert (
|
||||
probe["markers"]["local_var"] not in payload
|
||||
), f"{module}: значение локальной переменной ушло в мониторинг"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"])
|
||||
def test_scrub_failure_does_not_spawn_second_event(module: str) -> None:
|
||||
"""Сбой самого скраба не порождает ВТОРОГО события (#2753).
|
||||
|
||||
`logger` этого модуля внесён в `ignore_logger`, иначе строка журнала об
|
||||
отказе ушла бы в мониторинг через LoggingIntegration (event_level=ERROR),
|
||||
снова попала бы в скраб, снова упала — рекурсия, защиты от которой в SDK
|
||||
нет (проверено на коде до фикса: не завершается). Красный, если
|
||||
`ignore_logger` убрать: обработчик войдёт повторно.
|
||||
"""
|
||||
probe = json.loads(_probe(module))
|
||||
|
||||
assert (
|
||||
probe["scrub_handler_entries"] == 1
|
||||
), "сбой скраба вернулся вторым событием: строка журнала уходит в мониторинг"
|
||||
assert probe["counts"]["scrub_failure"] == 1
|
||||
|
|
|
|||
|
|
@ -8,11 +8,12 @@ Mock-based — без реальной БД. Проверяет:
|
|||
- resolve_weights: нет user_id и profile_id → системные дефолты
|
||||
- resolve_weights: user_id задан, default-профиль есть → его веса
|
||||
- resolve_weights: profile_id задан → его веса
|
||||
- resolve_weights: профиль не найден → системные дефолты (fallback)
|
||||
- resolve_weights: профиль не найден → системные дефолты (fallback) + source != profile
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
|
@ -110,7 +111,8 @@ def test_resolve_weights_system_default() -> None:
|
|||
"""Оба аргумента None → возвращаются системные веса."""
|
||||
db = MagicMock()
|
||||
result = resolve_weights(db, user_id=None, profile_id=None)
|
||||
assert result == _SYSTEM_POI_WEIGHTS
|
||||
assert result.weights == _SYSTEM_POI_WEIGHTS
|
||||
assert result.source == "system"
|
||||
# db не должен вызываться вообще
|
||||
db.execute.assert_not_called()
|
||||
|
||||
|
|
@ -119,7 +121,7 @@ def test_resolve_weights_system_default_returns_copy() -> None:
|
|||
"""Возвращается копия словаря, не ссылка на _SYSTEM_POI_WEIGHTS."""
|
||||
db = MagicMock()
|
||||
result = resolve_weights(db, user_id=None, profile_id=None)
|
||||
result["school"] = 999.0
|
||||
result.weights["school"] = 999.0
|
||||
# Оригинал не изменён
|
||||
assert _SYSTEM_POI_WEIGHTS["school"] == 1.5
|
||||
|
||||
|
|
@ -156,7 +158,8 @@ def test_resolve_weights_uses_default_profile() -> None:
|
|||
finally:
|
||||
wp_module.get_default_profile = original
|
||||
|
||||
assert result == custom_weights
|
||||
assert result.weights == custom_weights
|
||||
assert result.source == "user_default"
|
||||
|
||||
|
||||
def test_resolve_weights_uses_specific_profile() -> None:
|
||||
|
|
@ -175,7 +178,8 @@ def test_resolve_weights_uses_specific_profile() -> None:
|
|||
finally:
|
||||
wp_module.get_profile = original
|
||||
|
||||
assert result == custom_weights
|
||||
assert result.weights == custom_weights
|
||||
assert result.source == "profile"
|
||||
|
||||
|
||||
def test_resolve_weights_profile_not_found_fallback() -> None:
|
||||
|
|
@ -194,7 +198,9 @@ def test_resolve_weights_profile_not_found_fallback() -> None:
|
|||
wp_module.get_profile = original_get
|
||||
wp_module.get_default_profile = original_default
|
||||
|
||||
assert result == _SYSTEM_POI_WEIGHTS
|
||||
assert result.weights == _SYSTEM_POI_WEIGHTS
|
||||
# #2811: главное — источник НЕ выдаёт себя за профиль, которого не нашли
|
||||
assert result.source == "system"
|
||||
|
||||
|
||||
def test_resolve_weights_empty_profile_weights_fallback() -> None:
|
||||
|
|
@ -212,4 +218,52 @@ def test_resolve_weights_empty_profile_weights_fallback() -> None:
|
|||
finally:
|
||||
wp_module.get_default_profile = original_default
|
||||
|
||||
assert result == _SYSTEM_POI_WEIGHTS
|
||||
assert result.weights == _SYSTEM_POI_WEIGHTS
|
||||
assert result.source == "system"
|
||||
|
||||
|
||||
def test_resolve_weights_profile_id_without_owner_is_not_profile(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""#2811 сценарий 1: profile_id есть, user_id нет → первая ветка не выполняется.
|
||||
|
||||
Ровно это жило на проде: ран analysis_runs #4000 от 2026-08-07 —
|
||||
source='profile', profile_id=1, а tram_stop=-0.5 (системный, у профиля 1 он
|
||||
-0.4). Метка обязана быть 'system', а промах — попасть в warning.
|
||||
"""
|
||||
db = MagicMock()
|
||||
with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"):
|
||||
result = resolve_weights(db, user_id=None, profile_id=1)
|
||||
|
||||
assert result.source == "system"
|
||||
assert result.weights == _SYSTEM_POI_WEIGHTS
|
||||
assert "profile_id=1" in caplog.text
|
||||
db.execute.assert_not_called() # профиль даже не искали
|
||||
|
||||
|
||||
def test_resolve_weights_missing_profile_falls_to_user_default_not_profile(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""#2811 сценарий 3: profile_id не найден, но у юзера есть default-профиль.
|
||||
|
||||
Худший вариант: веса НЕ системные, поэтому по значениям подмена вообще не
|
||||
видна. Метка должна сказать 'user_default', а не 'profile'.
|
||||
"""
|
||||
import app.services.site_finder.weight_profiles as wp_module
|
||||
|
||||
default_profile = _make_profile_mock({"school": 2.0})
|
||||
db = MagicMock()
|
||||
original_get = wp_module.get_profile
|
||||
original_default = wp_module.get_default_profile
|
||||
wp_module.get_profile = lambda _db, uid, pid: None
|
||||
wp_module.get_default_profile = lambda _db, uid: default_profile
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"):
|
||||
result = resolve_weights(db, user_id="user-1", profile_id=999)
|
||||
finally:
|
||||
wp_module.get_profile = original_get
|
||||
wp_module.get_default_profile = original_default
|
||||
|
||||
assert result.source == "user_default"
|
||||
assert result.weights == {"school": 2.0}
|
||||
assert "profile_id=999" in caplog.text
|
||||
|
|
|
|||
|
|
@ -17,10 +17,66 @@
|
|||
#
|
||||
# Postgres + Redis run alongside the app on the same VM (Discovery mode).
|
||||
# Volumes are shared with docker-compose.yml so switching between files preserves data.
|
||||
#
|
||||
# ── logging: journald (#2761) ────────────────────────────────────────────────
|
||||
# До этого у стека НЕ БЫЛО потолка вообще: дефолтный json-file растёт без границ
|
||||
# и живёт в /var/lib/docker/containers/<id>/ (умирает вместе с контейнером).
|
||||
# Тот же anchor и тот же драйвер, что у trade-in (#2758/#2741) — намеренно ОДИН
|
||||
# способ на обе половины, расхождение двух стеков дороже в поддержке.
|
||||
#
|
||||
# Замер прод 2026-08-06 (МБ/сутки = размер json-file / возраст контейнера):
|
||||
# glitchtip-worker 31.9 ← 2.59 ГБ накоплено, 81% всего роста стека
|
||||
# postgres 2.2 ← 162 МБ за 74 дня
|
||||
# backend 1.9 · worker 1.3 · beat 0.6 · caddy 0.5 · остальные <0.5
|
||||
# ИТОГО ~39 МБ/сутки
|
||||
# Бюджет journald (замер там же, сообщение самого systemd-journald):
|
||||
# "System Journal ... is 2.2G, max 4.0G" — потолок 4G ЭМПИРИЧЕСКИ подтверждён
|
||||
# (journald.conf пуст, все дефолты; 10% от 145G = 14.5G, но капается 4G).
|
||||
# Системный поток 2.2G/103 суток ≈ 22 МБ/сутки. После этой правки
|
||||
# 22 + 39 + tradein(единицы) ≈ 65 МБ/сутки → 4096/65 ≈ 60 суток глубины.
|
||||
# Дисковый эффект ОТРИЦАТЕЛЬНЫЙ (в нашу пользу): 4G — это потолок с
|
||||
# самовытеснением, а сегодня glitchtip-worker растёт БЕЗ потолка; плюс
|
||||
# пересоздание контейнера удаляет его json-file → разово освобождает ~2.6 ГБ.
|
||||
#
|
||||
# КАК ЧИТАТЬ (проверено на проде 2026-08-06, ровно тем доступом, что есть):
|
||||
# docker logs gendesign-backend-1 # как и раньше — только текущий контейнер
|
||||
# # История через пересоздания: журнал принадлежит root:systemd-journal, а
|
||||
# # deploy-юзер gendesign состоит в docker/sudo, но НЕ в adm/systemd-journal, и
|
||||
# # sudo просит пароль (`sudo -n` молча падает) → голый journalctl даёт
|
||||
# # "No entries". Рабочий однострочник — через docker-группу:
|
||||
# docker run --rm -v /:/host:ro alpine chroot /host sh -c \
|
||||
# 'TZ=UTC journalctl -t gendesign-backend-1 -o short-iso --since "2026-08-07 00:00"'
|
||||
# # TZ=UTC обязателен: --since/--until разбираются в ЛОКАЛЬНОМ времени хоста
|
||||
# # (+03), и флаг --utc на это НЕ влияет — он меняет только вывод (#2760).
|
||||
# # По метке контейнера: CONTAINER_NAME=gendesign-backend-1 (или CONTAINER_ID=<id>
|
||||
# # — так читается лог УЖЕ УДАЛЁННОГО контейнера).
|
||||
# Владельцу стоит разово выдать `usermod -aG adm gendesign` — тогда journalctl
|
||||
# заработает напрямую (host-config, не этот файл). До этого правка не регрессия.
|
||||
#
|
||||
# ⚠️ Blast radius ПЕРВОГО деплоя: log-driver — свойство создания контейнера, так
|
||||
# что `compose up -d` пересоздаст ВСЁ. backend/worker/beat/caddy/forwarder и так
|
||||
# force-recreate'ятся каждым деплоем (см. deploy.yml) — ИНКРЕМЕНТ этой правки:
|
||||
# postgres (~10с даунтайма), redis (брокер celery), osrm + osrm-walk (перезагрузка
|
||||
# MLD-графа в RAM), frontend, glitchtip-web/worker. Разово, деплоить в окно без
|
||||
# ночных прогонов.
|
||||
# Ceiling: journald рейт-лимитит 10000 сообщений / 30s на сервис (дефолт) — при
|
||||
# флуде в журнал попадёт "Suppressed N messages". Текущий пик (glitchtip-worker
|
||||
# 31.9 МБ/сутки ≈ 3 строки/с) ниже лимита на три порядка; если появится — это
|
||||
# host drop-in journald.conf.d, не этот файл.
|
||||
# tag: имя контейнера, а не ID — SYSLOG_IDENTIFIER стабилен между пересозданиями.
|
||||
#
|
||||
# ⚠️ НЕ переносить этот anchor в корневой docker-compose.yml: он для локальной
|
||||
# разработки, а в Docker Desktop (macOS/Windows) journald в VM нет — контейнеры
|
||||
# просто не стартуют. Ceiling для dev-логов при нужде — json-file max-size.
|
||||
x-logging: &default-logging
|
||||
driver: journald
|
||||
options:
|
||||
tag: "{{.Name}}"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgis/postgis:16-3.4
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
|
|
@ -46,6 +102,7 @@ services:
|
|||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
|
@ -54,6 +111,40 @@ services:
|
|||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
# #2709: redis вводится в gendesign_shared, чтобы tradein-backend вообще МОГ
|
||||
# его достать. До этого redis жил только в gendesign_default, а tradein — в
|
||||
# gendesign_shared + tradein-net: общей сети НЕТ, поэтому REDIS_URL там не
|
||||
# резолвился НИ ПОД КАКИМ именем. Это была не «забытая переменная», а
|
||||
# отсутствующая связность (см. #2709).
|
||||
#
|
||||
# Почему общий инстанс, а не свой redis в стеке trade-in: deploy-tradein.yml
|
||||
# поднимает стек как `up -d --no-deps $SERVICES`, где SERVICES —
|
||||
# ЗАХАРДКОЖЕННЫЙ список (browser backend frontend tgbot [scraper]). Новый
|
||||
# сервис в tradein-compose в этот список не попадает и `--no-deps` его не
|
||||
# подтянет → контейнер просто никогда бы не стартовал, а REDIS_URL указывал
|
||||
# бы в пустоту. Правка того списка = правка deploy-tradein.yml, который
|
||||
# сейчас заморожен (#2680 ждёт человека). Общий инстанс обходит это целиком.
|
||||
#
|
||||
# aliases: тот же приём, что уже применён к postgres выше — стабильное имя
|
||||
# gendesign-redis вместо compose-зависимого gendesign-redis-1.
|
||||
# ⚠️ `default` ОБЯЗАН быть перечислен явно: как только у сервиса появляется
|
||||
# блок networks:, неявная привязка к default пропадает, и backend/worker/
|
||||
# beat/glitchtip потеряли бы брокер (та же грабля описана у postgres).
|
||||
#
|
||||
# Разделение ключей — по НОМЕРУ БД, инстанс общий:
|
||||
# db0 — gendesign (celery-брокер + кэши бэкенда), 2166 ключей
|
||||
# db1 — trade-in (SearchCache) ← вводится здесь
|
||||
# db2 — glitchtip (см. REDIS_URL ниже)
|
||||
# Ceiling: maxmemory=0 / noeviction на инстансе НЕ трогаем — allkeys-lru на
|
||||
# брокере celery вытеснял бы поставленные в очередь таски. Значит tradein
|
||||
# обязан ставить TTL на каждый ключ (он ставит: SET ... ex=ttl). Если
|
||||
# tradein когда-нибудь начнёт писать без TTL, упрётся весь инстанс, включая
|
||||
# celery. Тогда — отдельный инстанс, а не смена политики вытеснения.
|
||||
networks:
|
||||
default: {}
|
||||
shared:
|
||||
aliases:
|
||||
- gendesign-redis
|
||||
|
||||
# OSRM routing engine (#39 — site-finder /analyze road/walking distances to POI
|
||||
# вместо straight-line ST_Distance). INFRA-only здесь: интеграция в /analyze —
|
||||
|
|
@ -76,6 +167,7 @@ services:
|
|||
# отдельно (docs/osrm-routing.md).
|
||||
osrm:
|
||||
image: osrm/osrm-backend:latest
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}.osrm
|
||||
volumes:
|
||||
|
|
@ -103,6 +195,7 @@ services:
|
|||
# (backend не depends_on, флаг use_osrm_distances OFF). Backend ходит к http://osrm-walk:5000.
|
||||
osrm-walk:
|
||||
image: osrm/osrm-backend:latest
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}-foot.osrm
|
||||
volumes:
|
||||
|
|
@ -118,6 +211,7 @@ services:
|
|||
|
||||
backend:
|
||||
image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest}
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
# .env.runtime пишется deploy.yml через SSH (SENTRY_RELEASE=$IMAGE_TAG).
|
||||
# required: false — compose не падает если файла нет (первый деплой).
|
||||
|
|
@ -153,6 +247,7 @@ services:
|
|||
|
||||
frontend:
|
||||
image: ghcr.io/lekss361/gendesign-frontend:${IMAGE_TAG:-latest}
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
|
|
@ -172,6 +267,7 @@ services:
|
|||
worker:
|
||||
# Отдельный chromium-образ (+200 МБ Playwright). См. backend/Dockerfile target=runner-with-chromium.
|
||||
image: ghcr.io/lekss361/gendesign-worker:${IMAGE_TAG:-latest}
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: ./backend/.env
|
||||
|
|
@ -201,6 +297,7 @@ services:
|
|||
beat:
|
||||
# Lean backend-образ (без Chromium) — beat только триггерит таски в Redis.
|
||||
image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest}
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: ./backend/.env
|
||||
|
|
@ -219,6 +316,7 @@ services:
|
|||
glitchtip-web:
|
||||
image: glitchtip/glitchtip:6.1.6
|
||||
container_name: glitchtip-web
|
||||
logging: *default-logging
|
||||
# profiles: ["glitchtip"] keeps this service from starting on plain `compose up -d`.
|
||||
# Bootstrap script activates the profile after DB + secrets are ready.
|
||||
# On subsequent deploys, set COMPOSE_PROFILES=glitchtip in /opt/gendesign/.env.
|
||||
|
|
@ -254,6 +352,7 @@ services:
|
|||
glitchtip-worker:
|
||||
image: glitchtip/glitchtip:6.1.6
|
||||
container_name: glitchtip-worker
|
||||
logging: *default-logging
|
||||
profiles: ["glitchtip"]
|
||||
depends_on:
|
||||
postgres:
|
||||
|
|
@ -272,6 +371,7 @@ services:
|
|||
|
||||
caddy:
|
||||
image: caddy:2
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
|
|
@ -310,6 +410,7 @@ services:
|
|||
# deploy.yml запускает: docker compose build glitchtip-auth-forwarder
|
||||
build: ./ops/glitchtip-auth-forwarder
|
||||
container_name: gendesign-auth-forwarder
|
||||
logging: *default-logging
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GLITCHTIP_DSN: ${GLITCHTIP_DSN}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@
|
|||
| `GLITCHTIP_SECRET` | `.env` | Django `SECRET_KEY` GlitchTip | **F** (app secret) |
|
||||
| `OBJECTIVE_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета на VPS | **D** |
|
||||
| `OPENAI_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета (только если non-empty) | **D** |
|
||||
| `SCRAPE_ADMIN_TOKEN` | `backend/.env` | **DEPRECATED** (PR #436): app-level admin auth удалён, заменён Caddy basic_auth. Поле оставлено в `core/deps.py` для быстрого rollback | **F** (legacy, см. §3) |
|
||||
| ~~`SCRAPE_ADMIN_TOKEN`~~ | — | **УДАЛЁН** (#2775): не секрет и не credential. См. §3 | — |
|
||||
|
||||
### 1.3 Прод runtime — tradein стек (`/opt/gendesign/tradein-mvp/backend/.env.runtime`)
|
||||
|
||||
|
|
@ -210,18 +210,28 @@ Bootstrap-роли (`tradein_fdw_reader`, `gendesign_reader`):
|
|||
|
||||
---
|
||||
|
||||
## 3. Особый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance)
|
||||
## 3. Закрытый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance)
|
||||
|
||||
Issue #78 просит «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime».
|
||||
**Статус токена: DEPRECATED** — app-level admin-auth был удалён в PR #436
|
||||
(`backend/.env.example:30`), доступ к админ-эндпоинтам теперь закрыт Caddy basic_auth.
|
||||
Поле оставлено в `core/deps.py` только для быстрого rollback.
|
||||
Issue #78 просил «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime».
|
||||
**Ротировать нечего: переменной больше нет.**
|
||||
|
||||
**Вывод:** активной ротации не требуется — токен ни на что не влияет, пока
|
||||
`AdminTokenAuth` dep не реинстейтнут. Если/когда его вернут, он попадает в класс **F**
|
||||
(процедура: `sed` в `backend/.env.runtime` → `up -d --force-recreate --no-deps backend beat`,
|
||||
downtime отсутствует). Фактический прод-прогон ротации — операционное действие
|
||||
(Anton), не выполняется в рамках этого PR.
|
||||
История. App-level admin-auth сняли в PR #437 (заголовок `X-Admin-Token` убран
|
||||
со всех эндпоинтов), UI ввода токена — в PR #442. Поле `scrape_admin_token` и
|
||||
dep `AdminTokenAuth` оставили «для быстрого rollback» — и они пролежали так до
|
||||
#2775, полтора месяца, не имея ни одного вызывающего: разбор AST по всему
|
||||
репозиторию нашёл `verify_admin_token` и `AdminTokenAuth` ровно в одном месте —
|
||||
в объявлении в `core/deps.py`. Rollback, ради которого поле держали, всё равно
|
||||
означал бы правку всех `admin_*.py` (dep-то нигде не проставлен), то есть
|
||||
хранение поля не экономило ничего. Удалено вместе с `core/deps.py`,
|
||||
`SCRAPE_ADMIN_TOKEN` из `.env.example` и остатками в тестах.
|
||||
|
||||
Что закрывает `/api/v1/admin/*` сейчас — **два слоя, оба живые**:
|
||||
1. `rbac_guard` в `backend/app/main.py`: `_ADMIN_API_RE` → 403 `admin only`
|
||||
для роли ≠ admin. Покрыт `backend/tests/test_rbac.py` (24 теста).
|
||||
2. Caddy basic_auth на весь `gendsgn.ru` (PR #426).
|
||||
|
||||
Строку `SCRAPE_ADMIN_TOKEN=` в прод-`.env` удалять не обязательно:
|
||||
`Settings.model_config` — `extra="ignore"`, лишняя переменная безвредна.
|
||||
|
||||
`JWT_SECRET` (упомянут в #78 «после B3-4») в кодовой базе **ещё отсутствует** —
|
||||
добавить в реестр (класс **F**) при внедрении JWT-аутентификации.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab";
|
|||
import { LandTab } from "@/components/site-finder/LandTab";
|
||||
import { MarketTab } from "@/components/site-finder/MarketTab";
|
||||
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
|
||||
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
|
||||
import { buildAnalyzeOptions, useSiteAnalysis } from "@/hooks/useSiteAnalysis";
|
||||
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
||||
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
|
||||
import { useCustomPois } from "@/hooks/useCustomPois";
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
POI_DEFAULT_WEIGHTS,
|
||||
type PoiCategoryKey,
|
||||
} from "@/lib/api/weightProfiles";
|
||||
import { useMe } from "@/lib/useMe";
|
||||
|
||||
// SiteMap imports Leaflet which requires browser APIs — load without SSR
|
||||
const SiteMap = dynamic(
|
||||
|
|
@ -129,16 +130,16 @@ function SiteFinderContent() {
|
|||
// Ref to skip the initial mount effect (we only re-analyze on actual changes).
|
||||
const weightsChangeInitializedRef = useRef(false);
|
||||
|
||||
const [profileUserId, setProfileUserId] = useState<string>(() =>
|
||||
typeof window === "undefined"
|
||||
? ""
|
||||
: (localStorage.getItem("admin_user_id") ?? ""),
|
||||
);
|
||||
const [adminToken] = useState<string>(() =>
|
||||
typeof window === "undefined"
|
||||
? ""
|
||||
: (localStorage.getItem("admin_token") ?? ""),
|
||||
);
|
||||
// Владелец weight-профилей — вошедший пользователь (#2782). Было: два значения
|
||||
// из localStorage (`admin_user_id` из удалённого в #442 инпута и `admin_token`,
|
||||
// который сервер не читает с #437) — оба недостижимы без DevTools.
|
||||
//
|
||||
// 🔴 profileUserId нужен НЕ только панели: analyze с одним `profile_id` без
|
||||
// `profile_user_id` бэкенд резолвит в СИСТЕМНЫЕ веса, отдавая при этом
|
||||
// `weights_profile.source = "profile"` (проверено на проде: profile_id=1 без
|
||||
// user_id → tram_stop −0.5 вместо −0.4 из профиля). То есть выбранный профиль
|
||||
// молча не применялся бы, а UI показывал бы его ползунки.
|
||||
const profileUserId = useMe().data?.username ?? "";
|
||||
// Lazy init: считаем initialTab один раз на mount (useState всё равно
|
||||
// игнорирует initializer после первого render — не тратим CPU).
|
||||
const [tab, setTabState] = useState<TabId>(() => {
|
||||
|
|
@ -192,12 +193,7 @@ function SiteFinderContent() {
|
|||
setIsochrones(undefined);
|
||||
mutate({
|
||||
cad: currentData.cad_num,
|
||||
options:
|
||||
profileId != null
|
||||
? { profileId }
|
||||
: currentProfileUserId
|
||||
? { profileUserId: currentProfileUserId, weights }
|
||||
: { weights },
|
||||
options: buildAnalyzeOptions(weights, profileId, currentProfileUserId),
|
||||
});
|
||||
// mutate is stable from useMutation — safe to omit from deps.
|
||||
// data?.cad_num — dep, чтобы при завершении ПЕРВИЧНОГО analyze (cad_num
|
||||
|
|
@ -216,12 +212,11 @@ function SiteFinderContent() {
|
|||
// slider values are always respected even without a saved profile (#201).
|
||||
mutate({
|
||||
cad: cadNum,
|
||||
options:
|
||||
activeProfileId != null
|
||||
? { profileId: activeProfileId }
|
||||
: profileUserId
|
||||
? { profileUserId, weights: currentWeights }
|
||||
: { weights: currentWeights },
|
||||
options: buildAnalyzeOptions(
|
||||
currentWeights,
|
||||
activeProfileId,
|
||||
profileUserId,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -329,46 +324,9 @@ function SiteFinderContent() {
|
|||
|
||||
{/* Weight profile panel — collapsible, below header */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{/* Optional user-id field for profile CRUD (shown only when adminToken present) */}
|
||||
{!!adminToken && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
|
||||
>
|
||||
User ID (для профилей):
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileUserId}
|
||||
placeholder="user-abc"
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
fontSize: 12,
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
width: 180,
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setProfileUserId(e.target.value);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("admin_user_id", e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<WeightProfilePanel
|
||||
currentWeights={currentWeights}
|
||||
onWeightsChange={handleWeightsChange}
|
||||
userId={profileUserId || undefined}
|
||||
adminToken={adminToken || undefined}
|
||||
/>
|
||||
{/* Recalculation indicator — shown while re-analyze is in-flight after
|
||||
weights change (data already loaded, pendingWeightsChange set). */}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,12 @@ import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5At
|
|||
import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast";
|
||||
import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept";
|
||||
import { SectionAlternatives } from "@/components/site-finder/analysis/SectionAlternatives";
|
||||
import { adaptEgrn, useParcelAnalyzeQuery } from "@/lib/site-finder-api";
|
||||
import {
|
||||
AnalyzeWeightsContext,
|
||||
adaptEgrn,
|
||||
useParcelAnalyzeQuery,
|
||||
} from "@/lib/site-finder-api";
|
||||
import type { PoiCategoryKey } from "@/lib/api/weightProfiles";
|
||||
import type {
|
||||
ParcelAnalysis,
|
||||
PendingConceptProgram,
|
||||
|
|
@ -29,7 +34,40 @@ interface Props {
|
|||
|
||||
// ── Page Content (client — needs TanStack Query) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Обёртка над телом страницы: держит применённые в §4.1 POI-веса и кладёт их в
|
||||
* контекст ВЫШЕ всех вызовов useParcelAnalyzeQuery (#2790). Своё состояние
|
||||
* нельзя было оставить в теле: собственный вызов useParcelAnalyzeQuery читал бы
|
||||
* контекст «сверху», то есть null, и страница разъехалась бы на два разных
|
||||
* анализа — свой у шапки, свой у секций.
|
||||
*
|
||||
* null = веса не применяли → запрос как раньше, без тела.
|
||||
*/
|
||||
export function AnalysisPageContent({ cad }: Props) {
|
||||
const [appliedWeights, setAppliedWeights] = useState<Record<
|
||||
PoiCategoryKey,
|
||||
number
|
||||
> | null>(null);
|
||||
|
||||
return (
|
||||
<AnalyzeWeightsContext.Provider value={appliedWeights}>
|
||||
<AnalysisPageBody
|
||||
cad={cad}
|
||||
appliedWeights={appliedWeights}
|
||||
onWeightsApply={setAppliedWeights}
|
||||
/>
|
||||
</AnalyzeWeightsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalysisPageBody({
|
||||
cad,
|
||||
appliedWeights,
|
||||
onWeightsApply,
|
||||
}: Props & {
|
||||
appliedWeights: Record<PoiCategoryKey, number> | null;
|
||||
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
|
||||
}) {
|
||||
const [horizon, setHorizon] = useState<number>(12);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
|
@ -216,8 +254,15 @@ export function AnalysisPageContent({ cad }: Props) {
|
|||
{/* ── Группа «Стройка и рынок» ──────────────────────────────── */}
|
||||
<GroupDivider label="Стройка и рынок" />
|
||||
|
||||
{/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */}
|
||||
<Section3SettingsAndCompetitors cad={cad} data={analysis} />
|
||||
{/* 4. Рынок и конкуренты — IMPLEMENTED in A7. Веса POI из §4.1
|
||||
поднимаем сюда: «Применить» меняет ключ analyze-запроса → скор
|
||||
пересчитывается по ползункам во ВСЕХ секциях (#2790). */}
|
||||
<Section3SettingsAndCompetitors
|
||||
cad={cad}
|
||||
data={analysis}
|
||||
weights={appliedWeights}
|
||||
onWeightsApply={onWeightsApply}
|
||||
/>
|
||||
|
||||
{/* 5. Атмосфера — IMPLEMENTED in A11 */}
|
||||
<Section5Atmosphere cad={cad} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* #2790 п.1 — «Применить» у весов POI в §4.1 ничего не применяло.
|
||||
*
|
||||
* Состояние весов жило в `Section31Settings` и читалось только обратно в ту же
|
||||
* панель: до `/analyze` оно не доезжало никогда (слова `weights` в
|
||||
* AnalysisPageContent не было вовсе). Пользователь двигал ползунки, жал
|
||||
* «Применить» и получал ТОТ ЖЕ скор, посчитанный по системным весам.
|
||||
*
|
||||
* Тест идёт живым путём: рендерит настоящую страницу с настоящей §4.1 и
|
||||
* настоящим `useParcelAnalyzeQuery` (замокан только тяжёлый обвес — карты,
|
||||
* прогноз, концепция) и смотрит, что уходит в сеть. На коде до фикса второй
|
||||
* POST /analyze не случается вообще → красный.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AnalysisPageContent } from "../AnalysisPageContent";
|
||||
|
||||
// Тяжёлые секции не участвуют в контракте «ползунки → запрос»: они тянут
|
||||
// Leaflet / ECharts / собственные poll-запросы. §3 (настройки + панель весов) —
|
||||
// НАСТОЯЩАЯ, как и useParcelAnalyzeQuery: они и есть предмет теста.
|
||||
vi.mock("@/components/site-finder/ChatDock", () => ({ ChatDock: () => null }));
|
||||
vi.mock("@/components/site-finder/GateVerdictBanner", () => ({
|
||||
GateVerdictBanner: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/HorizonSelector", () => ({
|
||||
HorizonSelector: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section1ParcelInfo", () => ({
|
||||
Section1ParcelInfo: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section2NetworksUtilities", () => ({
|
||||
Section2NetworksUtilities: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section4Estimate", () => ({
|
||||
Section4Estimate: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section5Atmosphere", () => ({
|
||||
Section5Atmosphere: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section6Forecast", () => ({
|
||||
Section6Forecast: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section7Concept", () => ({
|
||||
Section7Concept: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/SectionAlternatives", () => ({
|
||||
SectionAlternatives: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/BestLayoutsBlock", () => ({
|
||||
BestLayoutsBlock: () => null,
|
||||
}));
|
||||
|
||||
const CAD = "66:41:0702017:131";
|
||||
|
||||
const ANALYSIS = {
|
||||
cad_num: CAD,
|
||||
score: 18.91,
|
||||
district: { district_name: "Чкаловский" },
|
||||
egrn: null,
|
||||
competitors: [],
|
||||
};
|
||||
|
||||
/** Тела всех POST /analyze в порядке отправки. undefined = запрос без тела. */
|
||||
const analyzeBodies: Array<Record<string, unknown> | undefined> = [];
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
analyzeBodies.length = 0;
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockImplementation(async (input, init) => {
|
||||
const url = typeof input === "string" ? input : String(input);
|
||||
if (url.includes("/analyze")) {
|
||||
const raw = init?.body;
|
||||
analyzeBodies.push(
|
||||
typeof raw === "string"
|
||||
? (JSON.parse(raw) as Record<string, unknown>)
|
||||
: undefined,
|
||||
);
|
||||
return jsonResponse(ANALYSIS);
|
||||
}
|
||||
if (url.includes("/api/v1/me")) {
|
||||
return jsonResponse({
|
||||
username: "admin",
|
||||
role: "admin",
|
||||
allowed_paths: ["/**"],
|
||||
deny_paths: [],
|
||||
});
|
||||
}
|
||||
if (url.includes("/weight-profiles")) {
|
||||
return jsonResponse([]);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AnalysisPageContent cad={CAD} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Ползунок конкретной категории по подписи строки в панели весов. */
|
||||
function sliderFor(label: string): HTMLInputElement {
|
||||
const row = screen.getByText(label).closest("div");
|
||||
if (!row) throw new Error(`не нашёл строку ползунка «${label}»`);
|
||||
const input = row.querySelector('input[type="range"]');
|
||||
if (!input) throw new Error(`в строке «${label}» нет ползунка`);
|
||||
return input as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe("§4.1 «Применить» доносит веса до /analyze (#2790)", () => {
|
||||
it("отправляет ползунки в тело повторного analyze", async () => {
|
||||
renderPage();
|
||||
|
||||
// Первичный анализ — без весов (ничего не применяли): тело не шлём вовсе,
|
||||
// бэкенд считает по системным. Это же и baseline для «стало другим».
|
||||
await waitFor(() => expect(analyzeBodies.length).toBe(1));
|
||||
expect(analyzeBodies[0]).toBeUndefined();
|
||||
|
||||
fireEvent.click(await screen.findByText("POI Веса"));
|
||||
fireEvent.change(sliderFor("Парки"), { target: { value: "3" } });
|
||||
fireEvent.change(sliderFor("Трамвайные ост. (−)"), {
|
||||
target: { value: "-2" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
||||
|
||||
// Главное утверждение: analyze уходит ЗАНОВО и несёт ровно те веса, что
|
||||
// выставлены ползунками. До фикса второго запроса не было — красный здесь.
|
||||
await waitFor(() => expect(analyzeBodies.length).toBe(2));
|
||||
const applied = analyzeBodies[1]?.weights as Record<string, number>;
|
||||
expect(applied.park).toBe(3);
|
||||
expect(applied.tram_stop).toBe(-2);
|
||||
// Нетронутые категории уходят как есть — бэкенд мержит поверх системных,
|
||||
// но панель отправляет полный набор, чтобы ответ совпадал с ползунками.
|
||||
expect(applied.school).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
|
@ -10,8 +10,8 @@
|
|||
/* ===================== SCOPED TOKENS (dark) ===================== */
|
||||
.pticaRoot[data-theme="dark"] {
|
||||
--font-ui:
|
||||
var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI",
|
||||
system-ui, sans-serif;
|
||||
var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI", system-ui,
|
||||
sans-serif;
|
||||
--font-mono:
|
||||
var(--font-plex-mono), "IBM Plex Mono", "Roboto Mono", ui-monospace,
|
||||
monospace;
|
||||
|
|
@ -907,31 +907,6 @@
|
|||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* ===================== PLACEHOLDER PANEL ===================== */
|
||||
.placeholderPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
min-height: 240px;
|
||||
border-style: dashed;
|
||||
}
|
||||
.placeholderPanel .soon {
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-soft);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.placeholderPanel p {
|
||||
font-size: 10px;
|
||||
color: var(--text-soft);
|
||||
margin: 0;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
/* ===================== STATE SCREENS ===================== */
|
||||
.stateScreen {
|
||||
display: grid;
|
||||
|
|
|
|||
|
|
@ -526,14 +526,15 @@ function VariantPanel({
|
|||
? "IRR помечен как оценочный: денежный поток вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
|
||||
: ""}
|
||||
НДС: жильё и услуги застройщика по ДДУ освобождены (ст. 149 НК
|
||||
РФ); НДС начисляется только на паркинг (нежилые машиноместа).
|
||||
Входной НДС по строительству уже учтён в себестоимости. Налог на
|
||||
прибыль — 25% (с 2025 года). Цена продажи жилья —{" "}
|
||||
РФ); НДС начисляется на нежилое — паркинг (машиноместа) и
|
||||
коммерцию 1-го этажа. Входной НДС по строительству уже учтён в
|
||||
себестоимости. Налог на прибыль — 25% (с 2025 года). Цена продажи
|
||||
жилья —{" "}
|
||||
{financial.price_is_calibrated
|
||||
? `калибрована по рынку (${priceSourceCaption(financial)})`
|
||||
: "норматив класса (нет рыночных данных по участку)"}
|
||||
; себестоимость СМР и цена паркинга — нормативные ориентиры.
|
||||
Коммерческие и офисные площади не учитываются.
|
||||
; себестоимость СМР, цена паркинга и цена нежилого — нормативные
|
||||
ориентиры.
|
||||
{financial.financing_enabled && financial.financing_is_simplified
|
||||
? " Финансирование упрощено: весь кассовый разрыв покрыт " +
|
||||
"кредитом по ставке-нормативу, проценты капитализируются, " +
|
||||
|
|
@ -656,7 +657,7 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
|
|||
value={formatMoneyCompact(financial.gross_margin_rub)}
|
||||
/>
|
||||
<CascadeRow
|
||||
label="НДС (паркинг)"
|
||||
label="НДС (паркинг + коммерция)"
|
||||
value={formatMoneyCompact(financial.vat_rub)}
|
||||
/>
|
||||
<CascadeRow
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
// ScoreCard is superseded by the tabbed dashboard in page.tsx.
|
||||
// Logic is now split into OverviewTab, EnvironmentTab, LandTab, MarketTab.
|
||||
// File kept to avoid breaking any external imports; exports an empty stub.
|
||||
|
||||
export {};
|
||||
|
|
@ -3,12 +3,14 @@
|
|||
import { useCallback, useState } from "react";
|
||||
|
||||
import { SectionLabel } from "@/components/ui/SectionLabel";
|
||||
import { useMe } from "@/lib/useMe";
|
||||
import {
|
||||
POI_CATEGORIES,
|
||||
POI_DEFAULT_WEIGHTS,
|
||||
POI_LABELS,
|
||||
POI_WEIGHT_MAX,
|
||||
POI_WEIGHT_MIN,
|
||||
SYSTEM_PROFILE_USER_ID,
|
||||
useCreateProfile,
|
||||
useWeightProfiles,
|
||||
type PoiCategoryKey,
|
||||
|
|
@ -29,13 +31,6 @@ interface Props {
|
|||
weights: Record<PoiCategoryKey, number>,
|
||||
profileId: number | null,
|
||||
) => void;
|
||||
/**
|
||||
* If provided, enables save/load from DB.
|
||||
* Must be non-empty for CRUD functionality.
|
||||
*/
|
||||
userId?: string;
|
||||
/** Admin token for CRUD API calls. */
|
||||
adminToken?: string;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
|
@ -66,12 +61,7 @@ function weightsEqual(
|
|||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function WeightProfilePanel({
|
||||
currentWeights,
|
||||
onWeightsChange,
|
||||
userId,
|
||||
adminToken,
|
||||
}: Props) {
|
||||
export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Local draft weights — editable before "Применить"
|
||||
|
|
@ -89,11 +79,19 @@ export function WeightProfilePanel({
|
|||
const [saveName, setSaveName] = useState("");
|
||||
const [saveDefault, setSaveDefault] = useState(false);
|
||||
|
||||
// Profiles query (only when userId + adminToken provided)
|
||||
const canUseCrud = !!userId && !!adminToken;
|
||||
const profilesQuery = useWeightProfiles(userId ?? "", adminToken ?? "");
|
||||
// Владелец профилей — вошедший пользователь (#2782). Раньше user_id вводили
|
||||
// руками, а CRUD был заперт на `adminToken` из localStorage, которого негде было
|
||||
// взять: поле ввода удалили в #442, а сервер перестал читать X-Admin-Token ещё в
|
||||
// #437. Профили и так per-user, так что личность берём оттуда же, откуда её берут
|
||||
// RouteGuard и Topbar — из /api/v1/me (тот же queryKey, запрос не дублируется).
|
||||
// В проде username всегда есть: до страницы не пустит Caddy basic_auth. Пусто
|
||||
// бывает только в dev без Caddy (/me → 401) — тогда CRUD выключен.
|
||||
const { data: me } = useMe();
|
||||
const userId = me?.username ?? "";
|
||||
const canUseCrud = !!userId;
|
||||
const profilesQuery = useWeightProfiles(userId);
|
||||
|
||||
const createMutation = useCreateProfile(adminToken ?? "");
|
||||
const createMutation = useCreateProfile();
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -113,11 +111,22 @@ export function WeightProfilePanel({
|
|||
}
|
||||
|
||||
function handleApply() {
|
||||
onWeightsChange({ ...draft }, selectedProfileId);
|
||||
// Системный пресет не адресуем через profile_id: resolve_weights() ищет
|
||||
// профиль в области ВЛАДЕЛЬЦА, а владелец пресета — `__system__`, не
|
||||
// текущий пользователь. Бэкенд его не найдёт, тихо возьмёт дефолтные веса и
|
||||
// отрапортует `weights_profile.source = "profile"` (#2782). Поэтому для
|
||||
// пресета отдаём profileId = null — вызывающая сторона пошлёт inline-веса,
|
||||
// а они ровно те, что на ползунках.
|
||||
const selected = profiles.find((p) => p.id === selectedProfileId) ?? null;
|
||||
const addressableId =
|
||||
selected && selected.user_id !== SYSTEM_PROFILE_USER_ID
|
||||
? selected.id
|
||||
: null;
|
||||
onWeightsChange({ ...draft }, addressableId);
|
||||
}
|
||||
|
||||
const handleSaveProfile = useCallback(async () => {
|
||||
if (!canUseCrud || !userId || !adminToken) {
|
||||
if (!canUseCrud || !userId) {
|
||||
setShowSaveDialog(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -136,15 +145,7 @@ export function WeightProfilePanel({
|
|||
} catch {
|
||||
// Error visible through createMutation.error
|
||||
}
|
||||
}, [
|
||||
canUseCrud,
|
||||
userId,
|
||||
adminToken,
|
||||
saveName,
|
||||
draft,
|
||||
saveDefault,
|
||||
createMutation,
|
||||
]);
|
||||
}, [canUseCrud, userId, saveName, draft, saveDefault, createMutation]);
|
||||
|
||||
// ── Derived ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -242,6 +243,7 @@ export function WeightProfilePanel({
|
|||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<label
|
||||
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
|
||||
title={`Профили сохраняются отдельно для каждого пользователя. Ваш: ${userId}`}
|
||||
>
|
||||
Профиль:
|
||||
</label>
|
||||
|
|
@ -268,6 +270,7 @@ export function WeightProfilePanel({
|
|||
{profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.profile_name}
|
||||
{p.user_id === SYSTEM_PROFILE_USER_ID ? " · пресет" : ""}
|
||||
{p.is_default ? " ★" : ""}
|
||||
</option>
|
||||
))}
|
||||
|
|
@ -278,10 +281,13 @@ export function WeightProfilePanel({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Hint when no crud */}
|
||||
{/* Hint when no crud. В проде недостижимо (без входа страницу не отдаёт
|
||||
Caddy basic_auth) — остаётся для dev-запуска без прокси, где
|
||||
/api/v1/me отвечает 401. Текст называет причину, а не действие:
|
||||
вводить тут больше нечего. */}
|
||||
{!canUseCrud && (
|
||||
<p style={{ fontSize: 11, color: "#9ca3af", margin: 0 }}>
|
||||
Укажите User ID и Admin Token для сохранения профилей.
|
||||
Пользователь не определён — сохранение профилей недоступно.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
@ -362,6 +368,9 @@ export function WeightProfilePanel({
|
|||
}}
|
||||
>
|
||||
<SectionLabel>Новый профиль</SectionLabel>
|
||||
<p style={{ fontSize: 11, color: "#6b7280", margin: 0 }}>
|
||||
Сохранится для пользователя {userId} — другие его не увидят.
|
||||
</p>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* #2782 — CRUD профилей весов был недостижим никому без DevTools.
|
||||
*
|
||||
* Панель отпирала сохранение/загрузку профилей значением `admin_token` из
|
||||
* localStorage. Записывать его было нечем: инпут удалили в #442. Сервер этот
|
||||
* заголовок к тому моменту уже не читал (#437, остатки убраны в #2775) — то есть
|
||||
* фича стояла за признаком, который ничего не решал.
|
||||
*
|
||||
* Тесты ниже пиннят три вещи, каждая из которых на старом коде красная:
|
||||
* 1) панель берёт владельца профилей из /api/v1/me и включает CRUD без пропсов;
|
||||
* 2) в запросы CRUD не уходит X-Admin-Token;
|
||||
* 3) `profileId` не уходит в /analyze без `profileUserId` (иначе бэкенд молча
|
||||
* считает по системным весам, рапортуя source="profile").
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { WeightProfilePanel } from "../WeightProfilePanel";
|
||||
import { buildAnalyzeOptions } from "@/hooks/useSiteAnalysis";
|
||||
import { POI_DEFAULT_WEIGHTS } from "@/lib/api/weightProfiles";
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const PROFILE = {
|
||||
id: 1,
|
||||
user_id: "admin",
|
||||
profile_name: "Мой профиль",
|
||||
weights: { ...POI_DEFAULT_WEIGHTS, park: 2.5 },
|
||||
is_default: true,
|
||||
description: null,
|
||||
created_at: "2026-05-15T05:30:51Z",
|
||||
updated_at: "2026-05-15T05:30:51Z",
|
||||
};
|
||||
|
||||
function renderPanel() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<WeightProfilePanel
|
||||
currentWeights={{ ...POI_DEFAULT_WEIGHTS }}
|
||||
onWeightsChange={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Раскрыть свёрнутую панель (в закрытом виде тело не рендерится). */
|
||||
async function openPanel() {
|
||||
const { default: userEvent } = await import("@testing-library/user-event");
|
||||
await userEvent.setup().click(screen.getByText("POI Веса"));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockImplementation(async (input) => {
|
||||
const url = typeof input === "string" ? input : String(input);
|
||||
if (url.includes("/api/v1/me")) {
|
||||
return jsonResponse({
|
||||
username: "admin",
|
||||
role: "admin",
|
||||
allowed_paths: ["/**"],
|
||||
deny_paths: [],
|
||||
});
|
||||
}
|
||||
if (url.includes("/weight-profiles")) {
|
||||
return jsonResponse([PROFILE]);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("WeightProfilePanel — владелец профилей из сессии (#2782)", () => {
|
||||
it("включает CRUD без пропсов: показывает профили вошедшего пользователя", async () => {
|
||||
renderPanel();
|
||||
await openPanel();
|
||||
|
||||
// Профили грузятся по user_id из /me, без ручного ввода и без токена.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(),
|
||||
);
|
||||
expect(screen.getByText("Профиль:")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("не обещает поля, которого нет: старой подсказки про Admin Token больше нет", async () => {
|
||||
renderPanel();
|
||||
await openPanel();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByText(/Admin Token/i)).toBeNull();
|
||||
expect(screen.queryByText(/Укажите User ID/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("не шлёт X-Admin-Token — сервер его не читает с #437", async () => {
|
||||
renderPanel();
|
||||
await openPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
const crudCall = fetchMock.mock.calls.find(([input]) =>
|
||||
String(input).includes("/weight-profiles"),
|
||||
);
|
||||
expect(crudCall).toBeTruthy();
|
||||
const headers = new Headers(
|
||||
(crudCall?.[1] as RequestInit | undefined)?.headers,
|
||||
);
|
||||
expect(headers.has("X-Admin-Token")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAnalyzeOptions — profileId не ходит без владельца (#2782)", () => {
|
||||
const weights = { ...POI_DEFAULT_WEIGHTS };
|
||||
|
||||
it("выбранный профиль уходит вместе с profileUserId", () => {
|
||||
expect(buildAnalyzeOptions(weights, 7, "admin")).toEqual({
|
||||
profileId: 7,
|
||||
profileUserId: "admin",
|
||||
});
|
||||
});
|
||||
|
||||
it("без пользователя profileId не отправляется — иначе бэкенд тихо возьмёт системные веса", () => {
|
||||
const options = buildAnalyzeOptions(weights, 7, "");
|
||||
expect(options.profileId).toBeUndefined();
|
||||
expect(options.weights).toEqual(weights);
|
||||
});
|
||||
|
||||
it("без профиля уходят inline-веса и владелец для default-профиля", () => {
|
||||
expect(buildAnalyzeOptions(weights, null, "admin")).toEqual({
|
||||
profileUserId: "admin",
|
||||
weights,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
interface AnalysisBreadcrumbProps {
|
||||
cadNum: string;
|
||||
}
|
||||
|
||||
export function AnalysisBreadcrumb({ cadNum }: AnalysisBreadcrumbProps) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
flexWrap: "wrap",
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{/* SiteFinder root */}
|
||||
<Link
|
||||
href="/site-finder"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--fg-secondary)",
|
||||
textDecoration: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
SiteFinder
|
||||
</Link>
|
||||
|
||||
<ChevronRight
|
||||
size={14}
|
||||
strokeWidth={1.5}
|
||||
style={{ color: "var(--fg-tertiary)", flexShrink: 0 }}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* Cad number */}
|
||||
<Link
|
||||
href={`/site-finder?selected=${encodeURIComponent(cadNum)}`}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--fg-secondary)",
|
||||
textDecoration: "none",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
maxWidth: 260,
|
||||
}}
|
||||
title={cadNum}
|
||||
>
|
||||
{cadNum}
|
||||
</Link>
|
||||
|
||||
<ChevronRight
|
||||
size={14}
|
||||
strokeWidth={1.5}
|
||||
style={{ color: "var(--fg-tertiary)", flexShrink: 0 }}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
{/* Current page */}
|
||||
<span
|
||||
aria-current="page"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--fg-primary)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
Анализ
|
||||
</span>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,266 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SubSection {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
id: string;
|
||||
label: string;
|
||||
sub?: SubSection[];
|
||||
}
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const NAV_SECTIONS: NavSection[] = [
|
||||
{ id: "section-1", label: "1. Объект" },
|
||||
{ id: "section-2", label: "2. Земля и риски" },
|
||||
{
|
||||
id: "section-3",
|
||||
label: "3. Рынок",
|
||||
sub: [
|
||||
{ id: "section-3-1", label: "3.1 Настройки выборки" },
|
||||
{ id: "section-3-2", label: "3.2 Планировки" },
|
||||
{ id: "section-3-3", label: "3.3 Остатки и скорость" },
|
||||
],
|
||||
},
|
||||
{ id: "section-4", label: "4. Оценка" },
|
||||
{ id: "section-5", label: "5. Атмосфера" },
|
||||
{
|
||||
id: "section-6",
|
||||
label: "6. Прогноз",
|
||||
sub: [
|
||||
{ id: "section-6-1", label: "6.1 Прогноз по горизонтам" },
|
||||
{ id: "section-6-2", label: "6.2 Сценарии" },
|
||||
{ id: "section-6-3", label: "6.3 Уверенность" },
|
||||
{ id: "section-6-4", label: "6.4 Рекомендация по продукту" },
|
||||
{ id: "section-6-5", label: "6.5 Прозрачность скоринга" },
|
||||
{ id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// All section IDs in scroll order (for IntersectionObserver)
|
||||
const ALL_SECTION_IDS: string[] = NAV_SECTIONS.flatMap((s) =>
|
||||
s.sub ? [s.id, ...s.sub.map((sub) => sub.id)] : [s.id],
|
||||
);
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AnalysisSidebar() {
|
||||
const [activeId, setActiveId] = useState<string>(ALL_SECTION_IDS[0]);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
// Scrollspy via IntersectionObserver
|
||||
useEffect(() => {
|
||||
const candidates = ALL_SECTION_IDS.map((id) =>
|
||||
document.getElementById(id),
|
||||
).filter((el): el is HTMLElement => el !== null);
|
||||
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
// Track which sections are visible; pick topmost visible one
|
||||
const visible = new Set<string>();
|
||||
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
visible.add(entry.target.id);
|
||||
} else {
|
||||
visible.delete(entry.target.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Pick the topmost section that is currently visible
|
||||
const next = ALL_SECTION_IDS.find((id) => visible.has(id));
|
||||
if (next) setActiveId(next);
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
// Trigger when section top enters top 60% of viewport
|
||||
rootMargin: "-8px 0px -40% 0px",
|
||||
threshold: 0,
|
||||
},
|
||||
);
|
||||
|
||||
candidates.forEach((el) => observerRef.current!.observe(el));
|
||||
|
||||
return () => {
|
||||
observerRef.current?.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleAnchorClick(
|
||||
e: React.MouseEvent<HTMLAnchorElement>,
|
||||
targetId: string,
|
||||
) {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById(targetId);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
setActiveId(targetId);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
style={{
|
||||
width: 240,
|
||||
flexShrink: 0,
|
||||
background: "var(--bg-card)",
|
||||
borderRight: "1px solid var(--border-card)",
|
||||
padding: "16px 12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
position: "sticky",
|
||||
top: 56,
|
||||
height: "calc(100vh - 56px)",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{/* Section label */}
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
color: "var(--fg-tertiary)",
|
||||
margin: "0 0 8px",
|
||||
padding: "0 4px",
|
||||
}}
|
||||
>
|
||||
Навигация
|
||||
</p>
|
||||
|
||||
{/* Nav items */}
|
||||
{NAV_SECTIONS.map((section) => {
|
||||
const isParentActive =
|
||||
activeId === section.id ||
|
||||
section.sub?.some((s) => s.id === activeId);
|
||||
|
||||
return (
|
||||
<div key={section.id}>
|
||||
<a
|
||||
href={`#${section.id}`}
|
||||
onClick={(e) => handleAnchorClick(e, section.id)}
|
||||
style={{
|
||||
display: "block",
|
||||
padding: "7px 10px",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: isParentActive ? 600 : 400,
|
||||
color: isParentActive ? "var(--accent)" : "var(--fg-secondary)",
|
||||
background: isParentActive
|
||||
? "var(--accent-soft)"
|
||||
: "transparent",
|
||||
textDecoration: "none",
|
||||
transition: "background 100ms, color 100ms",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
</a>
|
||||
|
||||
{/* Sub-sections */}
|
||||
{section.sub && (
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: 12,
|
||||
marginTop: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{section.sub.map((sub) => {
|
||||
const isSubActive = activeId === sub.id;
|
||||
return (
|
||||
<a
|
||||
key={sub.id}
|
||||
href={`#${sub.id}`}
|
||||
onClick={(e) => handleAnchorClick(e, sub.id)}
|
||||
style={{
|
||||
display: "block",
|
||||
padding: "5px 10px",
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: isSubActive ? 600 : 400,
|
||||
color: isSubActive
|
||||
? "var(--accent)"
|
||||
: "var(--fg-tertiary)",
|
||||
background: isSubActive
|
||||
? "var(--accent-soft)"
|
||||
: "transparent",
|
||||
textDecoration: "none",
|
||||
transition: "background 100ms, color 100ms",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{sub.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Sources footer */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: "auto",
|
||||
paddingTop: 16,
|
||||
borderTop: "1px solid var(--border-soft)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
color: "var(--fg-tertiary)",
|
||||
margin: "0 0 6px",
|
||||
padding: "0 4px",
|
||||
}}
|
||||
>
|
||||
Источники
|
||||
</p>
|
||||
{[
|
||||
{ label: "Росреестр / ЕГРН", href: "https://rosreestr.gov.ru" },
|
||||
{ label: "НСПД", href: "https://nspd.gov.ru" },
|
||||
{ label: "2ГИС / OSM", href: "https://2gis.ru" },
|
||||
].map(({ label, href }) => (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "4px 4px",
|
||||
fontSize: 11,
|
||||
color: "var(--fg-tertiary)",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={10} strokeWidth={1.5} />
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,410 +0,0 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* MassingEconomics — LIVE financial KPI strip for «7. Концепция» (#1965 Stage 2b,
|
||||
* epic #1953).
|
||||
*
|
||||
* Driven by the interactive 3D MassingScene: every time the user drags the
|
||||
* этажность / секций sliders, Section7Concept maps the scene's `computeModel`
|
||||
* result + the analysis context into a `MassingProgram` and hands it here via
|
||||
* `program`. We POST it to `/api/v1/concepts/recompute` (debounced ~250 ms) and
|
||||
* render the recomputed ТЭП + финмодель (NPV / IRR / выручка / себестоимость /
|
||||
* прибыль / ROI).
|
||||
*
|
||||
* Robustness:
|
||||
* • debounce — slider drags fire many programs; only the settled one is sent.
|
||||
* • latest-wins — an in-flight request is superseded by a newer one via a
|
||||
* monotonic request id; a stale response is dropped, never overwriting a
|
||||
* fresher result (mutateAsync + id guard, no UI flicker from out-of-order).
|
||||
* • last-good — on a failed recompute we keep the last successful values and
|
||||
* show a subtle inline note rather than blanking the panel.
|
||||
* • skeleton — a plain grey fade KPI grid while the FIRST recompute is in
|
||||
* flight (no shimmer, per ui-conventions).
|
||||
*
|
||||
* Light-theme only (Section7 is light): the 3D viewport stays dark-canvas, but
|
||||
* this strip uses the light KPI tokens via the shared KpiCard.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
import { KpiCard } from "@/components/analytics/KpiCard";
|
||||
import { Section } from "@/components/analytics/Section";
|
||||
import {
|
||||
priceSourceCaption,
|
||||
useRecomputeMassing,
|
||||
type FinancialModel,
|
||||
type MassingProgram,
|
||||
type MassingRecomputeOutput,
|
||||
type Teap,
|
||||
} from "@/lib/concept-api";
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
// ── Formatters (ru microcopy, shared shape with ConceptVariantsResult) ─────────
|
||||
|
||||
const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
|
||||
|
||||
/** Compact ₽ for headline figures: "2.4 млрд ₽", "145 млн ₽". */
|
||||
function formatMoneyCompact(rub: number): string {
|
||||
const abs = Math.abs(rub);
|
||||
if (abs >= 1e9) return `${(rub / 1e9).toFixed(1)} млрд ₽`;
|
||||
if (abs >= 1e6) return `${(rub / 1e6).toFixed(0)} млн ₽`;
|
||||
return `${nf.format(Math.round(rub))} ₽`;
|
||||
}
|
||||
|
||||
function formatInt(n: number): string {
|
||||
return nf.format(Math.round(n));
|
||||
}
|
||||
|
||||
function formatPct(fraction: number): string {
|
||||
return `${(fraction * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatFar(far: number): string {
|
||||
return far.toLocaleString("ru-RU", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
// ── KPI grid ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface KpiGridProps {
|
||||
teap: Teap;
|
||||
financial: FinancialModel;
|
||||
/** Регламентная КСИТ-цель (max_far) — to flag the КСИТ over-cap. */
|
||||
farTarget: number;
|
||||
/** True → факт-КСИТ превышает регламентный потолок (model.over). */
|
||||
ksitOver: boolean;
|
||||
/** Dim the strip while a fresher recompute is in flight (last-good values). */
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
function KpiGrid({
|
||||
teap,
|
||||
financial,
|
||||
farTarget,
|
||||
ksitOver,
|
||||
stale,
|
||||
}: KpiGridProps) {
|
||||
const netPositive =
|
||||
financial.net_profit_rub > 0
|
||||
? true
|
||||
: financial.net_profit_rub < 0
|
||||
? false
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
opacity: stale ? 0.55 : 1,
|
||||
transition: "opacity 150ms linear",
|
||||
}}
|
||||
>
|
||||
{/* ТЭП */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<KpiCard
|
||||
label="Общая площадь (GFA)"
|
||||
value={formatInt(teap.total_floor_area_sqm)}
|
||||
unit="м²"
|
||||
hint="Поэтажная площадь всех корпусов = пятно застройки × этажность."
|
||||
/>
|
||||
<KpiCard
|
||||
label="Продаваемая площадь"
|
||||
value={formatInt(teap.residential_area_sqm)}
|
||||
unit="м²"
|
||||
hint="Жилая к продаже = (GFA − нежилой 1-й этаж) × коэффициент эффективности класса."
|
||||
/>
|
||||
<KpiCard
|
||||
label="Квартир"
|
||||
value={formatInt(teap.apartments_count)}
|
||||
unit="шт"
|
||||
hint="Продаваемая площадь ÷ средняя площадь квартиры класса."
|
||||
/>
|
||||
<KpiCard
|
||||
label="КСИТ — факт / цель"
|
||||
value={`${formatFar(teap.density)} / ${formatFar(farTarget)}`}
|
||||
delta={{
|
||||
value: ksitOver
|
||||
? "Факт превышает регламентный потолок"
|
||||
: "В пределах регламента",
|
||||
positive: ksitOver ? false : true,
|
||||
}}
|
||||
hint="КСИТ (коэффициент строительного использования) = надземная GFA ÷ площадь участка. Цель — предельный max_far по регламенту НСПД."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Финмодель */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
<KpiCard
|
||||
label="Выручка (GDV)"
|
||||
value={formatMoneyCompact(financial.revenue_rub)}
|
||||
hint="Продаваемая площадь × цена продажи м² (+ машиноместа и нежилой 1-й этаж по ценам класса)."
|
||||
/>
|
||||
<KpiCard
|
||||
label="Себестоимость"
|
||||
value={formatMoneyCompact(financial.cost_rub)}
|
||||
hint="Строительство (GFA × удельная по классу) + ПИР, сети, услуги застройщика, непредвиденные, маркетинг + стоимость земли."
|
||||
/>
|
||||
<KpiCard
|
||||
label="Чистая прибыль"
|
||||
value={formatMoneyCompact(financial.net_profit_rub)}
|
||||
delta={{
|
||||
value:
|
||||
netPositive === true
|
||||
? "Положительная (после НДС и налога на прибыль)"
|
||||
: netPositive === false
|
||||
? "Отрицательная (после НДС и налога на прибыль)"
|
||||
: "Нулевая",
|
||||
positive: netPositive,
|
||||
}}
|
||||
hint="Выручка − себестоимость − НДС на нежилое − налог на прибыль 25%. Жильё по ДДУ от НДС освобождено."
|
||||
/>
|
||||
<KpiCard
|
||||
label="ROI на затраты"
|
||||
value={formatPct(financial.roi)}
|
||||
delta={{
|
||||
value: `Чистая маржа на выручку ${formatPct(financial.margin_pct)}`,
|
||||
positive: null,
|
||||
}}
|
||||
hint="Чистая прибыль ÷ себестоимость. Маржа считается от выручки."
|
||||
/>
|
||||
<KpiCard
|
||||
label="NPV (DCF)"
|
||||
value={formatMoneyCompact(financial.npv_rub)}
|
||||
delta={{
|
||||
value: `Дисконт ${formatPct(financial.discount_rate_used)} годовых`,
|
||||
positive:
|
||||
financial.npv_rub > 0
|
||||
? true
|
||||
: financial.npv_rub < 0
|
||||
? false
|
||||
: null,
|
||||
}}
|
||||
hint={`Сумма дисконтированных помесячных денежных потоков по графику стройки и продаж (ставка дисконта ${formatPct(
|
||||
financial.discount_rate_used,
|
||||
)} годовых).`}
|
||||
/>
|
||||
<KpiCard
|
||||
label="IRR (DCF, годовой)"
|
||||
value={formatPct(financial.irr)}
|
||||
delta={{
|
||||
value: financial.irr_is_proxy
|
||||
? "Оценочный (вырожденный поток, не DCF)"
|
||||
: "Дисконтированный денежный поток",
|
||||
positive: financial.irr_is_proxy
|
||||
? null
|
||||
: financial.irr > financial.discount_rate_used
|
||||
? true
|
||||
: false,
|
||||
}}
|
||||
hint="Годовая внутренняя ставка доходности тех же денежных потоков (ставка, при которой NPV = 0)."
|
||||
/>
|
||||
<KpiCard
|
||||
label="Цена продажи жилья"
|
||||
value={`${formatInt(financial.price_per_sqm_used)} ₽/м²`}
|
||||
delta={{ value: priceSourceCaption(financial), positive: null }}
|
||||
hint="Цена, заложенная в выручку: медиана объявлений Objective по району (источник указан выше)."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Skeleton (grey fade, no shimmer — ui-conventions) ──────────────────────────
|
||||
|
||||
function SkeletonGrid() {
|
||||
const cells = Array.from({ length: 7 });
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{cells.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height: 92,
|
||||
background: "var(--bg-card-alt)",
|
||||
border: "1px solid var(--border-card)",
|
||||
borderRadius: 12,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Component ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* The current massing program (Σ footprint × floors + context), mapped by
|
||||
* Section7Concept from the 3D scene's computeModel result. `null` until the
|
||||
* scene has fired its first onModelChange (or when geometry is missing).
|
||||
*/
|
||||
program: MassingProgram | null;
|
||||
/** Регламентная КСИТ-цель (max_far) — for the факт/цель comparison + over-cap. */
|
||||
farTarget: number;
|
||||
/** True when факт-КСИТ exceeds the cap (model.over, computed scene-side). */
|
||||
ksitOver: boolean;
|
||||
/**
|
||||
* True when the parcel is regulatorily constrained for МКД (gate-blocked /
|
||||
* non-residential / ЗОУИТ-СЗЗ) — drives the honest «условный расчёт» caveat
|
||||
* below the strip alongside the negative-economics case.
|
||||
*/
|
||||
gateConstrained?: boolean;
|
||||
}
|
||||
|
||||
export function MassingEconomics({
|
||||
program,
|
||||
farTarget,
|
||||
ksitOver,
|
||||
gateConstrained = false,
|
||||
}: Props) {
|
||||
const recompute = useRecomputeMassing();
|
||||
|
||||
// Last successful result kept locally so a failed/stale recompute never blanks
|
||||
// the panel (last-good values stay on screen).
|
||||
const [result, setResult] = useState<MassingRecomputeOutput | null>(null);
|
||||
const [errored, setErrored] = useState(false);
|
||||
|
||||
// Monotonic request id → latest-wins: a response is applied only if it belongs
|
||||
// to the most recently issued request, so out-of-order arrivals are dropped.
|
||||
const reqIdRef = useRef(0);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Stable JSON key so we only recompute when the program actually changes
|
||||
// (not on every parent re-render that hands an equal-but-new object).
|
||||
const programKey = program ? JSON.stringify(program) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!program) return;
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const id = ++reqIdRef.current;
|
||||
recompute
|
||||
.mutateAsync(program)
|
||||
.then((out) => {
|
||||
if (id !== reqIdRef.current) return; // stale — a newer request won.
|
||||
setResult(out);
|
||||
setErrored(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (id !== reqIdRef.current) return; // stale failure — ignore.
|
||||
setErrored(true); // keep last-good `result`.
|
||||
});
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
// recompute is a stable mutation object; we key off the serialized program.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [programKey]);
|
||||
|
||||
// No program yet → nothing to show (parent gates this on geometry anyway).
|
||||
if (!program) return null;
|
||||
|
||||
// First recompute in flight, no last-good value yet → skeleton.
|
||||
if (!result) {
|
||||
return (
|
||||
<Section
|
||||
title="Экономика по 3D-модели"
|
||||
subtitle="Пересчёт ТЭП и финмодели по текущей массе застройки (этажность / секции) из 3D-модели слева."
|
||||
>
|
||||
<SkeletonGrid />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// A fresher request is in flight over the last-good values.
|
||||
const stale = recompute.isPending;
|
||||
|
||||
// Honest «условный расчёт» caveat: when the economics turn negative OR the
|
||||
// parcel is regulatorily constrained (gate-blocked / ЗОУИТ-СЗЗ / нежилое),
|
||||
// we say so plainly rather than presenting the figures as a viable project.
|
||||
const economicsNegative =
|
||||
result.financial.net_profit_rub < 0 || result.financial.npv_rub < 0;
|
||||
const showConditionalNote = economicsNegative || gateConstrained;
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Экономика по 3D-модели"
|
||||
subtitle="Пересчитывается вживую при изменении этажности / секций в 3D-модели слева. Цена продажи — из оценки участка, без рыночного DB-запроса; ТЭП синтезируется из массинг-программы."
|
||||
>
|
||||
<KpiGrid
|
||||
teap={result.teap}
|
||||
financial={result.financial}
|
||||
farTarget={farTarget}
|
||||
ksitOver={ksitOver}
|
||||
stale={stale}
|
||||
/>
|
||||
|
||||
{showConditionalNote ? (
|
||||
<p
|
||||
role="note"
|
||||
style={{
|
||||
margin: "12px 0 0",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
lineHeight: "16px",
|
||||
color: "var(--warn)",
|
||||
}}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
style={{ flexShrink: 0, marginTop: 1 }}
|
||||
/>
|
||||
<span>
|
||||
Расчёт условный: участок ограничен регламентом (см. блокеры выше)
|
||||
и/или экономика отрицательна при текущих вводных. Измените
|
||||
этажность, число секций или класс — модель пересчитается.
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{errored ? (
|
||||
<p
|
||||
role="status"
|
||||
style={{
|
||||
margin: "12px 0 0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
color: "var(--warn)",
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
Не удалось пересчитать экономику по последнему изменению — показаны
|
||||
предыдущие значения. Измените параметры ещё раз для повторного
|
||||
расчёта.
|
||||
</p>
|
||||
) : null}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,6 +28,10 @@ interface Props {
|
|||
cad: string;
|
||||
/** Full analysis data — used for Section 3.2/3.3 placeholders, competitors. */
|
||||
data: ParcelAnalysis;
|
||||
/** Уже применённые POI-веса; null = ничего не применяли (системные). */
|
||||
weights: Record<PoiCategoryKey, number> | null;
|
||||
/** «Применить» в панели весов — страница перезапрашивает analyze (#2790). */
|
||||
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
|
|
@ -86,25 +90,18 @@ function FilterChip({ label, selected, onToggle }: ChipProps) {
|
|||
function Section31Settings({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
weights,
|
||||
onWeightsApply,
|
||||
}: {
|
||||
filters: FilterState;
|
||||
onFiltersChange: (f: FilterState) => void;
|
||||
weights: Record<PoiCategoryKey, number> | null;
|
||||
onWeightsApply: (weights: Record<PoiCategoryKey, number>) => void;
|
||||
}) {
|
||||
const [weights, setWeights] = useState<Record<PoiCategoryKey, number>>(
|
||||
() => ({ ...POI_DEFAULT_WEIGHTS }),
|
||||
);
|
||||
|
||||
function toggleChip(key: keyof Omit<FilterState, "radiusKm">) {
|
||||
onFiltersChange({ ...filters, [key]: !filters[key] });
|
||||
}
|
||||
|
||||
function handleWeightsChange(
|
||||
newWeights: Record<PoiCategoryKey, number>,
|
||||
_profileId: number | null,
|
||||
) {
|
||||
setWeights(newWeights);
|
||||
}
|
||||
|
||||
const chips: Array<{
|
||||
key: keyof Omit<FilterState, "radiusKm">;
|
||||
label: string;
|
||||
|
|
@ -136,8 +133,8 @@ function Section31Settings({
|
|||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
Фильтры применяются к конкурентам локально — без повторного запроса к
|
||||
бэкенду
|
||||
Радиус и фильтры применяются к конкурентам локально. Веса POI —
|
||||
пересчёт анализа на бэкенде по кнопке «Применить»
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -259,8 +256,8 @@ function Section31Settings({
|
|||
Профиль весов POI
|
||||
</div>
|
||||
<WeightProfilePanel
|
||||
currentWeights={weights}
|
||||
onWeightsChange={handleWeightsChange}
|
||||
currentWeights={weights ?? POI_DEFAULT_WEIGHTS}
|
||||
onWeightsChange={onWeightsApply}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -769,7 +766,12 @@ function applyFilters(
|
|||
|
||||
// ── Section 3 wrapper ─────────────────────────────────────────────────────────
|
||||
|
||||
export function Section3SettingsAndCompetitors({ cad, data }: Props) {
|
||||
export function Section3SettingsAndCompetitors({
|
||||
cad,
|
||||
data,
|
||||
weights,
|
||||
onWeightsApply,
|
||||
}: Props) {
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
radiusKm: 2,
|
||||
onlyUnderConstruction: false,
|
||||
|
|
@ -821,7 +823,12 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
|
|||
<StageDetails>
|
||||
{/* Sub-sections */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
<Section31Settings filters={filters} onFiltersChange={setFilters} />
|
||||
<Section31Settings
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
weights={weights}
|
||||
onWeightsApply={onWeightsApply}
|
||||
/>
|
||||
|
||||
{/* Competitor table — moved before 3.2/3.3 for context */}
|
||||
{filteredCompetitors.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -1,104 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Building2 } from "lucide-react";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function getStoredOrgId(): string | null {
|
||||
// Guard against SSR — localStorage not available on server
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return localStorage.getItem("gd_org_id");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function orgInitials(orgId: string): string {
|
||||
// Build a 2-letter monogram from org ID string
|
||||
const parts = orgId
|
||||
.toUpperCase()
|
||||
.replace(/[^A-ZА-Я0-9]/gu, " ")
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
if (parts.length === 0) return "??";
|
||||
if (parts.length === 1) return parts[0].slice(0, 2);
|
||||
return parts[0][0] + parts[1][0];
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function UserAvatar() {
|
||||
const [orgId, setOrgId] = useState<string | null>(null);
|
||||
|
||||
// Hydration-safe: read localStorage after mount
|
||||
useEffect(() => {
|
||||
setOrgId(getStoredOrgId());
|
||||
}, []);
|
||||
|
||||
const displayLabel = orgId ?? "Demo Org";
|
||||
const initials = orgId ? orgInitials(orgId) : "DO";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title={displayLabel}
|
||||
>
|
||||
{/* Avatar circle */}
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
background: "var(--accent-soft)",
|
||||
border: "1px solid var(--border-card)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{orgId ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "var(--accent)",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
) : (
|
||||
<Building2
|
||||
size={14}
|
||||
strokeWidth={1.5}
|
||||
style={{ color: "var(--accent)" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Org name — hidden on narrow viewports via maxWidth trick */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: "var(--fg-secondary)",
|
||||
maxWidth: 140,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{displayLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* PticaPlaceholderPanel — honest "В разработке" panel for cockpit tabs/sections
|
||||
* that aren't wired in INCREMENT 1 (Scenarios / Reports / Compare).
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function PticaPlaceholderPanel({ label, hint }: Props) {
|
||||
return (
|
||||
<div className={`${styles.panel} ${styles.placeholderPanel}`}>
|
||||
<div className={styles.soon}>{label}</div>
|
||||
{hint && <p>{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,11 +12,7 @@ import type { ParcelAnalysis } from "@/types/site-finder";
|
|||
// /fetch-status every 2s, re-triggers analyze когда status=ready.
|
||||
|
||||
export type FetchStatus =
|
||||
| "ready"
|
||||
| "fetching"
|
||||
| "failed"
|
||||
| "not_in_nspd"
|
||||
| "invalid_format";
|
||||
"ready" | "fetching" | "failed" | "not_in_nspd" | "invalid_format";
|
||||
|
||||
export interface FetchStatusResponse {
|
||||
status: FetchStatus;
|
||||
|
|
@ -52,6 +48,34 @@ export interface AnalyzeOptions {
|
|||
weights?: Record<string, number> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Собрать options для POST /analyze из состояния панели весов POI.
|
||||
*
|
||||
* 🔴 `profileId` уходит на бэкенд ТОЛЬКО в паре с `profileUserId`. Причина не
|
||||
* стилистическая: `resolve_weights()` ищет профиль как `get_profile(db, user_id,
|
||||
* profile_id)` — при `user_id=None` условие `profile_id is not None and user_id is
|
||||
* not None` не выполняется, и веса тихо падают на системные. Ответ при этом
|
||||
* рапортует `weights_profile.source = "profile"`, то есть врёт (проверено на проде
|
||||
* #2782: `profile_id=1` без `profile_user_id` → `tram_stop −0.5` вместо `−0.4` из
|
||||
* профиля). Юзер бы видел ползунки профиля и score, посчитанный по другим весам.
|
||||
*
|
||||
* Когда пользователь неизвестен (dev без Caddy: /api/v1/me → 401) — шлём inline
|
||||
* `weights`. Они всегда равны ползункам панели, так что расхождения нет.
|
||||
*/
|
||||
export function buildAnalyzeOptions(
|
||||
weights: Record<string, number>,
|
||||
profileId: number | null,
|
||||
profileUserId: string,
|
||||
): AnalyzeOptions {
|
||||
if (profileId != null && profileUserId) {
|
||||
return { profileId, profileUserId };
|
||||
}
|
||||
if (profileUserId) {
|
||||
return { profileUserId, weights };
|
||||
}
|
||||
return { weights };
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook для analyze flow с graceful on-demand fetch fallback.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
* directly with a real AbortSignal and a per-URL `fetch` stub, under fake
|
||||
* timers, and assert on abort behaviour + the happy path.
|
||||
*/
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Capture the options passed to useQuery ───────────────────────────────────
|
||||
|
|
@ -117,12 +118,15 @@ const CAD = "66:41:0701045:42";
|
|||
* polling queryFn. Reads `captured.options` via a fresh binding so TS control-
|
||||
* flow doesn't pin it (the hook mutates it opaquely through the mock).
|
||||
*
|
||||
* `useQuery` is fully mocked (it just records its options, no React state), so
|
||||
* the rules-of-hooks invariant does not apply to this call — disable locally.
|
||||
* Хук зовём через `renderHook`, а не напрямую: с #2790 он читает применённые
|
||||
* веса из `AnalyzeWeightsContext` (`useContext`), а вне рендера у React нет
|
||||
* dispatcher'а → «Cannot read properties of null». `useQuery` по-прежнему
|
||||
* замокан и просто записывает options; провайдера над хуком нет, значит
|
||||
* контекст = null, то есть ровно тот случай «весов не применяли», который этот
|
||||
* тест и гоняет.
|
||||
*/
|
||||
function getQueryFn(): CapturedQueryOptions["queryFn"] {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useParcelAnalyzeQuery(CAD, 12);
|
||||
renderHook(() => useParcelAnalyzeQuery(CAD, 12));
|
||||
const options = captured.options;
|
||||
if (options === null) throw new Error("useQuery options not captured");
|
||||
return options.queryFn;
|
||||
|
|
|
|||
|
|
@ -27,14 +27,18 @@ export interface WeightProfileCreate {
|
|||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface WeightProfileUpdate {
|
||||
profile_name?: string;
|
||||
weights?: Record<string, number>;
|
||||
is_default?: boolean;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Владелец системных пресетов (Эконом / Комфорт / Бизнес) — mirrors
|
||||
* `SYSTEM_USER_ID` в backend/app/services/site_finder/weight_profiles.py.
|
||||
* Профили с этим user_id общие для всех и НЕ адресуемы через `profile_id`:
|
||||
* `resolve_weights()` ищет профиль в области владельца, у чужого пользователя
|
||||
* его не найдёт и молча вернёт системные веса с ответом `source="profile"`
|
||||
* (#2782). Их веса уходят в analyze inline — см. WeightProfilePanel.
|
||||
*/
|
||||
export const SYSTEM_PROFILE_USER_ID = "__system__";
|
||||
|
||||
// ALLOWED_CATEGORIES — mirrors backend weight_profiles.py ALLOWED_CATEGORIES.
|
||||
// Keep in sync with backend; source of truth is `_POI_WEIGHTS` in parcels.py.
|
||||
|
||||
|
|
@ -91,35 +95,44 @@ export const POI_WEIGHT_MAX = 3;
|
|||
|
||||
const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles";
|
||||
|
||||
function profilesHeaders(adminToken: string): HeadersInit {
|
||||
return { "X-Admin-Token": adminToken };
|
||||
}
|
||||
// Никакого X-Admin-Token: сервер перестал его читать в #437, а последний
|
||||
// `verify_admin_token` удалён в #2775. Заголовок отправлялся ещё год после этого и
|
||||
// ничего не решал — проверено живым запросом на проде (#2782): один и тот же 200
|
||||
// с корректным токеном, с мусорным и без заголовка вовсе.
|
||||
//
|
||||
// Реальная защита `/api/v1/admin/*` — два живых слоя, оба прод-проверены:
|
||||
// 1) Caddy basic_auth на gendsgn.ru → без валидных кред 401 ещё на периметре
|
||||
// (подставленный клиентом X-Authenticated-User туда же не проходит);
|
||||
// 2) app/main.py rbac_guard → `role != "admin"` даёт 403 "admin only".
|
||||
|
||||
// ── Hooks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** List all weight profiles for a given user_id. */
|
||||
export function useWeightProfiles(userId: string, adminToken: string) {
|
||||
/**
|
||||
* Профили пользователя + системные пресеты (#2790).
|
||||
*
|
||||
* `include_system=true` домешивает в конец списка три общих пресета (Эконом /
|
||||
* Комфорт / Бизнес, засеяны `data/sql/100_user_weight_profiles_default_seed.sql`).
|
||||
* Без него у пользователя без своих профилей дропдаун пустой — пресеты лежали в
|
||||
* проде с 16.05.2026 и не были видны никому.
|
||||
*/
|
||||
export function useWeightProfiles(userId: string) {
|
||||
return useQuery<WeightProfile[]>({
|
||||
queryKey: ["weight-profiles", userId],
|
||||
queryFn: () =>
|
||||
apiFetch<WeightProfile[]>(
|
||||
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}`,
|
||||
{
|
||||
headers: profilesHeaders(adminToken),
|
||||
},
|
||||
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}&include_system=true`,
|
||||
),
|
||||
enabled: !!userId && !!adminToken,
|
||||
enabled: !!userId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a new weight profile. Invalidates the list query on success. */
|
||||
export function useCreateProfile(adminToken: string) {
|
||||
export function useCreateProfile() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<WeightProfile, Error, WeightProfileCreate>({
|
||||
mutationFn: (payload) =>
|
||||
apiFetch<WeightProfile>(BASE_PATH, {
|
||||
method: "POST",
|
||||
headers: profilesHeaders(adminToken),
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
onSuccess: (_, variables) => {
|
||||
|
|
@ -130,43 +143,9 @@ export function useCreateProfile(adminToken: string) {
|
|||
});
|
||||
}
|
||||
|
||||
/** Update an existing weight profile by id. */
|
||||
export function useUpdateProfile(
|
||||
userId: string,
|
||||
profileId: number,
|
||||
adminToken: string,
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<WeightProfile, Error, WeightProfileUpdate>({
|
||||
mutationFn: (payload) =>
|
||||
apiFetch<WeightProfile>(
|
||||
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: profilesHeaders(adminToken),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */
|
||||
export function useDeleteProfile(userId: string, adminToken: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, Error, number>({
|
||||
mutationFn: (profileId) =>
|
||||
apiFetch<void>(
|
||||
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: profilesHeaders(adminToken),
|
||||
},
|
||||
),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
// useUpdateProfile / useDeleteProfile здесь больше нет (#2790 п.3). Их не звали
|
||||
// ниоткуда: в UI есть список и создание, кнопок «переименовать» / «удалить» нет.
|
||||
// Спрос за 3 месяца по проду: 1 профиль на всю базу (`admin`, создан 15.05.2026,
|
||||
// updated_at = created_at) + 3 системных пресета — ни одного изменения и ни
|
||||
// одной попытки удаления. PUT/DELETE-эндпоинты живы и покрыты тестами бэкенда;
|
||||
// понадобится UI — хуки вернутся из истории (мертвее они там не станут).
|
||||
|
|
|
|||
|
|
@ -330,7 +330,7 @@ export interface MassingRecomputeOutput {
|
|||
* rule; uses the shared `apiFetch` (base URL + session header + Content-Type).
|
||||
*
|
||||
* Stage 2b drives this off the 3D MassingScene's `onModelChange` (debounced),
|
||||
* with latest-wins sequencing handled by the caller (see MassingEconomics).
|
||||
* with latest-wins sequencing handled by the caller.
|
||||
*/
|
||||
export function useRecomputeMassing() {
|
||||
return useMutation<MassingRecomputeOutput, Error, MassingProgram>({
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
* и иметь один безопасный канал кодирования кадастрового номера.
|
||||
*
|
||||
* Существующие потребители паттерна (до централизации): NspdZoningBlock,
|
||||
* NspdOpportunityBlock, ParcelDrawer, AnalysisSidebar.
|
||||
* NspdOpportunityBlock, ParcelDrawer.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
*/
|
||||
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { createContext, useContext } from "react";
|
||||
import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api";
|
||||
import { abortableSleep } from "@/lib/abortableSleep";
|
||||
import type {
|
||||
|
|
@ -503,9 +504,36 @@ export interface PoiScoreResponse {
|
|||
const ANALYZE_POLL_INTERVAL_MS = 2000;
|
||||
const ANALYZE_POLL_MAX_ITERATIONS = 60; // 60 × 2s = 2 min hard cap
|
||||
|
||||
/**
|
||||
* Применённые в §4.1 POI-веса (#2790). `null` = ничего не применяли → запрос
|
||||
* уходит без тела, как и раньше (бэкенд считает по системным весам).
|
||||
*
|
||||
* Почему контекст, а не проп: на странице анализа `useParcelAnalyzeQuery(cad)`
|
||||
* зовут ШЕСТЬ мест (§1, §2, §4, §5, сама страница, /ptica) — все они делят один
|
||||
* ключ кэша `["parcel-analyze", cad, horizon]` и один дорогой (10-30 c) запрос.
|
||||
* Если веса доедут только до части из них, ключи разойдутся: половина страницы
|
||||
* покажет скор по одним весам, половина по другим, и /analyze уйдёт дважды.
|
||||
* Контекст держит всех потребителей ключа на одном значении по построению —
|
||||
* забыть прокинуть проп в новую секцию нельзя.
|
||||
*/
|
||||
export const AnalyzeWeightsContext = createContext<Record<
|
||||
string,
|
||||
number
|
||||
> | null>(null);
|
||||
|
||||
export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
|
||||
const weights = useContext(AnalyzeWeightsContext);
|
||||
// Стабильный кусок ключа: порядок ключей объекта не гарантирован, сортируем.
|
||||
// null (весов не применяли) оставляем null — ключ тогда совпадает с ключом до
|
||||
// #2790, кэш не сбрасывается на ровном месте.
|
||||
const weightsKey = weights
|
||||
? JSON.stringify(Object.entries(weights).sort())
|
||||
: null;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["parcel-analyze", cad, horizon],
|
||||
// Префикс ["parcel-analyze", cad] сохранён: по нему инвалидируют custom-POI
|
||||
// мутации (useCustomPois) — они матчатся по префиксу, любой хвост подойдёт.
|
||||
queryKey: ["parcel-analyze", cad, horizon, weightsKey],
|
||||
// TanStack Query v5 passes an AbortSignal in the queryFn context; it aborts
|
||||
// on unmount and whenever the queryKey changes (смена cad/horizon). Thread
|
||||
// it through the POST/GET fetches and check it before each poll iteration so
|
||||
|
|
@ -522,11 +550,19 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
|
|||
cad,
|
||||
)}/analyze?horizon=${horizon}`;
|
||||
|
||||
// Inline POI-веса (#201) из §4.1. Шлём именно inline, а не profile_id:
|
||||
// тело запроса == ползункам панели, и ответ рапортует source="inline" —
|
||||
// расхождению между показанными весами и посчитанным скором взяться
|
||||
// неоткуда (в отличие от profile_id, см. #2782).
|
||||
const analyzeInit: RequestInit = weights
|
||||
? { method: "POST", signal, body: JSON.stringify({ weights }) }
|
||||
: { method: "POST", signal };
|
||||
|
||||
// First request — POST /analyze. apiFetchWithStatus surfaces the 202
|
||||
// Accepted code instead of treating it as a successful payload.
|
||||
const first = await apiFetchWithStatus<
|
||||
ParcelAnalyzeResponse | AnalyzeAcceptedResponse
|
||||
>(analyzeUrl, { method: "POST", signal });
|
||||
>(analyzeUrl, analyzeInit);
|
||||
|
||||
// 200 → geometry was cached, full analysis is ready.
|
||||
if (first.status === 200) {
|
||||
|
|
@ -553,7 +589,7 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
|
|||
// rather than returning the stub (symmetry with the first request).
|
||||
const second = await apiFetchWithStatus<
|
||||
ParcelAnalyzeResponse | AnalyzeAcceptedResponse
|
||||
>(analyzeUrl, { method: "POST", signal });
|
||||
>(analyzeUrl, analyzeInit);
|
||||
if (second.status === 200) {
|
||||
return second.body as ParcelAnalyzeResponse;
|
||||
}
|
||||
|
|
|
|||
242
scripts/check-migration-lock-timeout.py
Normal file
242
scripts/check-migration-lock-timeout.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Гейт: новая миграция с блокирующим DDL обязана нести `SET LOCAL lock_timeout` (#2752).
|
||||
|
||||
ПОЧЕМУ. 2026-08-07 миграция 250 (`DROP INDEX` на таблице в 1061 строку) встала
|
||||
на боевой БД: сам DROP берёт лок за миллисекунды, но ЖДАЛ его выдачи 29 минут за
|
||||
чужой аналитической psql-сессией; вторая попытка деплоя — ещё 16 минут. Опасность
|
||||
не в простое деплоя: ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми
|
||||
запросами, поэтому обычный SELECT приложения по той же таблице начинает ждать за
|
||||
ним. В тот раз обошлось, но `lock_timeout` не стоял НИ В ОДНОЙ миграции обоих
|
||||
data/sql — то есть следующий блокирующий DDL повторил бы это.
|
||||
|
||||
`SET LOCAL` ограничивает ТОЛЬКО ожидание лока, не работу под ним: длинный
|
||||
CREATE INDEX он не оборвёт, а очередь — не соберёт. Срабатывание = красный деплой
|
||||
(ON_ERROR_STOP=on) вместо тихой очереди перед приложением.
|
||||
|
||||
ПОЧЕМУ НЕ ОДНИМ `lock_timeout` В РАННЕРЕ (проверено, а не предположено). Вариант
|
||||
«задать один раз перед циклом миграций» отвергнут замером на PostgreSQL 16.4:
|
||||
`PGOPTIONS="-c lock_timeout=5s"` действительно доезжает до сервера (`SHOW
|
||||
lock_timeout` → 5s), но session-wide значение ОБРЫВАЕТ `CREATE INDEX
|
||||
CONCURRENTLY` — тот ждёт завершения параллельных транзакций через VirtualXactLock,
|
||||
и это ожидание тоже под lock_timeout. В замере CIC упал через 5 s, когда встречная
|
||||
сессия просто держала открытую транзакцию (ACCESS SHARE — с CIC вообще не
|
||||
конфликтует), и ОСТАВИЛ невалидный индекс. То есть runner-wide значение
|
||||
изготавливало бы ровно ту аварию, от которой заведена проверка невалидных
|
||||
индексов в deploy-workflow'ах. Блокирующий DDL и CONCURRENTLY хотят
|
||||
противоположной политики, поэтому granularity — файл, а не раннер.
|
||||
|
||||
ЧТО ТРЕБУЕТСЯ ОТ ФАЙЛА: `SET LOCAL` (не голый `SET`: голый доживёт до конца
|
||||
сессии и обрежет CIC в том же файле), ПОСЛЕ `BEGIN` (вне транзакции `SET LOCAL`
|
||||
молча ничего не делает, только WARNING) и ДО первого блокирующего стейтмента.
|
||||
|
||||
ГРАНДФАЗЕРИНГ: миграции ниже порога уже применены на проде, а применённые файлы
|
||||
задним числом не переписываются. Гейт смотрит только вперёд.
|
||||
|
||||
Запуск: python3 scripts/check-migration-lock-timeout.py [--selftest]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# каталог миграций -> минимальный NN, с которого правило обязательно.
|
||||
# data/sql: последняя на 2026-08-07 — 188_*; tradein: 250_* (та самая).
|
||||
SQL_DIRS: dict[str, int] = {
|
||||
"data/sql": 189,
|
||||
"tradein-mvp/backend/data/sql": 250,
|
||||
}
|
||||
|
||||
# DDL, берущий лок, который конфликтует с трафиком приложения (ACCESS EXCLUSIVE,
|
||||
# у CREATE INDEX / REFRESH MV — SHARE / ACCESS EXCLUSIVE). Всё это может встать
|
||||
# в очередь и увести за собой запросы приложения.
|
||||
BLOCKING = re.compile(
|
||||
r"\b(?:"
|
||||
r"ALTER\s+TABLE|ALTER\s+MATERIALIZED\s+VIEW|"
|
||||
r"DROP\s+INDEX|CREATE\s+(?:UNIQUE\s+)?INDEX|REINDEX|"
|
||||
r"DROP\s+(?:MATERIALIZED\s+)?VIEW|REFRESH\s+MATERIALIZED\s+VIEW|"
|
||||
r"DROP\s+TABLE|TRUNCATE|CLUSTER|VACUUM\s+FULL"
|
||||
r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# CONCURRENTLY-форма НЕ требует lock_timeout и не терпит его (см. шапку).
|
||||
# Исключение по-стейтментно, не по-файлово: файл с CIC И с ALTER TABLE
|
||||
# по-прежнему обязан прикрыть свой ALTER.
|
||||
CONCURRENTLY = re.compile(r"\bCONCURRENTLY\b", re.IGNORECASE)
|
||||
|
||||
BEGIN_STMT = re.compile(r"^\s*(?:BEGIN|START\s+TRANSACTION)\b", re.IGNORECASE)
|
||||
SET_LOCAL_LT = re.compile(r"^\s*SET\s+LOCAL\s+lock_timeout\b", re.IGNORECASE)
|
||||
SET_BARE_LT = re.compile(r"^\s*SET\s+(?!LOCAL\b)(?:SESSION\s+)?lock_timeout\b", re.IGNORECASE)
|
||||
NN_PREFIX = re.compile(r"^(\d+)")
|
||||
|
||||
|
||||
def strip_noise(sql: str) -> str:
|
||||
"""Убирает `--` и `/* */` комментарии, а тела строковых литералов заменяет на
|
||||
пробелы (сохраняя длину и переводы строк — номера строк не съезжают).
|
||||
|
||||
Дословный текст литералов не нужен, а вреден: в COMMENT ON ... IS '...'
|
||||
легко встречается слово ALTER TABLE, и без затирания гейт ловил бы прозу.
|
||||
Тела $$...$$ (DO-блоки) НЕ затираются — там живёт исполняемый DDL.
|
||||
"""
|
||||
out: list[str] = []
|
||||
i, n = 0, len(sql)
|
||||
while i < n:
|
||||
ch = sql[i]
|
||||
nxt = sql[i + 1] if i + 1 < n else ""
|
||||
if ch == "-" and nxt == "-":
|
||||
while i < n and sql[i] != "\n":
|
||||
out.append(" ")
|
||||
i += 1
|
||||
elif ch == "/" and nxt == "*":
|
||||
depth = 1 # в PostgreSQL блочные комментарии вложенные
|
||||
out.append(" ")
|
||||
i += 2
|
||||
while i < n and depth:
|
||||
if sql[i] == "/" and i + 1 < n and sql[i + 1] == "*":
|
||||
depth += 1
|
||||
out.append(" ")
|
||||
i += 2
|
||||
elif sql[i] == "*" and i + 1 < n and sql[i + 1] == "/":
|
||||
depth -= 1
|
||||
out.append(" ")
|
||||
i += 2
|
||||
else:
|
||||
out.append("\n" if sql[i] == "\n" else " ")
|
||||
i += 1
|
||||
elif ch == "'":
|
||||
out.append("'")
|
||||
i += 1
|
||||
while i < n:
|
||||
if sql[i] == "'" and i + 1 < n and sql[i + 1] == "'":
|
||||
out.append(" ")
|
||||
i += 2
|
||||
continue
|
||||
if sql[i] == "'":
|
||||
break
|
||||
out.append("\n" if sql[i] == "\n" else " ")
|
||||
i += 1
|
||||
if i < n:
|
||||
out.append("'")
|
||||
i += 1
|
||||
else:
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def scan(sql: str) -> list[str]:
|
||||
"""-> список претензий к файлу; пустой список = файл в порядке."""
|
||||
clean = strip_noise(sql)
|
||||
statements = clean.split(";")
|
||||
|
||||
first_blocking: int | None = None
|
||||
blocking_text = ""
|
||||
for idx, stmt in enumerate(statements):
|
||||
if BLOCKING.search(stmt) and not CONCURRENTLY.search(stmt):
|
||||
first_blocking = idx
|
||||
blocking_text = " ".join(stmt.split())[:80]
|
||||
break
|
||||
if first_blocking is None:
|
||||
return []
|
||||
|
||||
set_local = next((i for i, s in enumerate(statements) if SET_LOCAL_LT.search(s)), None)
|
||||
if set_local is None:
|
||||
if any(SET_BARE_LT.search(s) for s in statements):
|
||||
return [
|
||||
f"`SET lock_timeout` без LOCAL при блокирующем DDL ({blocking_text}). "
|
||||
"Голый SET живёт до конца сессии и обрежет CREATE INDEX CONCURRENTLY "
|
||||
"в этом же файле. Нужен `SET LOCAL lock_timeout = '5s';` внутри BEGIN."
|
||||
]
|
||||
return [
|
||||
f"блокирующий DDL без lock_timeout ({blocking_text}). Добавь первой "
|
||||
"строкой после BEGIN: `SET LOCAL lock_timeout = '5s';` — иначе DDL встанет "
|
||||
"в очередь за чужой сессией и уведёт за собой запросы приложения (#2752)."
|
||||
]
|
||||
|
||||
problems: list[str] = []
|
||||
if not any(BEGIN_STMT.search(s) for s in statements[:set_local]):
|
||||
problems.append(
|
||||
"`SET LOCAL lock_timeout` стоит ВНЕ транзакции (нет BEGIN выше). "
|
||||
"Вне блока транзакции SET LOCAL молча ничего не делает (только WARNING)."
|
||||
)
|
||||
if set_local > first_blocking:
|
||||
problems.append(
|
||||
f"`SET LOCAL lock_timeout` стоит ПОСЛЕ блокирующего DDL ({blocking_text}) — "
|
||||
"к моменту DDL он ещё не действует. Подними его сразу под BEGIN."
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
ok = "BEGIN;\nSET LOCAL lock_timeout = '5s';\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n"
|
||||
assert scan(ok) == [], scan(ok)
|
||||
|
||||
# красное: ровно случай 250 до фикса
|
||||
bad = "BEGIN;\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n"
|
||||
assert len(scan(bad)) == 1 and "без lock_timeout" in scan(bad)[0]
|
||||
assert scan("BEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n")
|
||||
assert scan("BEGIN;\nALTER TABLE t ADD CONSTRAINT c CHECK (x > 0);\nCOMMIT;\n")
|
||||
assert scan("BEGIN;\nALTER TABLE t DROP COLUMN IF EXISTS x;\nCOMMIT;\n")
|
||||
assert scan("BEGIN;\nCREATE INDEX IF NOT EXISTS i ON t (c);\nCOMMIT;\n")
|
||||
|
||||
# красное: правильная строка, но в местах, где она не действует
|
||||
assert "ВНЕ транзакции" in scan("SET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\n")[0]
|
||||
late = "BEGIN;\nALTER TABLE t ADD COLUMN x int;\nSET LOCAL lock_timeout='5s';\nCOMMIT;\n"
|
||||
assert any("ПОСЛЕ блокирующего DDL" in p for p in scan(late))
|
||||
bare = "BEGIN;\nSET lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
|
||||
assert "без LOCAL" in scan(bare)[0]
|
||||
|
||||
# зелёное: CONCURRENTLY-формы, им lock_timeout вреден (обрывает CIC)
|
||||
assert scan("CREATE INDEX CONCURRENTLY IF NOT EXISTS i ON t (c);\n") == []
|
||||
assert scan("DROP INDEX CONCURRENTLY IF EXISTS i;\n") == []
|
||||
assert scan("REFRESH MATERIALIZED VIEW CONCURRENTLY mv;\n") == []
|
||||
# ...но CONCURRENTLY в файле не прощает соседний блокирующий DDL
|
||||
mixed = "CREATE INDEX CONCURRENTLY i ON t (c);\nBEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
|
||||
assert scan(mixed), "CONCURRENTLY не должен амнистировать ALTER TABLE в том же файле"
|
||||
mixed_ok = (
|
||||
"CREATE INDEX CONCURRENTLY i ON t (c);\n"
|
||||
"BEGIN;\nSET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n"
|
||||
)
|
||||
assert scan(mixed_ok) == [], scan(mixed_ok)
|
||||
|
||||
# зелёное: DDL, которого нет — он в комментарии или в строковом литерале
|
||||
assert scan("-- ALTER TABLE t ADD COLUMN x int;\nSELECT 1;\n") == []
|
||||
assert scan("/* DROP INDEX foo; */\nSELECT 1;\n") == []
|
||||
assert scan("/* /* вложенный */ ALTER TABLE t ADD COLUMN x int; */\nSELECT 1;\n") == []
|
||||
assert scan("COMMENT ON INDEX i IS 'не заводить второй: ALTER TABLE тут проза';\n") == []
|
||||
assert scan("COMMENT ON INDEX i IS 'кавычка внутри '' и DROP INDEX проза';\n") == []
|
||||
# зелёное: не-DDL миграции (backfill/seed) правила не касаются
|
||||
assert scan("BEGIN;\nUPDATE t SET x = 1 WHERE x IS NULL;\nCOMMIT;\n") == []
|
||||
assert scan("BEGIN;\nINSERT INTO t (x) VALUES (1) ON CONFLICT DO NOTHING;\nCOMMIT;\n") == []
|
||||
print("selftest OK")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if "--selftest" in sys.argv:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
failed = False
|
||||
checked = 0
|
||||
for dirname, min_nn in SQL_DIRS.items():
|
||||
sql_dir = Path(dirname)
|
||||
if not sql_dir.is_dir():
|
||||
print(f"::error::{sql_dir} не найден — запускать из корня репозитория")
|
||||
return 1
|
||||
for path in sorted(sql_dir.glob("*.sql")):
|
||||
m = NN_PREFIX.match(path.name)
|
||||
if not m or int(m.group(1)) < min_nn:
|
||||
continue # применено на проде до внедрения гейта — не переписываем
|
||||
checked += 1
|
||||
for problem in scan(path.read_text(encoding="utf-8")):
|
||||
failed = True
|
||||
print(f"::error file={path}::{problem}")
|
||||
if failed:
|
||||
return 1
|
||||
print(f"✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: {checked})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
125
scripts/check-workflow-ports.py
Normal file
125
scripts/check-workflow-ports.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Гейт: сервис-контейнер сборки не должен публиковать порт, занятый на VPS (#2757).
|
||||
|
||||
ПОЧЕМУ. Forgejo-раннер запускает и job, и сервис-контейнеры с `--network host`
|
||||
(проверено: `docker inspect FORGEJO-ACTIONS-TASK-... -f {{.HostConfig.NetworkMode}}`
|
||||
→ `host`). На том же хосте слушают боевые сервисы. Поэтому обычный приём
|
||||
`services: postgres` + публикация `5432:5432` не поднимает тестовую БД, а тихо
|
||||
уводит job В ПРОД: занять порт сервис-контейнер не может, а `localhost:5432` из
|
||||
job'а — это боевой Postgres. В #2745 отказ спас только несовпадение пароля.
|
||||
|
||||
Список занятых портов — не догадки, а замер `ss -ltnp` на bot-server 2026-08-06.
|
||||
Опровергнутая по ходу предпосылка issue: Redis / CouchDB / GlitchTip НА ХОСТЕ НЕ
|
||||
СЛУШАЮТ (в `docker ps` у них `6379/tcp`, `5984/tcp`, `8000/tcp` без `->`, они
|
||||
живут в bridge-сетях) — сервис-контейнер с их портами ни с чем не столкнётся,
|
||||
и запрещать их значило бы ронять сборку на ровном месте.
|
||||
|
||||
Правильный способ дать сборке БД — как в ci-tradein.yml: явный `docker run`
|
||||
в bridge-сети БЕЗ публикации порта + строка подключения по IP контейнера.
|
||||
|
||||
Запуск: python3 scripts/check-workflow-ports.py [--selftest]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# host-порт -> кто его уже занял (ss -ltnp на bot-server, 2026-08-06)
|
||||
RESERVED: dict[int, str] = {
|
||||
22: "sshd хоста",
|
||||
53: "systemd-resolved (127.0.0.53)",
|
||||
80: "gendesign-caddy-1 (0.0.0.0:80)",
|
||||
443: "gendesign-caddy-1 (0.0.0.0:443)",
|
||||
2222: "forgejo, git-over-ssh (0.0.0.0:2222)",
|
||||
3000: "gendesign-frontend-1 (127.0.0.1:3000)",
|
||||
5432: "gendesign-postgres-1 — БОЕВАЯ БД (127.0.0.1:5432)",
|
||||
8000: "gendesign-backend-1 — боевой API (127.0.0.1:8000)",
|
||||
}
|
||||
|
||||
WORKFLOW_DIR = Path(".forgejo/workflows")
|
||||
|
||||
# "8080:80", "127.0.0.1:5432:5432", "5432:5432/tcp" -> (host_port, container_port)
|
||||
PORT_PAIR = re.compile(r"(?:(?:\d{1,3}\.){3}\d{1,3}:)?(\d{1,5}):(\d{1,5})(?:/(?:tcp|udp))?")
|
||||
PUBLISH_FLAG = re.compile(r"(?:^|\s)(?:-p|--publish)[=\s]+(\S+)")
|
||||
|
||||
|
||||
def scan(text: str) -> list[tuple[int, str, int]]:
|
||||
"""-> [(номер строки, строка, занятый host-порт)] для публикаций в ports:/-p."""
|
||||
hits: list[tuple[int, str, int]] = []
|
||||
in_ports = False
|
||||
ports_indent = 0
|
||||
|
||||
for lineno, raw in enumerate(text.splitlines(), 1):
|
||||
stripped = raw.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue # комментарий ничего не запускает (в них живут анти-примеры)
|
||||
indent = len(raw) - len(raw.lstrip())
|
||||
|
||||
if in_ports and (not stripped.startswith("- ") or indent <= ports_indent):
|
||||
in_ports = False
|
||||
|
||||
candidates: list[str] = []
|
||||
if stripped.startswith("ports:"):
|
||||
rest = stripped[len("ports:") :].strip()
|
||||
if rest:
|
||||
candidates.append(rest) # инлайн-форма: ports: ["5432:5432"]
|
||||
else:
|
||||
in_ports, ports_indent = True, indent
|
||||
elif in_ports:
|
||||
candidates.append(stripped)
|
||||
candidates += PUBLISH_FLAG.findall(raw) # docker run -p / --publish
|
||||
|
||||
for candidate in candidates:
|
||||
for host_port, _container_port in PORT_PAIR.findall(candidate):
|
||||
if int(host_port) in RESERVED:
|
||||
hits.append((lineno, stripped, int(host_port)))
|
||||
return hits
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
assert scan(" ports:\n - 5432:5432\n") == [(2, "- 5432:5432", 5432)]
|
||||
assert scan(' ports: ["127.0.0.1:8000:8000"]\n')[0][2] == 8000
|
||||
assert scan(" docker run -p 3000:3000 nginx\n")[0][2] == 3000
|
||||
assert scan(" docker run --publish=443:443 nginx\n")[0][2] == 443
|
||||
# безопасные формы и посторонние двоеточия — не должны ронять сборку
|
||||
assert scan(" ports:\n - 5432\n") == []
|
||||
assert scan(" ports:\n - 15432:5432\n") == []
|
||||
assert scan(" image: postgis/postgis:16-3.4\n") == []
|
||||
assert scan(' run: journalctl --since "2026-08-07 22:00"\n') == []
|
||||
assert scan(" # ports:\n # - 5432:5432\n") == []
|
||||
assert scan(" env:\n DATABASE_URL: pg://u:p@localhost:5432/test\n") == []
|
||||
# выход из блока ports: по отступу
|
||||
assert scan(" ports:\n - 9999:9999\n image: x\n - 80:80\n") == []
|
||||
print("selftest OK")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if "--selftest" in sys.argv:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
if not WORKFLOW_DIR.is_dir():
|
||||
print(f"::error::{WORKFLOW_DIR} не найден — запускать из корня репозитория")
|
||||
return 1
|
||||
|
||||
failed = False
|
||||
for path in sorted(WORKFLOW_DIR.glob("*.yml")):
|
||||
for lineno, line, port in scan(path.read_text(encoding="utf-8")):
|
||||
failed = True
|
||||
print(
|
||||
f"::error file={path},line={lineno}::публикация порта {port} — он занят "
|
||||
f"на VPS ({RESERVED[port]}). Раннер работает в сети хоста: контейнер "
|
||||
f"порт не займёт, а job уйдёт в этот прод-сервис (#2757). Поднимай "
|
||||
f"сервис через `docker run` в bridge-сети БЕЗ публикации и ходи по IP "
|
||||
f"контейнера — образец в .forgejo/workflows/ci-tradein.yml. || {line}"
|
||||
)
|
||||
if failed:
|
||||
return 1
|
||||
print("✓ ни один workflow не публикует занятый на VPS порт")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
5
tradein-mvp/.gitignore
vendored
5
tradein-mvp/.gitignore
vendored
|
|
@ -15,7 +15,10 @@ frontend/node_modules/
|
|||
frontend/.next/
|
||||
frontend/out/
|
||||
frontend/dist/
|
||||
frontend/package-lock.json
|
||||
# frontend/package-lock.json — TRACKED с #2770: Dockerfile и CI ставят `npm ci`,
|
||||
# которому лок обязателен. Правишь frontend/package.json — регенерируй лок
|
||||
# (`npm install --legacy-peer-deps --no-audit --no-fund`) в том же PR, иначе
|
||||
# `npm ci` роняет сборку образа и весь deploy откатывается.
|
||||
frontend/next-env.d.ts
|
||||
|
||||
# Docker
|
||||
|
|
|
|||
46
tradein-mvp/CHANGELOG.md
Normal file
46
tradein-mvp/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# История версий «МЕРА»
|
||||
|
||||
Формат по мотивам [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/) и
|
||||
[Semantic Versioning](https://semver.org/lang/ru/). Заголовок версии — ровно
|
||||
`## <semver> — <YYYY-MM-DD>` (машинно читается страницей истории версий).
|
||||
|
||||
## 2.1.0 — 2026-08-10
|
||||
|
||||
Первая версия с явным версионированием. Номер продолжает ряд, который до этого
|
||||
показывался в отчётах, — чтобы он не пошёл назад для тех, кто уже видел прежние
|
||||
отчёты.
|
||||
|
||||
### Добавлено
|
||||
|
||||
- Оценка стоимости квартиры по объявлениям (Авито, Циан, Яндекс.Недвижимость) и
|
||||
реальным сделкам Росреестра — медиана, диапазон цены и цены за м², уровень
|
||||
уверенности в оценке.
|
||||
- PDF-отчёт по оценке под брендом «МЕРА»: обложка с диапазоном цены, состав
|
||||
аналогов и сделок, формирование выкупной стоимости.
|
||||
- Аналитика по дому — история размещений объявлений и продаж в доме.
|
||||
- История прошлых оценок в личном кабинете, автодополнение адреса при поиске.
|
||||
- Личный кабинет: вход/выход, дашборд менеджера (сотрудники, квоты, история).
|
||||
- Чат поддержки на сайте, в том числе без входа в личный кабинет.
|
||||
- Публичный лендинг «МЕРА».
|
||||
- Номер версии продукта в подвале интерфейса и в шапке PDF-отчёта, а также эта
|
||||
страница истории версий.
|
||||
|
||||
### Изменено
|
||||
|
||||
- Дизайн PDF-отчёта переработан в фирменный HUD-стиль «МЕРА» вместо более
|
||||
раннего технического макета.
|
||||
|
||||
### Исправлено
|
||||
|
||||
- Студии больше не оцениваются как однокомнатные квартиры. Раньше в выборе
|
||||
комнатности не было варианта «Студия», из-за чего для студии подбирались
|
||||
однокомнатные аналоги — их рядом почти нет, и оценка не выдавалась.
|
||||
- Оценка больше не блокируется, если рядом мало аналогов. Теперь подбор
|
||||
автоматически расширяется (студии, срок объявлений, новостройки, радиус),
|
||||
а над результатом показывается предупреждение о сниженной точности и о том,
|
||||
какие параметры пришлось расширить.
|
||||
- Восстановлены блоки «сделки по улице» и «продажи против объявлений»: для части
|
||||
адресов улица не распознавалась, и разделы оставались пустыми.
|
||||
- PDF-отчёт стабильно формируется ровно на 4 страницах без пустых листов.
|
||||
- Устранены неточности в отчёте: пустой «Год постройки», дублирующиеся блоки
|
||||
на обложке, некорректные допущения о сроке экспозиции.
|
||||
1
tradein-mvp/VERSION
Normal file
1
tradein-mvp/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
2.1.0
|
||||
|
|
@ -76,6 +76,30 @@ COPY --from=builder --chown=app:app /app/packages /app/packages
|
|||
COPY --from=builder --chown=app:app /app/backend/app /app/app
|
||||
COPY --from=builder --chown=app:app /app/backend/scripts /app/scripts
|
||||
|
||||
# Version-файл фолбэка (app/core/version.py ищет VERSION, идя вверх от своего
|
||||
# каталога — здесь она на 2 уровня выше /app/app/core/, т.е. ровно /app/VERSION).
|
||||
# Build context = tradein-mvp/, поэтому VERSION резолвится с корня контекста.
|
||||
COPY --chown=app:app VERSION VERSION
|
||||
|
||||
# Версия продукта + короткий git SHA + дата сборки — запечены как build-args
|
||||
# в образ (см. .forgejo/workflows/deploy-tradein.yml, job build-backend).
|
||||
# Пустые дефолты ЗДЕСЬ не читаются напрямую: app/core/version.py фолбэчит сам
|
||||
# (VERSION-файл выше / "dev" / момент импорта модуля).
|
||||
#
|
||||
# НАМЕРЕННО в самом низу runner-стадии, ПОСЛЕ apt-get install и тяжёлых
|
||||
# COPY --from=builder (.venv/packages/app выше) — BUILD_DATE меняется на
|
||||
# КАЖДОМ деплое (текущее время сборки), а Docker-кэш инвалидирует ВСЕ слои
|
||||
# ПОСЛЕ первого изменившегося ENV/ARG. Если бы этот блок стоял в начале
|
||||
# стадии (как раньше), апдейт даты бил бы registry buildcache для apt-get +
|
||||
# COPY .venv/packages/app КАЖДЫЙ раз — здесь инвалидирует только этот
|
||||
# дешёвый хвост (ENV + USER + EXPOSE + CMD ниже).
|
||||
ARG APP_VERSION=""
|
||||
ARG BUILD_SHA=""
|
||||
ARG BUILD_DATE=""
|
||||
ENV APP_VERSION=$APP_VERSION \
|
||||
BUILD_SHA=$BUILD_SHA \
|
||||
BUILD_DATE=$BUILD_DATE
|
||||
|
||||
USER app
|
||||
|
||||
# HOME должен быть явным: Docker НЕ выставляет $HOME по USER, а некоторые
|
||||
|
|
|
|||
|
|
@ -72,7 +72,9 @@ from app.services import cian_session as cian_session_svc
|
|||
from app.services import domclick_session as domclick_session_svc
|
||||
from app.services import proxy_rotation as proxy_rotation_svc
|
||||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||
from app.services.geocoder import geocode, known_city_hint
|
||||
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url
|
||||
from app.services.proxy_pool import clear_source_bans
|
||||
from app.services.scheduler import has_running_run
|
||||
from app.services.scraper_adapters import (
|
||||
|
|
@ -231,6 +233,8 @@ async def scrape_around(
|
|||
)
|
||||
else:
|
||||
lots = await scraper.fetch_around(payload.lat, payload.lon, payload.radius_m)
|
||||
# run_id нет и не будет (#2701): ручной admin-скрейп строки в scrape_runs не
|
||||
# заводит — снимок пишется вне прогона, поле честно остаётся NULL.
|
||||
inserted, updated = save_listings(
|
||||
db, lots, matcher=matcher, region_code=DEFAULT_REGION_CODE
|
||||
)
|
||||
|
|
@ -370,6 +374,56 @@ async def geocode_missing(
|
|||
}
|
||||
|
||||
|
||||
def _cian_verify_state_error(state: dict[str, Any] | None) -> HTTPException | None:
|
||||
"""Маппинг исхода cian_session_svc.verify_session() на HTTP-ответ админки.
|
||||
|
||||
verify_session() возвращает 4 разных исхода (см. докстринг сервиса) плюс успех —
|
||||
их нельзя схлопывать в один "cookies invalid", иначе бан по IP выглядит так же,
|
||||
как протухшие куки, и человек в момент инцидента перезаливает заведомо валидные
|
||||
куки вместо починки egress/прокси (инцидент 2026-08-10).
|
||||
|
||||
Sentinel'ы сравниваются через `is`, НЕ `==` — так требует докстринг verify_session.
|
||||
|
||||
Возвращает None, если state — это успешно распаршенный state dict (в т.ч. случай
|
||||
"успех, но userId не найден" — этот случай caller должен обработать отдельно).
|
||||
"""
|
||||
if state is cian_session_svc.VERIFY_BAN_SENTINEL:
|
||||
return HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"Cian заблокировал наш IP (HTTP 403, TLS/bot-fingerprint ban). "
|
||||
"Куки, скорее всего, валидны — блокировка не про них. "
|
||||
"Нужно чинить egress: проверить SCRAPER_PROXY_URL и баны в "
|
||||
"scrape_proxy_source_bans. Перезаливать куки бесполезно. "
|
||||
"(CIAN_PROXY_URL — мёртвая переменная, снята в #2616.)"
|
||||
),
|
||||
)
|
||||
if state is cian_session_svc.VERIFY_SOURCE_UNAVAILABLE_SENTINEL:
|
||||
return HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"Cian временно недоступен (5xx или сетевой сбой при проверке кук). "
|
||||
"Повторите проверку позже. Куки не трогать — источник просто не ответил."
|
||||
),
|
||||
)
|
||||
if state is cian_session_svc.VERIFY_MARKUP_CHANGED_SENTINEL:
|
||||
return HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
"Cian изменил вёрстку/схему страницы — auth-state не найден/не "
|
||||
"распарсился (scraper_kit.cian_state_parser.extract_state, MFE "
|
||||
"header-frontend). Нужен инженерный фикс парсера, перезалив кук "
|
||||
"проблему НЕ решит."
|
||||
),
|
||||
)
|
||||
if state is None:
|
||||
return HTTPException(
|
||||
status_code=401,
|
||||
detail="Куки протухли или сессия разлогинена на cian.ru — перезалейте куки.",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/scrape/cian/upload-cookies", status_code=200)
|
||||
async def upload_cian_cookies(
|
||||
cookies: dict[str, str],
|
||||
|
|
@ -402,15 +456,21 @@ async def upload_cian_cookies(
|
|||
)
|
||||
|
||||
state = await cian_session_svc.verify_session(cleaned)
|
||||
if state is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Cookies invalid or session not authenticated on cian.ru",
|
||||
)
|
||||
verify_error = _cian_verify_state_error(state)
|
||||
if verify_error is not None:
|
||||
raise verify_error
|
||||
assert state is not None # narrowed by _cian_verify_state_error above
|
||||
|
||||
user_id = state.get("user", {}).get("userId")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=400, detail="Authenticated state missing userId")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Cian подтвердил аутентификацию (state распарсился), но userId в "
|
||||
"ответе не найден — структура state неожиданная, куки тут ни при "
|
||||
"чём, смотрите server logs."
|
||||
),
|
||||
)
|
||||
|
||||
cian_session_svc.save_session(db, account_user_id=int(user_id), cookies=cleaned)
|
||||
return {"ok": True, "userId": user_id, "cookieCount": len(cleaned)}
|
||||
|
|
@ -477,15 +537,21 @@ async def cian_auto_login(
|
|||
)
|
||||
|
||||
state = await cian_session_svc.verify_session(cleaned)
|
||||
if state is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Logged in but session not authenticated (cookies rejected by cian.ru)",
|
||||
)
|
||||
verify_error = _cian_verify_state_error(state)
|
||||
if verify_error is not None:
|
||||
raise verify_error
|
||||
assert state is not None # narrowed by _cian_verify_state_error above
|
||||
|
||||
user_id = state.get("user", {}).get("userId")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=400, detail="Authenticated state missing userId")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Cian подтвердил аутентификацию (state распарсился), но userId в "
|
||||
"ответе не найден — структура state неожиданная, куки тут ни при "
|
||||
"чём, смотрите server logs."
|
||||
),
|
||||
)
|
||||
|
||||
cian_session_svc.save_session(db, account_user_id=int(user_id), cookies=cleaned)
|
||||
return {"ok": True, "userId": user_id, "cookieCount": len(cleaned)}
|
||||
|
|
@ -497,6 +563,12 @@ async def test_cian_auth(
|
|||
) -> dict:
|
||||
"""Проверить что текущие сохранённые Cian cookies ещё валидны.
|
||||
|
||||
reason различает 5 исходов (см. cian_session_svc.verify_session докстринг):
|
||||
"banned_403" — куки, вероятно, ОК, блокирован IP; "source_unavailable" —
|
||||
Cian недоступен, куки ни при чём; "markup_changed" — вёрстка Cian сменилась,
|
||||
нужен фикс парсера; "session_expired_or_invalid" — куки реально протухли;
|
||||
"no_session_in_db" / "encryption_key_not_configured" — конфигурация/данных нет.
|
||||
|
||||
Returns: {"authenticated": bool, "userId": <int|null>, "reason": <str|null>}
|
||||
"""
|
||||
if not settings.cookie_encryption_key:
|
||||
|
|
@ -507,10 +579,14 @@ async def test_cian_auth(
|
|||
return {"authenticated": False, "userId": None, "reason": "no_session_in_db"}
|
||||
|
||||
state = await cian_session_svc.verify_session(cookies)
|
||||
if state is cian_session_svc.VERIFY_BAN_SENTINEL:
|
||||
return {"authenticated": False, "userId": None, "reason": "banned_403"}
|
||||
if state is cian_session_svc.VERIFY_SOURCE_UNAVAILABLE_SENTINEL:
|
||||
return {"authenticated": False, "userId": None, "reason": "source_unavailable"}
|
||||
if state is cian_session_svc.VERIFY_MARKUP_CHANGED_SENTINEL:
|
||||
return {"authenticated": False, "userId": None, "reason": "markup_changed"}
|
||||
if state is None:
|
||||
return {"authenticated": False, "userId": None, "reason": "session_expired_or_invalid"}
|
||||
if state.get("_ban"):
|
||||
return {"authenticated": False, "userId": None, "reason": "banned_403"}
|
||||
|
||||
user_id = state.get("user", {}).get("userId")
|
||||
return {"authenticated": True, "userId": user_id, "reason": None}
|
||||
|
|
@ -1591,8 +1667,22 @@ def update_schedule(
|
|||
"""UPDATE existing schedule (create если не существует, через INSERT ON CONFLICT)."""
|
||||
from app.services.scheduler import compute_next_run_at
|
||||
|
||||
# Compute new next_run_at если window изменился — recompute, иначе keep existing
|
||||
next_at = compute_next_run_at(payload.window_start_hour, payload.window_end_hour)
|
||||
# #2674: такт берётся из default_params — ровно как его читает планировщик
|
||||
# (_claim_run/_defer_next_run_at). Без него compute_next_run_at падал на default=1 и
|
||||
# ЛЮБОЕ сохранение сбивало источник на «завтра»: недельный avito_full_load после
|
||||
# правки окна побежал бы через сутки. На суточных источниках баг был невидим —
|
||||
# для них «завтра» и есть правильный ответ.
|
||||
# None-safe так же, как в scheduler: `"interval_days": null` в jsonb → 1, не TypeError.
|
||||
_interval_days = payload.default_params.get("interval_days")
|
||||
interval_days = max(1, int(_interval_days)) if _interval_days is not None else 1
|
||||
|
||||
# Явно заданный оператором момент уважается как есть (в т.ч. в прошлом — «запустить
|
||||
# сейчас»). Иначе считаем от такта.
|
||||
next_at = payload.next_run_at or compute_next_run_at(
|
||||
payload.window_start_hour,
|
||||
payload.window_end_hour,
|
||||
interval_days=interval_days,
|
||||
)
|
||||
|
||||
row = (
|
||||
db.execute(
|
||||
|
|
@ -1606,7 +1696,22 @@ def update_schedule(
|
|||
window_start_hour = EXCLUDED.window_start_hour,
|
||||
window_end_hour = EXCLUDED.window_end_hour,
|
||||
default_params = EXCLUDED.default_params,
|
||||
next_run_at = EXCLUDED.next_run_at,
|
||||
-- #2674: не двигаем уже назначенный запуск, если двигать не за чем.
|
||||
-- Раньше next_run_at перезаписывался ВСЕГДА, поэтому правка соседнего
|
||||
-- поля (enabled, request_delay_sec в params) заново разыгрывала момент
|
||||
-- внутри окна и сдвигала прогон. Сохраняем существующий только когда он
|
||||
-- ещё в будущем И ни окно, ни такт не менялись — тогда пересчёт дал бы
|
||||
-- то же самое окно, только с другим random-смещением.
|
||||
next_run_at = CASE
|
||||
WHEN CAST(:explicit AS boolean) THEN EXCLUDED.next_run_at
|
||||
WHEN scrape_schedules.next_run_at > NOW()
|
||||
AND scrape_schedules.window_start_hour = EXCLUDED.window_start_hour
|
||||
AND scrape_schedules.window_end_hour = EXCLUDED.window_end_hour
|
||||
AND COALESCE(scrape_schedules.default_params ->> 'interval_days', '1')
|
||||
= COALESCE(EXCLUDED.default_params ->> 'interval_days', '1')
|
||||
THEN scrape_schedules.next_run_at
|
||||
ELSE EXCLUDED.next_run_at
|
||||
END,
|
||||
updated_at = NOW()
|
||||
RETURNING id, source, enabled, window_start_hour, window_end_hour,
|
||||
default_params, last_run_id, last_run_at, next_run_at, updated_at
|
||||
|
|
@ -1619,6 +1724,7 @@ def update_schedule(
|
|||
"we": payload.window_end_hour,
|
||||
"params": json.dumps(payload.default_params, ensure_ascii=False),
|
||||
"next_at": next_at,
|
||||
"explicit": payload.next_run_at is not None,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
|
|
@ -1871,9 +1977,16 @@ async def scrape_cian_detail(
|
|||
Without it → debug-only (no DB write).
|
||||
"""
|
||||
_assert_allowed_url(offer_url)
|
||||
from scraper_kit.cian_exceptions import CianBlockedError
|
||||
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
|
||||
|
||||
enrichment = await fetch_detail(offer_url, config=RealScraperConfig())
|
||||
try:
|
||||
enrichment = await fetch_detail(offer_url, config=RealScraperConfig())
|
||||
except CianBlockedError as exc:
|
||||
# #2700: 403 теперь исключение (узел снимается с выдачи Циану). Ad-hoc ручке
|
||||
# нужен внятный ответ, а не 500: «страницу не разобрали» и «нас не пустили с
|
||||
# этого узла» — разные новости для того, кто дёргает ручку руками.
|
||||
raise HTTPException(502, f"Cian заблокировал наш узел: {exc}") from exc
|
||||
if enrichment is None:
|
||||
raise HTTPException(404, f"Could not parse Cian detail page: {offer_url}")
|
||||
|
||||
|
|
@ -1920,14 +2033,17 @@ async def scrape_cian_newbuilding(
|
|||
save_newbuilding_enrichment,
|
||||
)
|
||||
|
||||
enrichment = await fetch_newbuilding(zhk_url, config=RealScraperConfig())
|
||||
enrichment = await fetch_newbuilding(
|
||||
zhk_url, config=RealScraperConfig(), proxy_provider=_kit_proxy_provider()
|
||||
)
|
||||
if enrichment is None:
|
||||
raise HTTPException(404, f"Could not parse Cian newbuilding page: {zhk_url}")
|
||||
|
||||
saved = False
|
||||
if house_id is not None:
|
||||
# save_newbuilding_enrichment — sync (def, returns None); await на sync-функции
|
||||
# раньше поднимал TypeError на любом вызове с house_id.
|
||||
# save_newbuilding_enrichment — sync (def, не корутина); await на sync-функции
|
||||
# раньше поднимал TypeError на любом вызове с house_id. Возвращаемый счёт
|
||||
# записанного (#2807) этой ручке не нужен — она отвечает фактом сохранения.
|
||||
save_newbuilding_enrichment(db, house_id, enrichment)
|
||||
saved = True
|
||||
|
||||
|
|
@ -2188,12 +2304,30 @@ async def scrape_house_imv_backfill(
|
|||
|
||||
|
||||
class UnifiedScrapeRunRow(BaseModel):
|
||||
"""Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче)."""
|
||||
"""Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче).
|
||||
|
||||
#2674: поля run_type больше нет. Вид прогона в БД всегда был дефолтом
|
||||
'city_sweep' (3244 из 3244 строк, ни одно место кода его не задавало), и
|
||||
таблица подписывала им прогоны, которые никаким sweep не были —
|
||||
proxy_healthcheck, deactivate_stale_*, sber_index_pull. Что именно бежало,
|
||||
называет `source`.
|
||||
"""
|
||||
|
||||
run_id: int
|
||||
source: str
|
||||
run_type: str | None = None
|
||||
status: str
|
||||
# #2674: чинить фильтр без этого флага было бы регрессом. Пока таблица была
|
||||
# пуста на всех вкладках, кнопка отмены не рендерилась ни разу; теперь оператор
|
||||
# видит все 53 источника — и без флага мог бы «отменить» задачу, которая отмену
|
||||
# не опрашивает (см. scrape_runs.honors_cancel): статус соврал бы, а
|
||||
# has_running_run перестал бы держать single-run guard.
|
||||
cancellable: bool = False
|
||||
# #2686: диагноз для status='banned' — 'platform' (площадка заблокировала),
|
||||
# 'infra' (не отдал наш браузерный сайдкар) или 'unknown' (#2764 — причина не
|
||||
# установлена; раньше такие прогоны молча получали 'platform'). Без него
|
||||
# оператор видит только «забанен» и делает вывод «площадка нас палит» на 80%
|
||||
# наших же отказов.
|
||||
ban_kind: str | None = None
|
||||
params: dict | None = None
|
||||
counters: dict | None = None
|
||||
total_seen: int | None = None
|
||||
|
|
@ -2209,6 +2343,12 @@ class UnifiedScrapeRunsResponse(BaseModel):
|
|||
rows: list[UnifiedScrapeRunRow]
|
||||
|
||||
|
||||
class ScrapeRunSourcesResponse(BaseModel):
|
||||
"""Список source'ов для фильтра истории прогонов — из данных, не из литерала."""
|
||||
|
||||
sources: list[str]
|
||||
|
||||
|
||||
class BrowserHealth(BaseModel):
|
||||
reachable: bool
|
||||
browsers: dict[str, bool] = Field(default_factory=dict)
|
||||
|
|
@ -2231,18 +2371,33 @@ class ScraperHealthResponse(BaseModel):
|
|||
_ROTATABLE_SOURCES = ("avito", "cian", "yandex")
|
||||
|
||||
|
||||
def _provider_proxy_url(source: str) -> str | None:
|
||||
"""Effective proxy URL для source (учитывает property-fallback в settings).
|
||||
def _provider_proxy_url(db: Session, source: str) -> str | None:
|
||||
"""Узел, который РЕАЛЬНО получит трафик этого источника (#2830).
|
||||
|
||||
#2616 шаг 2: avito/cian/yandex все три сходятся на settings.scraper_proxy_url
|
||||
(per-provider AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты — мёртвая
|
||||
mobileproxy-подписка, #2613).
|
||||
Раньше здесь стоял `settings.scraper_proxy_url` — одна и та же статичная
|
||||
переменная для всех трёх источников. После #2825/#2831 egress выбирается из
|
||||
`scrape_proxies` по запросу и с учётом `scrape_proxy_source_bans`, то есть
|
||||
страница показывала один узел, а трафик шёл через другой — слепое пятно ровно
|
||||
того класса, который спрятал инцидент 2026-08-10 (месяц сбора через узел,
|
||||
забаненный и Avito, и Cian), только теперь на диагностической странице.
|
||||
|
||||
Тот же резолвер, что у боевых ad-hoc путей (`cian_session.verify_session`,
|
||||
`*_detail_backfill`) — не «похожая логика», иначе страница снова начнёт
|
||||
расходиться с трафиком.
|
||||
|
||||
Вердикт пулу отсюда НЕ уходит и уходить не должен (#2805): резолвер read-only,
|
||||
lease не берёт, а ipify-проба ниже проверяет доступность ipify через узел, а не
|
||||
его репутацию у Авито/Циана — присваивать узлу отказ по чужой пробе значит
|
||||
выдавать ему чужой бан.
|
||||
"""
|
||||
return {
|
||||
"avito": settings.scraper_proxy_url,
|
||||
"cian": settings.cian_proxy_url,
|
||||
"yandex": settings.yandex_proxy_url,
|
||||
}.get(source)
|
||||
try:
|
||||
return resolve_proxy_url(db, source)
|
||||
except ProxyPoolExhaustedError:
|
||||
# Пул не пуст, но для source не осталось ни одного здорового небаненного узла.
|
||||
# resolve_proxy_url уже написал error с разбивкой; здесь отдаём None — пусть
|
||||
# страница покажет «—», а не статичный env-узел (зелёная строка на месте
|
||||
# отказа хуже пустой).
|
||||
return None
|
||||
|
||||
|
||||
def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | None]:
|
||||
|
|
@ -2262,7 +2417,11 @@ def list_scrape_runs_unified(
|
|||
db: Annotated[Session, Depends(get_db)],
|
||||
source: Annotated[str | None, Query()] = None,
|
||||
status: Annotated[
|
||||
Literal["done", "running", "banned", "zombie", "failed", "cancelled"] | None, Query()
|
||||
# 'skipped' (#2658) — пропущенное расписание; без него оператор не может
|
||||
# спросить «что сейчас пропускается» (фильтр отдавал 422 на единственной
|
||||
# поверхности, построенной ровно для этого вопроса).
|
||||
Literal["done", "running", "banned", "zombie", "failed", "cancelled", "skipped"] | None,
|
||||
Query(),
|
||||
] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
|
|
@ -2274,7 +2433,7 @@ def list_scrape_runs_unified(
|
|||
|
||||
Query:
|
||||
source — опц. фильтр по source (avito_city_sweep / cian_city_sweep / ...).
|
||||
status — опц. фильтр (done/running/banned/zombie/failed/cancelled).
|
||||
status — опц. фильтр (done/running/banned/zombie/failed/cancelled/skipped).
|
||||
limit — default 50, max 200.
|
||||
offset — default 0.
|
||||
"""
|
||||
|
|
@ -2289,8 +2448,9 @@ def list_scrape_runs_unified(
|
|||
UnifiedScrapeRunRow(
|
||||
run_id=r["run_id"],
|
||||
source=r["source"],
|
||||
run_type=r.get("run_type"),
|
||||
status=r["status"],
|
||||
cancellable=runs_mod.honors_cancel(str(r["source"])),
|
||||
ban_kind=r.get("ban_kind"),
|
||||
params=r.get("params"),
|
||||
counters=r.get("counters"),
|
||||
total_seen=r.get("total_seen"),
|
||||
|
|
@ -2305,6 +2465,21 @@ def list_scrape_runs_unified(
|
|||
)
|
||||
|
||||
|
||||
@router.get("/scrape/runs/sources", response_model=ScrapeRunSourcesResponse)
|
||||
def list_scrape_run_sources(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> ScrapeRunSourcesResponse:
|
||||
"""Источники для фильтра истории прогонов — ровно те, что есть в scrape_runs.
|
||||
|
||||
#2674: фильтр в UI был захардкожен тремя значениями (avito/cian/yandex), а в
|
||||
таблице 53 разных source и НИ ОДНОЙ строки с таким точным значением — каждый
|
||||
пункт фильтра давал пустую выдачу, и 76% прогонов (вся площадка Домклик в том
|
||||
числе) были недоступны для вопроса «что там происходит». Список берётся из
|
||||
данных: новый source появляется в фильтре сам, без правки кода.
|
||||
"""
|
||||
return ScrapeRunSourcesResponse(sources=runs_mod.distinct_sources(db))
|
||||
|
||||
|
||||
async def _probe_browser_health() -> BrowserHealth:
|
||||
"""GET tradein-browser /health (timeout 5с). reachable=False при ошибке."""
|
||||
url = f"{settings.browser_http_endpoint.rstrip('/')}/health"
|
||||
|
|
@ -2338,19 +2513,23 @@ async def _probe_current_ip(proxy_url: str | None) -> str | None:
|
|||
|
||||
|
||||
@router.get("/scraper/health", response_model=ScraperHealthResponse)
|
||||
async def scraper_health() -> ScraperHealthResponse:
|
||||
async def scraper_health(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> ScraperHealthResponse:
|
||||
"""Сводный health для единой scrapers-страницы: fetch_mode + browser + провайдеры.
|
||||
|
||||
- fetch_mode: settings.scraper_fetch_mode (curl_cffi / browser).
|
||||
- browser: GET tradein-browser /health (reachable + per-browser ready-флаги).
|
||||
- providers: для avito/cian/yandex — proxy host/port, rotate_supported
|
||||
(#2616 шаг 2: всегда False — changeip mobileproxy-ротация снята, мёртвый
|
||||
аккаунт #2613; живая ASocks-ротация — POST /admin/proxies/{id}/rotate, #2611,
|
||||
не per-provider-source), best-effort current_ip (пробинг через прокси на ipify).
|
||||
- providers: для avito/cian/yandex — узел, который пул отдаст ЭТОМУ источнику
|
||||
сейчас (#2830, см. `_provider_proxy_url`; пусто = ни одного небаненного
|
||||
здорового узла), rotate_supported (#2616 шаг 2: всегда False — changeip
|
||||
mobileproxy-ротация снята, мёртвый аккаунт #2613; живая ASocks-ротация —
|
||||
POST /admin/proxies/{id}/rotate, #2611, не per-provider-source), best-effort
|
||||
current_ip (пробинг через этот же узел на ipify).
|
||||
|
||||
Все пробинги параллельны (asyncio.gather) и time-boxed — суммарно ≤10с.
|
||||
"""
|
||||
proxy_urls = {s: _provider_proxy_url(s) for s in _ROTATABLE_SOURCES}
|
||||
proxy_urls = {s: _provider_proxy_url(db, s) for s in _ROTATABLE_SOURCES}
|
||||
|
||||
browser, *ips = await asyncio.gather(
|
||||
_probe_browser_health(),
|
||||
|
|
@ -2473,6 +2652,12 @@ async def update_scraper_pacing(
|
|||
class SourceCoverage(BaseModel):
|
||||
source: str
|
||||
active_count: int
|
||||
# #2660: «активно» ≠ «живо». is_active снимается только деактиватором протухших,
|
||||
# а он покрывает не все источники — на проде (2026-08-05) cian показывал 18 530
|
||||
# активных при 12 683 не виденных 14+ дней. Из-за этого #2574 месяц читалась как
|
||||
# «всё собирается». Не прячем протухшее из счётчика, а отдаём ВТОРЫМ числом
|
||||
# рядом — тогда «активно» перестаёт читаться как «живо».
|
||||
stale_count: int
|
||||
fields: dict[str, float] # field_name -> fill% (0..100, round 1)
|
||||
|
||||
|
||||
|
|
@ -2487,6 +2672,9 @@ class HousesCoverage(BaseModel):
|
|||
class DataQualityResponse(BaseModel):
|
||||
sources: list[SourceCoverage]
|
||||
houses: HousesCoverage
|
||||
# Порог «не виделись N дней» для stale_count — отдаём в ответе, чтобы UI
|
||||
# подписывал число, а не хардкодил порог у себя вторым определением.
|
||||
stale_days: int
|
||||
|
||||
|
||||
# Поля listings для fill%-аудита. Каждый кортеж: (имя_поля, SQL-выражение IS NOT NULL).
|
||||
|
|
@ -2499,7 +2687,8 @@ _DQ_LISTING_FIELDS: list[tuple[str, str]] = [
|
|||
("lon", "lon IS NOT NULL"),
|
||||
("kitchen_area_m2", "kitchen_area_m2 IS NOT NULL"),
|
||||
("living_area_m2", "living_area_m2 IS NOT NULL"),
|
||||
("ceiling_height", "ceiling_height IS NOT NULL"),
|
||||
# #2699: одна колонка вместо двух. ceiling_height (019) DEPRECATED — писатели
|
||||
# переведены на ceiling_height_m, исторические значения перенесены (мигр. 238).
|
||||
("ceiling_height_m", "ceiling_height_m IS NOT NULL"),
|
||||
("metro_stations", "metro_stations IS NOT NULL AND metro_stations <> '[]'::jsonb"),
|
||||
]
|
||||
|
|
@ -2513,9 +2702,13 @@ def get_data_quality(
|
|||
|
||||
Один проход per source через COUNT(*)...FILTER — не N запросов.
|
||||
Поля listings: description, photo_urls, address, lat/lon, kitchen_area_m2,
|
||||
living_area_m2, ceiling_height (cian), ceiling_height_m (avito), metro_stations.
|
||||
living_area_m2, ceiling_height_m (все источники, #2699), metro_stations.
|
||||
houses: total, avito_validated_at%, rating_score%, house_type%.
|
||||
house_reviews: общий count.
|
||||
|
||||
#2660: рядом с active_count отдаётся stale_count — сколько из «активных» не
|
||||
виделись LISTINGS_FRESH_DAYS дней (last_seen_at). Порог отдаётся в ответе
|
||||
(stale_days), чтобы UI не заводил второе определение.
|
||||
"""
|
||||
# Строим single-pass SELECT для listings полей через FILTER-агрегаты.
|
||||
# Структура: COUNT(*) FILTER (WHERE <expr>) / NULLIF(COUNT(*), 0) * 100
|
||||
|
|
@ -2523,10 +2716,17 @@ def get_data_quality(
|
|||
filter_exprs = ", ".join(
|
||||
f"COUNT(*) FILTER (WHERE {expr}) AS f_{name}" for name, expr in _DQ_LISTING_FIELDS
|
||||
)
|
||||
# last_seen_at, а не scraped_at: счётчик отвечает буквально на «сколько не
|
||||
# виделись». На проде две колонки не расходятся (замер 2026-08-05: 0 активных
|
||||
# строк с разницей ≥ суток), но семантика счётчика — про «видели», и колонка
|
||||
# должна называть ровно её.
|
||||
sql_listings = text(f"""
|
||||
SELECT
|
||||
source,
|
||||
COUNT(*) AS active_count,
|
||||
COUNT(*) FILTER (
|
||||
WHERE last_seen_at <= NOW() - (:fresh_days || ' days')::interval
|
||||
) AS stale_count,
|
||||
{filter_exprs}
|
||||
FROM listings
|
||||
WHERE is_active = true
|
||||
|
|
@ -2534,7 +2734,7 @@ def get_data_quality(
|
|||
ORDER BY source
|
||||
""")
|
||||
|
||||
rows = db.execute(sql_listings).mappings().all()
|
||||
rows = db.execute(sql_listings, {"fresh_days": LISTINGS_FRESH_DAYS}).mappings().all()
|
||||
|
||||
sources: list[SourceCoverage] = []
|
||||
for row in rows:
|
||||
|
|
@ -2548,6 +2748,7 @@ def get_data_quality(
|
|||
SourceCoverage(
|
||||
source=row["source"],
|
||||
active_count=int(row["active_count"]),
|
||||
stale_count=int(row["stale_count"] or 0),
|
||||
fields=fields,
|
||||
)
|
||||
)
|
||||
|
|
@ -2575,7 +2776,7 @@ def get_data_quality(
|
|||
reviews_count=reviews_count,
|
||||
)
|
||||
|
||||
return DataQualityResponse(sources=sources, houses=houses)
|
||||
return DataQualityResponse(sources=sources, houses=houses, stale_days=LISTINGS_FRESH_DAYS)
|
||||
|
||||
|
||||
# ── Proxy pool: хранилище + bulk-загрузка / список (#2161) ───────────────────
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ async def list_accounts(
|
|||
count(*) FILTER (WHERE event_type = 'api_request') AS request_count,
|
||||
count(*) FILTER (WHERE event_type = 'estimate_request') AS search_count
|
||||
FROM user_events
|
||||
-- Событие без имени — не аккаунт (#2715: `login_verify_saturated`
|
||||
-- пишется с пустым именем намеренно — отказ случается ДО того, как
|
||||
-- на имя посмотрели). Без фильтра строка встала бы ПЕРВОЙ (её
|
||||
-- last_seen_at — момент атаки), а её кнопка в UI раскрывалась бы в
|
||||
-- /audit/accounts/{username} с `min_length=1`, то есть в ошибку.
|
||||
WHERE username <> ''
|
||||
GROUP BY username
|
||||
ORDER BY last_seen_at DESC
|
||||
"""
|
||||
|
|
@ -182,12 +188,16 @@ async def analytics_dashboard(
|
|||
db.execute(
|
||||
text(
|
||||
"""
|
||||
-- NULLIF(username, ''): безымянные события (#2715) — СОБЫТИЯ, они
|
||||
-- честно входят в total_events, но не люди: count(DISTINCT) их
|
||||
-- игнорирует по NULL, иначе первая же атака навсегда добавила бы
|
||||
-- фантомного пользователя в счётчик уникальных.
|
||||
SELECT count(*) AS total_events,
|
||||
count(DISTINCT username) AS distinct_users,
|
||||
count(DISTINCT NULLIF(username, '')) AS distinct_users,
|
||||
count(*) FILTER (
|
||||
WHERE created_at >= now() - INTERVAL '24 hours'
|
||||
) AS events_last_24h,
|
||||
count(DISTINCT username) FILTER (
|
||||
count(DISTINCT NULLIF(username, '')) FILTER (
|
||||
WHERE created_at >= now() - INTERVAL '24 hours'
|
||||
) AS active_users_last_24h
|
||||
FROM user_events
|
||||
|
|
@ -204,7 +214,7 @@ async def analytics_dashboard(
|
|||
"""
|
||||
SELECT date_trunc('day', created_at)::date AS day,
|
||||
count(*) AS events,
|
||||
count(DISTINCT username) AS users
|
||||
count(DISTINCT NULLIF(username, '')) AS users -- см. выше (#2715)
|
||||
FROM user_events
|
||||
WHERE created_at >= now() - make_interval(days => CAST(:days AS int))
|
||||
GROUP BY date_trunc('day', created_at)::date
|
||||
|
|
@ -261,6 +271,7 @@ async def analytics_dashboard(
|
|||
count(*) FILTER (WHERE event_type = 'estimate_request') AS searches,
|
||||
max(created_at) AS last_seen
|
||||
FROM user_events
|
||||
WHERE username <> '' -- не аккаунт, см. /audit/accounts выше (#2715)
|
||||
GROUP BY username
|
||||
ORDER BY events DESC
|
||||
LIMIT 50
|
||||
|
|
|
|||
|
|
@ -28,22 +28,50 @@ Security:
|
|||
username с `:` внутри мог бы схлопнуть бюджет с другой (username, ip)
|
||||
парой (IPv6-адреса тоже содержат `:`, так что просто эскейпить разделитель
|
||||
в username недостаточно — паразитная граница возможна с обеих сторон).
|
||||
- Настоящий ПОТОЛОК ТЕМПА — `verify_password_bounded` (#2665): bcrypt считает
|
||||
282 мс, и ровно столько же он раньше держал заблокированным единственный
|
||||
событийный цикл, кладя вместе с логином ВЕСЬ API. Теперь bcrypt крутится в
|
||||
пуле из `login_password_verify_workers` потоков, а число потоков и есть
|
||||
потолок (проверок/с не больше workers/282мс). Убрать одно без другого
|
||||
нельзя: вынос без потолка ускорил бы перебор вчетверо, потолок без выноса
|
||||
оставил бы отказ в обслуживании. Сверх очереди — 429, не ожидание.
|
||||
Слоты делятся ПО АДРЕСУ (#2714): один источник не занимает больше половины,
|
||||
иначе потолок бил и по своим — легитимный вход с верным паролем во время
|
||||
флуда получал 429 столько раз, сколько пытался. Ключ — IP, поэтому защита
|
||||
поднимает стоимость атаки, но не закрывает её (подделка за вторым прокси,
|
||||
общий адрес за NAT, ротация через ботнет) — см. docstring той же функции.
|
||||
Отказ по насыщению выдаётся ДО выборки из реестра (#2715): иначе на этом
|
||||
пути оставалась бы единственная работа, время которой зависит от того,
|
||||
существует ли имя, — а bcrypt, который эту разницу ровняет, до него уже не
|
||||
доходит. След инцидента — агрегированный, `_saturated_429`.
|
||||
- Поверх него — ГЛОБАЛЬНЫЙ счётчик неудач на ИМЯ, без IP в ключе (#2571):
|
||||
лимит по паре (username, IP) распределённый перебор обходит целиком, просто
|
||||
меняя адрес. Превышение порога не блокирует вход, а замедляет ответ
|
||||
(`_throttle_delay_s`) — см. развёрнутое обоснование там же.
|
||||
- Raw-пароль НИКОГДА не логируется и не попадает в user_events payload —
|
||||
только username/ip/user_agent/path/method (см. schedule_event ниже).
|
||||
только username/ip/user_agent/path/method и (для неудач) состояние
|
||||
счётчика попыток: сколько их за окно и какая задержка применена.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.password import hash_password, verify_password
|
||||
from app.core.password import (
|
||||
PasswordVerifyOverloadedError,
|
||||
hash_password,
|
||||
verify_password_bounded,
|
||||
verify_slots_saturated,
|
||||
)
|
||||
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
||||
from app.services.auth_session import create_session, get_user_by_username, revoke_session
|
||||
from app.services.identity_store import AccessState, get_identity_db
|
||||
|
|
@ -63,6 +91,32 @@ _LOGIN_LIMITER = SlidingWindowLimiter(
|
|||
window_s=settings.login_rate_limit_window_s,
|
||||
)
|
||||
|
||||
# Глобальный счётчик неудач НА ИМЯ (#2571) — ключ БЕЗ IP, поэтому попытки со
|
||||
# всех адресов складываются в один бюджет. Дополняет `_LOGIN_LIMITER`, а не
|
||||
# заменяет: тот режет частый перебор с одного адреса, этот — редкий, но с
|
||||
# тысячи адресов (credential stuffing), от которого per-(username, IP) ключ не
|
||||
# защищает вообще — каждый новый адрес получает свежие login_rate_limit попыток.
|
||||
#
|
||||
# Живёт В ПАМЯТИ ПРОЦЕССА — сознательно, а не по недосмотру. Прод-бэкенд
|
||||
# запущен одним uvicorn-воркером (docker-compose.prod.yml, комментарий над
|
||||
# `command`: «Single worker сохраняется для предсказуемости»), значит счётчик и
|
||||
# так глобален, а Redis в auth-пути добавил бы сетевую зависимость там, где её
|
||||
# падение = либо дыра (fail-open), либо отказ входа (fail-closed).
|
||||
# Потолок: появятся воркеры (`--workers N`) — потолок делится на N, и его надо
|
||||
# переносить в Redis (`app.services.cache` уже держит там пул). Тот же ceiling
|
||||
# у соседнего `_LOGIN_LIMITER`; перезапуск процесса обнуляет оба.
|
||||
#
|
||||
# ⚠️ `limit` здесь НЕ ПОРОГ и ничего не режет: мы зовём только `record()`, а он
|
||||
# на лимит не смотрит — считает и отдаёт число попыток в окне. Настоящий порог
|
||||
# живёт в `_throttle_delay_s`, которая читает настройку на каждом вызове (и
|
||||
# потому подхватывает monkeypatch в тестах). Значение продублировано сюда ровно
|
||||
# для того, чтобы `retry_after()` на этом объекте — если его однажды позовут —
|
||||
# отвечал по тому же числу, а не по случайному.
|
||||
_USERNAME_FAIL_LIMITER = SlidingWindowLimiter(
|
||||
limit=settings.login_username_fail_threshold,
|
||||
window_s=settings.login_username_fail_window_s,
|
||||
)
|
||||
|
||||
# Timing-oracle защита (см. module docstring): bcrypt-хеш случайного пароля,
|
||||
# сгенерированный ОДИН РАЗ на импорте модуля — используется вместо
|
||||
# password_hash, когда юзер не найден/деактивирован/без пароля, чтобы
|
||||
|
|
@ -84,7 +138,15 @@ _ACCESS_EXPIRED_MESSAGE = "Пробный доступ закончился"
|
|||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
# max_length=64 — ровно верхняя граница CHECK'а реестра
|
||||
# (`users_username_ascii_ck`, data/sql/auth/001), так что живое имя отсечь
|
||||
# нельзя. Ограничение нужно не валидации ради: сырое имя становится ключом
|
||||
# ОБОИХ лимитеров, а их `defaultdict` подчищается только при >10000 ключей и
|
||||
# только от пустых корзин — при окне в час корзины непустые, освобождать
|
||||
# нечего. Без границы длины килобайтные имена растили бы память ключами.
|
||||
# Паттерн/минимум длины НЕ дублируем: в режиме `identity_store="tradein"`
|
||||
# CHECK'а нет и живут не-ASCII имена (см. тест на кириллицу).
|
||||
username: str = Field(max_length=64)
|
||||
password: str
|
||||
|
||||
|
||||
|
|
@ -92,6 +154,208 @@ class LoginResponse(BaseModel):
|
|||
ok: bool = True
|
||||
|
||||
|
||||
def _throttle_delay_s(fails_in_window: int) -> float:
|
||||
"""Насколько задержать ответ на неудачный вход при *fails_in_window* неудачах
|
||||
по этому имени за окно. 0 — пока порог не перебран.
|
||||
|
||||
Замедление, а НЕ блокировка — намеренно. Жёсткая блокировка учётки после N
|
||||
неудач лечится злоумышленником в свою пользу: не зная ни одного пароля, он
|
||||
гарантированно выключает вход конкретному человеку (директору, админу) —
|
||||
отказ в обслуживании дешевле и надёжнее, чем то, от чего блокировка
|
||||
защищает. Задержка же не отнимает доступ ни у кого: владелец пароля войдёт
|
||||
с первой попытки, просто ответ на очередную НЕУДАЧУ придёт медленнее.
|
||||
|
||||
Рост удвоением от 1с с потолком `login_username_throttle_max_delay_s`:
|
||||
первые перебранные попытки почти незаметны, а сотни — упираются в потолок.
|
||||
Потолок обязателен: без него задержка становится той же блокировкой, только
|
||||
растянутой во времени.
|
||||
|
||||
Показатель степени зажат (`min(..., 16)`) — это не косметика. `min()` считает
|
||||
ОБА аргумента до сравнения, поэтому наивный `float(2 ** (excess - 1))` при
|
||||
excess>=1025 падает с `OverflowError: int too large to convert to float` —
|
||||
то есть ровно под целевой нагрузкой (1045 неудач по имени за час = 0.3 rps)
|
||||
защита начинала отдавать 500 мгновенно и без аудита, вместо 401 с задержкой.
|
||||
2**16 = 65536с заведомо больше любого разумного потолка, так что зажим
|
||||
видимого поведения не меняет, а арифметику делает безусловно конечной.
|
||||
"""
|
||||
excess = fails_in_window - settings.login_username_fail_threshold
|
||||
if excess <= 0:
|
||||
return 0.0
|
||||
return min(settings.login_username_throttle_max_delay_s, 2.0 ** min(excess - 1, 16))
|
||||
|
||||
|
||||
# Не чаще одной записи в это окно на ВСЕ отказы по насыщению (#2715). Окно, а не
|
||||
# запись на запрос, потому что лог у бэкенда общий и ограниченный (docker
|
||||
# json-file, max-size 20m × max-file 3): при флуде в сотни запросов в секунду
|
||||
# строка на каждый отказ прокручивает 60 МБ за минуты и выселяет ВСЕ остальные
|
||||
# логи ровно во время атаки — то есть в момент, когда они нужнее всего.
|
||||
# Значение не в настройках намеренно: это не тюнинг, а «человек читает лог», и
|
||||
# крутить его нечем — меньше секунды возвращает исходную проблему, больше
|
||||
# ухудшает разрешение по времени, не давая взамен ничего.
|
||||
_SATURATION_REPORT_WINDOW_S = 1.0
|
||||
|
||||
# Отказов с прошлой записи и когда была прошлая запись (monotonic; None — записи
|
||||
# ещё не было). Обычные глобалы без лока — по той же причине, что и счётчик
|
||||
# слотов в `app.core.password`: обе строчки исполняются в потоке событийного
|
||||
# цикла и между чтением и записью нет `await`.
|
||||
_saturation_rejected = 0
|
||||
_saturation_reported_at: float | None = None
|
||||
|
||||
|
||||
def _saturated_429(ip: str) -> HTTPException:
|
||||
"""429 «слоты сверки заняты» + АГРЕГИРОВАННЫЙ след инцидента.
|
||||
|
||||
Событие неудачного входа тут не пишется и бюджет неудач по имени не
|
||||
тратится сознательно (#2712): пароль не проверялся, это не попытка входа, а
|
||||
трата бюджета означала бы, что насыщением можно заблокировать чужую учётку.
|
||||
Но тогда весь инцидент виден ровно здесь, и до #2715 — только строкой в
|
||||
логе на каждый отклонённый запрос (см. `_SATURATION_REPORT_WINDOW_S`).
|
||||
|
||||
Поэтому на окно приходится одна строка в лог И одно событие
|
||||
`login_verify_saturated` в `user_events` — с числом отказов, накопленных с
|
||||
прошлой записи. Событие важнее строки: аудит переживает и ротацию логов, и
|
||||
редеплой. Первый отказ отчитывается сразу, а не в конце окна: одиночная
|
||||
аномалия обязана быть видна, даже если продолжения не будет.
|
||||
|
||||
`since_prev_s` в payload — НЕ дубль `created_at`, а единственный способ
|
||||
прочитать счётчик правильно. Хвост копится, пока не придёт следующий отказ:
|
||||
атака кончилась в 03:00, 900 отказов остались неотчитанными — и во вторник
|
||||
одиночный 429 соседа по NAT унёс бы их все в запись, датированную вторником
|
||||
и подписанную АДРЕСОМ СОСЕДА. С `since_prev_s` видно, что 901 отказ
|
||||
накоплен за неделю, а не за секунду, и что читать `ip` в этой записи не
|
||||
надо. `None` — первая запись за жизнь процесса, сравнивать не с чем.
|
||||
|
||||
Уровень ERROR, а не WARNING, — не косметика: бэкенд поднят с
|
||||
`LoggingIntegration(level=INFO, event_level=ERROR)` (app/main.py), то есть
|
||||
ровно с ERROR запись становится событием GlitchTip, а WARNING остаётся
|
||||
строкой в docker-логе, которая умирает с ротацией и редеплоем. Цена
|
||||
прецедента известна (#2674): монитор писал WARNING про протухшие куки — и
|
||||
событий было ноль. Спама не будет: запись не чаще раза в окно, и все они
|
||||
группируются в один issue (шаблон сообщения один).
|
||||
|
||||
Чего это НЕ делает: у GlitchTip-проекта нет ни правил, ни получателей
|
||||
(#2673), так что уведомление никому не уйдёт — событие будет видно в
|
||||
интерфейсе, но не в чьём-то телефоне. Проверить доставку поведенчески
|
||||
сейчас не на чем, и утверждать её здесь было бы враньём.
|
||||
|
||||
`username=""` — не заглушка: имя не пишем ПОТОМУ, что отказ случился до
|
||||
того, как мы на него посмотрели. Записывай мы присланное, атакующий
|
||||
наполнял бы аудит строками с любым именем на выбор. Пустое имя — не аккаунт,
|
||||
и списки аудита его отфильтровывают (`WHERE username <> ''` в
|
||||
`app/api/v1/audit.py`), иначе оно встало бы первой строкой в списке
|
||||
аккаунтов и фантомом в `count(DISTINCT username)`. `ip` — адрес последнего
|
||||
отклонённого запроса, то есть ОБРАЗЕЦ: при распределённом флуде адресов
|
||||
много, и по одной записи их не восстановить (счётчик — восстановит).
|
||||
|
||||
Потолок объёма: час непрерывной атаки — это 3600 строк в `user_events`
|
||||
(в таблице за всю её жизнь ~3.4 тысячи), сутки — под 86 тысяч. Retention у
|
||||
таблицы нет, а `GET /audit/accounts` делает полный `GROUP BY` без фильтра по
|
||||
времени. То же давление уходит на квоту проекта в GlitchTip — тот же
|
||||
механизм вытеснения чужого сигнала, только в другом ведре. Дойдёт до этого —
|
||||
окно агрегации растёт с длительностью атаки (экспонента с потолком, как у
|
||||
`_throttle_delay_s`), это следующий шаг, а не сегодняшний.
|
||||
"""
|
||||
global _saturation_rejected, _saturation_reported_at
|
||||
|
||||
_saturation_rejected += 1
|
||||
now = time.monotonic()
|
||||
since_prev = None if _saturation_reported_at is None else now - _saturation_reported_at
|
||||
if since_prev is None or since_prev >= _SATURATION_REPORT_WINDOW_S:
|
||||
rejected, _saturation_rejected = _saturation_rejected, 0
|
||||
_saturation_reported_at = now
|
||||
logger.error(
|
||||
"login rejected: password verify saturated — %d отказов, "
|
||||
"с прошлой записи %s с, последний ip=%s",
|
||||
rejected,
|
||||
"—" if since_prev is None else f"{since_prev:.1f}",
|
||||
ip,
|
||||
)
|
||||
schedule_event(
|
||||
event_type="login_verify_saturated",
|
||||
username="",
|
||||
ip=ip,
|
||||
path="/api/v1/auth/login",
|
||||
method="POST",
|
||||
payload={
|
||||
"rejected": rejected,
|
||||
# Считается ДО сдвига `_saturation_reported_at` — иначе всегда 0.
|
||||
"since_prev_s": None if since_prev is None else round(since_prev, 1),
|
||||
},
|
||||
)
|
||||
|
||||
# Retry-After 1с — порядок времени одной сверки, не окно соседнего
|
||||
# `_LOGIN_LIMITER`. Ответ ОДИН И ТОТ ЖЕ для любого имени: отказ приходит до
|
||||
# сверки и потому ничего не сообщает о том, существует ли учётка.
|
||||
return HTTPException(
|
||||
status_code=429,
|
||||
detail="слишком много попыток входа, попробуйте позже",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
async def _reject_invalid_credentials(
|
||||
db: Session, username: str, ip: str, user_agent: str | None
|
||||
) -> HTTPException:
|
||||
"""Единый хвост ЛЮБОГО отказа по кредам: счётчик → аудит → задержка → 401.
|
||||
|
||||
Один код на все ветки отказа (нет такого имени / неверный пароль / доступ
|
||||
закрыт / password_hash NULL) — это не борьба с дублированием, а инвариант:
|
||||
ветки обязаны быть неразличимы снаружи. Разъедься они по телу хендлера —
|
||||
и достаточно забыть задержку в одной, чтобы «быстрый 401» стал оракулом
|
||||
существования учётки ровно в том же виде, что и разные сообщения об ошибке.
|
||||
Поэтому счётчик ведётся по ПРИСЛАННОМУ имени, без проверки, есть ли такое
|
||||
в реестре: несуществующее имя копит неудачи и тормозит так же, как живое.
|
||||
(`get_user_by_username` сверяет `username = :username` по text-колонке без
|
||||
нормализации, так что сырое имя — тот же ключ, что и у поиска: регистром
|
||||
счётчик не обойти.)
|
||||
|
||||
Возвращает `HTTPException`, а не бросает: `raise await …` не собирается, а
|
||||
`raise (await …)` читается хуже, чем `raise` над возвращённым значением.
|
||||
|
||||
*db* нужен ровно затем, чтобы ОТДАТЬ соединение перед сном. `get_identity_db`
|
||||
в дефолтном режиме (`identity_store="tradein"`, он же прод) отдаёт ту же
|
||||
сессию, что `get_db` — движок с QueuePool на 5+10 соединений. После SELECT в
|
||||
`get_user_by_username` сессия держит соединение в открытой транзакции, и сон
|
||||
внутри её области жизни превращал бы каждую спящую попытку в занятое
|
||||
соединение: ~15 одновременных неудач выбирают пул целиком, и тогда ЛЮБОЙ
|
||||
эндпоинт ждёт checkout 30с и падает. Отказ в обслуживании против всех сразу —
|
||||
хуже той блокировки учётки, ради отказа от которой всё это писалось.
|
||||
"""
|
||||
fails = _USERNAME_FAIL_LIMITER.record(username)
|
||||
delay_s = _throttle_delay_s(fails)
|
||||
|
||||
schedule_event(
|
||||
event_type="login_failed",
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
path="/api/v1/auth/login",
|
||||
method="POST",
|
||||
# Состояние глобального счётчика — в аудит: по нему в user_events видно
|
||||
# именно РАСПРЕДЕЛЁННЫЙ перебор (десятки неудач по одному имени с разных
|
||||
# ip_address), который иначе выглядит как россыпь одиночных неудач.
|
||||
payload={"username_fails_in_window": fails, "throttle_delay_s": delay_s},
|
||||
)
|
||||
|
||||
if delay_s > 0:
|
||||
logger.warning(
|
||||
"login throttle: username=%r fails=%d delay=%.1fs ip=%s",
|
||||
username,
|
||||
fails,
|
||||
delay_s,
|
||||
ip,
|
||||
)
|
||||
# Соединение — в пул ДО сна (см. docstring). Сессия дальше не нужна:
|
||||
# вызывающий немедленно делает raise, а повторный close() в самой
|
||||
# зависимости идемпотентен.
|
||||
db.close()
|
||||
# await, не time.sleep: событийный цикл в это время обслуживает всех
|
||||
# остальных — тормозим перебор, а не сервис.
|
||||
await asyncio.sleep(delay_s)
|
||||
|
||||
return HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
body: LoginRequest,
|
||||
|
|
@ -111,6 +375,16 @@ async def login(
|
|||
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||
)
|
||||
|
||||
# Гейт насыщения — ДО выборки из реестра (#2715). Заведомо отклоняемый
|
||||
# запрос не берёт соединение из пула и не делает SELECT по имени: под
|
||||
# насыщением это была бы единственная работа на пути отказа, а значит и
|
||||
# единственное, чьё время зависит от существования учётки — bcrypt, который
|
||||
# эту разницу ровняет, до отказанного запроса не доходит вовсе. Решение
|
||||
# всё равно остаётся за `verify_password_bounded` ниже (тот же предикат,
|
||||
# `except` под ним никуда не делся) — здесь только экономия похода в базу.
|
||||
if verify_slots_saturated(ip):
|
||||
raise _saturated_429(ip)
|
||||
|
||||
user = get_user_by_username(db, body.username)
|
||||
hash_to_check = (
|
||||
user["password_hash"]
|
||||
|
|
@ -119,20 +393,26 @@ async def login(
|
|||
)
|
||||
# ВСЕГДА вызывается — dummy-хеш при отсутствующем юзере/NULL password_hash
|
||||
# держит время ответа одинаковым независимо от существования аккаунта.
|
||||
password_ok = verify_password(body.password, hash_to_check)
|
||||
try:
|
||||
# key=ip — доля слотов на адрес (#2714): один источник не занимает больше
|
||||
# половины ёмкости, и вход остаётся открыт тем, кто приходит с других
|
||||
# адресов. Ключ — ИМЕННО адрес, не имя: имя присылает клиент, и перебор
|
||||
# менял бы его каждую попытку, получая полную долю на каждое. Границы
|
||||
# применимости (IP подделывается за вторым прокси, разделяется за NAT,
|
||||
# ротируется ботнетом) — в docstring `verify_password_bounded`.
|
||||
password_ok = await verify_password_bounded(body.password, hash_to_check, key=ip)
|
||||
except PasswordVerifyOverloadedError:
|
||||
# Настоящий потолок темпа (#2665): слоты проверки заняты, ждать нельзя —
|
||||
# ждущий держит соединение к БД. Предчек выше сюда почти всё и отсекает,
|
||||
# но авторитетен ИМЕННО ЭТОТ отказ, поэтому ветка остаётся. Ответ —
|
||||
# тот же самый и с той же аргументацией, что у предчека: один helper,
|
||||
# чтобы две ветки не разъехались (одинаковость 429 — часть защиты).
|
||||
raise _saturated_429(ip) from None
|
||||
|
||||
# Пароль проверен ВЫШЕ и безусловно — только теперь смотрим на состояние
|
||||
# доступа. Порядок несущий, а не стилистический: см. модульный docstring.
|
||||
if user is None or not password_ok:
|
||||
schedule_event(
|
||||
event_type="login_failed",
|
||||
username=body.username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
path="/api/v1/auth/login",
|
||||
method="POST",
|
||||
)
|
||||
raise HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL)
|
||||
raise await _reject_invalid_credentials(db, body.username, ip, user_agent)
|
||||
|
||||
access_state = user["access_state"]
|
||||
if access_state is AccessState.TRIAL_EXPIRED:
|
||||
|
|
@ -156,17 +436,9 @@ async def login(
|
|||
|
||||
if not access_state.can_sign_in:
|
||||
# disabled (и любое нераспознанное состояние — to_access_state fail-closed)
|
||||
# → ТОТ ЖЕ generic 401 и то же событие, что при неверном пароле:
|
||||
# заблокированный аккаунт неотличим от несуществующего.
|
||||
schedule_event(
|
||||
event_type="login_failed",
|
||||
username=body.username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
path="/api/v1/auth/login",
|
||||
method="POST",
|
||||
)
|
||||
raise HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL)
|
||||
# → ТОТ ЖЕ generic 401, то же событие и та же задержка, что при неверном
|
||||
# пароле: заблокированный аккаунт неотличим от несуществующего.
|
||||
raise await _reject_invalid_credentials(db, body.username, ip, user_agent)
|
||||
|
||||
token = create_session(db, user_id=user["user_id"], ip=ip, user_agent=user_agent)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Annotated, Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ from sqlalchemy import text
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.v1.trade_in import _assert_estimate_access
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -115,17 +117,26 @@ async def create_trade_in_lead(
|
|||
# consent_text_snapshot теперь durable-колонки на trade_in_leads (migration 182,
|
||||
# ранее — только audit-лог, #2497 TODO). client_ip может быть None (нет
|
||||
# X-Forwarded-For и request.client) — колонка nullable, CAST(NULL AS inet) валиден.
|
||||
#
|
||||
# ЭТАП 4 B2C: expires_at (migration 231) — раньше лид хранился бессрочно
|
||||
# (никакого TTL вообще не было, в отличие от trade_in_estimates.expires_at).
|
||||
# Считаем на insert-time тем же паттерном, что estimator.py делает для
|
||||
# trade_in_estimates — retention-период вынесен в settings, не хардкод.
|
||||
expires_at = datetime.now(tz=UTC) + timedelta(days=settings.trade_in_lead_retention_days)
|
||||
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO trade_in_leads (
|
||||
estimate_id, phone, consent, source, user_agent,
|
||||
client_ip, consent_policy_version, consent_text_snapshot
|
||||
client_ip, consent_policy_version, consent_text_snapshot,
|
||||
expires_at
|
||||
)
|
||||
VALUES (
|
||||
CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent,
|
||||
CAST(:client_ip AS inet), :consent_policy_version, :consent_text_snapshot
|
||||
CAST(:client_ip AS inet), :consent_policy_version, :consent_text_snapshot,
|
||||
:expires_at
|
||||
)
|
||||
RETURNING CAST(id AS text), created_at
|
||||
"""
|
||||
|
|
@ -139,6 +150,7 @@ async def create_trade_in_lead(
|
|||
"client_ip": client_ip,
|
||||
"consent_policy_version": _CONSENT_POLICY_VERSION,
|
||||
"consent_text_snapshot": _CONSENT_TEXT_SNAPSHOT,
|
||||
"expires_at": expires_at,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
|
|
|
|||
79
tradein-mvp/backend/app/api/v1/privacy_admin.py
Normal file
79
tradein-mvp/backend/app/api/v1/privacy_admin.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Admin right-to-erasure endpoint (152-ФЗ) — ЭТАП 4 B2C launch, part C.
|
||||
|
||||
Auth не нужен в этом файле — вся ветка `/api/v1/admin/*` уже гейтится
|
||||
`rbac_guard` middleware в app/main.py (`_ADMIN_API_RE`, role != admin → 403),
|
||||
тем же паттерном, что app/api/v1/audit.py.
|
||||
|
||||
Мутационный (DELETE), поэтому осторожно: это НЕ self-service для конечного
|
||||
пользователя. Оператор поддержки/admin вызывает это ПОСЛЕ того, как убедился
|
||||
(вне этого API — телефон/estimate-ссылка/переписка), что запрос на удаление
|
||||
реально пришёл от владельца данных, а не от третьего лица, знающего чей-то
|
||||
номер телефона. Идентификация анонима — см. app/services/data_erasure.py
|
||||
module docstring (честно про то, что не всегда разрешимо).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class DataErasureRequest(BaseModel):
|
||||
"""Хотя бы одно поле обязательно — см. erase_person_data ValueError guard."""
|
||||
|
||||
username: str | None = Field(default=None, max_length=200)
|
||||
estimate_ids: list[UUID] | None = None
|
||||
phone: str | None = Field(default=None, max_length=32)
|
||||
tg_chat_id: int | None = None
|
||||
|
||||
|
||||
@router.post("/privacy/erase")
|
||||
async def erase_person_data_endpoint(
|
||||
payload: DataErasureRequest,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Физически удалить данные человека по одному или нескольким идентификаторам.
|
||||
|
||||
Идентификаторы (хотя бы один):
|
||||
- username — B2B-пилот: удаляет ВСЕ его оценки (created_by=username, CASCADE
|
||||
подчищает фото/IMV-оценки), связанные лиды, веб-чат поддержки.
|
||||
- estimate_ids — конкретные оценки по UUID (анонимный путь: человек прислал
|
||||
ссылку/PDF со своим estimate_id) + лиды, привязанные к ним.
|
||||
- phone — лиды с этим номером телефона (независимо от привязки к оценке).
|
||||
- tg_chat_id — Telegram-поддержка (@MERAsupport_bot), включая переписку В
|
||||
ЭТОЙ БД. НЕ удаляет зеркало в Telegram-топике (см.
|
||||
app/services/data_erasure.py — ВАЖНЫЙ ФАКТ, честно, не скрываем).
|
||||
|
||||
422 если ни один идентификатор не передан (не даём случайно вызвать
|
||||
"удали всё" пустым телом).
|
||||
"""
|
||||
if not any([payload.username, payload.estimate_ids, payload.phone, payload.tg_chat_id]):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="at least one identifier required: username / estimate_ids / phone / tg_chat_id",
|
||||
)
|
||||
|
||||
from app.services.data_erasure import erase_person_data
|
||||
|
||||
counters = await asyncio.to_thread(
|
||||
erase_person_data,
|
||||
db,
|
||||
username=payload.username,
|
||||
estimate_ids=payload.estimate_ids,
|
||||
phone=payload.phone,
|
||||
tg_chat_id=payload.tg_chat_id,
|
||||
)
|
||||
logger.info("admin privacy erase requested -> %s", counters)
|
||||
return {"deleted": counters}
|
||||
|
|
@ -5,7 +5,9 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import calendar
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from typing import Annotated, Any
|
||||
|
|
@ -52,6 +54,27 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
# PR-D1: единственное определение «оценка читаема» — раньше SQL-фильтр (404,
|
||||
# ниже в get_estimate) и Python-проверка (410, в estimate_pdf) уже разошлись
|
||||
# по коду ответа; третий потребитель (`/r/<token>`, PR-9) разошёлся бы
|
||||
# неизбежно без унификации. `retain_until > NOW()` при NULL даёт NULL → false
|
||||
# в SQL — для всех существующих строк (retain_until IS NULL) поведение не
|
||||
# меняется вообще. Не копировать это выражение по месту — только через
|
||||
# константу/хелпер ниже. Payments retention, PR #2754.
|
||||
ESTIMATE_READABLE_SQL = "(expires_at > NOW() OR retain_until > NOW())"
|
||||
|
||||
|
||||
def estimate_readable(expires_at: datetime, retain_until: datetime | None) -> bool:
|
||||
"""Python-зеркало ESTIMATE_READABLE_SQL — та же дизъюнкция, без похода в БД.
|
||||
|
||||
tzinfo-нормализация повторяет прежнюю Python-проверку (estimate_pdf) —
|
||||
`.replace(tzinfo=UTC)`, не переизобретается.
|
||||
"""
|
||||
now = datetime.now(tz=UTC)
|
||||
if expires_at.replace(tzinfo=UTC) > now:
|
||||
return True
|
||||
return retain_until is not None and retain_until.replace(tzinfo=UTC) > now
|
||||
|
||||
|
||||
def _assert_estimate_access(created_by: str | None, x_authenticated_user: str | None) -> None:
|
||||
"""IDOR guard (#690): только владелец оценки или admin могут её читать.
|
||||
|
|
@ -146,6 +169,239 @@ def _resolve_target_house_id(
|
|||
return None
|
||||
|
||||
|
||||
# ── Revival на GET /estimate/{id} (incident 2026-08-10) ─────────────────────
|
||||
# Заказчик открыл сохранённую ссылку (?id=...) и увидел «НЕДОСТАТОЧНО ДАННЫХ»:
|
||||
# запись создана ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825) и
|
||||
# лежит в БД мёртвой (median_price<=0/NULL), хотя тот же адрес/параметры
|
||||
# сейчас честно считаются. get_estimate() ниже пытается пересчитать такую
|
||||
# строку ОДИН раз (throttled) через тот же estimate_quality(), что и POST
|
||||
# /estimate, и пишет результат В ТУ ЖЕ строку (id/ссылка не меняются). Живую
|
||||
# строку (median_price>0) этот путь не трогает вообще — сохранённая клиенту
|
||||
# цена неприкосновенна.
|
||||
def _precision_to_qc_geo(precision: str | None) -> int | None:
|
||||
"""Best-effort обратное отображение к estimator._qc_geo_to_precision.
|
||||
|
||||
AggregatedEstimate наружу отдаёт только бакетированный address_precision
|
||||
(house/street/approximate), не сырой dadata.qc_geo (0..5) — тот остаётся
|
||||
приватным для estimate_quality(). При revival нам нужно записать ЧТО-ТО в
|
||||
колонку dadata_qc_geo, чтобы будущие (уже НЕ revival, обычные) GET той же
|
||||
теперь-живой строки не откатили address_precision в None. Бакеты 2..5
|
||||
(settlement/city/region/unknown) неразличимы ПОСЛЕ _qc_geo_to_precision —
|
||||
2 репрезентативно для всех: тот же helper на чтении схлопывает их обратно
|
||||
в тот же "approximate", наблюдаемое поведение не меняется.
|
||||
"""
|
||||
if precision == "house":
|
||||
return 0
|
||||
if precision == "street":
|
||||
return 1
|
||||
if precision == "approximate":
|
||||
return 2
|
||||
return None
|
||||
|
||||
|
||||
def _payload_from_dead_row(row: Any) -> TradeInEstimateInput:
|
||||
"""Восстанавливает вход оценки из мёртвой сохранённой строки для revival.
|
||||
|
||||
Только поля, реально персистящиеся в trade_in_estimates при создании
|
||||
(address/lat/lon/area_m2/rooms/floor/total_floors/year_built/house_type/
|
||||
repair_state/has_balcony) — CRM-only поля (ownership_type/has_mortgage)
|
||||
на расчёт не влияют и не нужны здесь. radius_m НИКОГДА не персистится
|
||||
(payload.radius_m живёт только в рамках одного POST-запроса, ни главный
|
||||
INSERT, ни _empty_estimate его не пишут) — None здесь даёт тот же
|
||||
default-каскад (DEFAULT_RADIUS_M/FALLBACK_RADIUS_M), что у подавляющего
|
||||
большинства сохранённых строк (явный радиус выбирает меньшинство).
|
||||
consent=None + require_consent=False у вызывающего — revival не новое
|
||||
согласие физлица, а служебный recompute уже существующей записи.
|
||||
"""
|
||||
return TradeInEstimateInput(
|
||||
address=row.address,
|
||||
area_m2=float(row.area_m2),
|
||||
rooms=row.rooms,
|
||||
floor=row.floor,
|
||||
total_floors=row.total_floors,
|
||||
year_built=row.year_built,
|
||||
house_type=row.house_type,
|
||||
repair_state=row.repair_state,
|
||||
has_balcony=row.has_balcony,
|
||||
lat=row.lat,
|
||||
lon=row.lon,
|
||||
radius_m=None,
|
||||
consent=None,
|
||||
)
|
||||
|
||||
|
||||
async def _try_revive_dead_estimate(
|
||||
db: Session, estimate_id: UUID, row: Any
|
||||
) -> AggregatedEstimate | None:
|
||||
"""Пытается пересчитать «мёртвую» (median_price<=0/NULL) строку на месте.
|
||||
|
||||
Возвращает свежий AggregatedEstimate (estimate_id ПОДМЕНЁН на исходный —
|
||||
id/ссылка не меняются) при успехе; None если: (а) throttle ещё не истёк /
|
||||
заявку уже забрал параллельный запрос — anti-storm через атомарный
|
||||
conditional `UPDATE ... RETURNING` ниже (тот же паттерн, что
|
||||
account_quota.increment, #747): WHERE перепроверяет и «мертва ли строка
|
||||
сейчас», и «давно ли последняя попытка» НЕПОСРЕДСТВЕННО в БД, а не по
|
||||
значению, прочитанному раньше в Python — TOCTOU-гонка между двумя
|
||||
параллельными GET невозможна, проигравший просто не дублирует работу;
|
||||
(б) пересчёт сам дал 0 (по-прежнему недостаточно данных); (в) пересчёт
|
||||
упал с исключением (сеть/геокод/что угодно). Во всех трёх случаях caller
|
||||
обязан отдать сохранённую (по-прежнему мёртвую) строку как раньше — НЕ 500.
|
||||
"""
|
||||
claim = db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE trade_in_estimates
|
||||
SET revival_attempted_at = NOW()
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
AND (median_price <= 0 OR median_price IS NULL)
|
||||
AND (
|
||||
revival_attempted_at IS NULL
|
||||
OR revival_attempted_at
|
||||
< NOW() - make_interval(mins => CAST(:throttle AS integer))
|
||||
)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{"id": str(estimate_id), "throttle": settings.trade_in_revival_throttle_minutes},
|
||||
).fetchone()
|
||||
db.commit()
|
||||
if claim is None:
|
||||
logger.info("estimate revival throttled/lost race: id=%s", estimate_id)
|
||||
return None
|
||||
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
try:
|
||||
payload = _payload_from_dead_row(row)
|
||||
result = await estimate_quality(
|
||||
payload,
|
||||
db,
|
||||
created_by=row.created_by,
|
||||
client_ip=None,
|
||||
require_consent=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("estimate revival failed: id=%s address=%r", estimate_id, row.address)
|
||||
return None
|
||||
|
||||
temp_id = result.estimate_id
|
||||
if result.median_price_rub <= 0:
|
||||
logger.info("estimate revival still insufficient data: id=%s", estimate_id)
|
||||
db.execute(
|
||||
text("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": str(temp_id)},
|
||||
)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
# estimate_quality() persists under a BRAND NEW uuid (temp_id) — it has no
|
||||
# notion of "recompute this existing row". Copy the computed OUTPUT fields
|
||||
# into the ORIGINAL row (id/link contract), then drop the throwaway one.
|
||||
# INPUT snapshot (address/area/rooms/...) is untouched — it did not change,
|
||||
# only the outputs were recomputed.
|
||||
# #incident-2026-08-11: created_at is DELIBERATELY excluded from this SET —
|
||||
# it is the client's original request date (printed in /history and in
|
||||
# AggregatedEstimate.created_at, see app/schemas/trade_in.py:317-318), NOT
|
||||
# a recompute output. It previously got clobbered with the throwaway temp
|
||||
# row's created_at (=NOW() at recompute time), which also silently
|
||||
# re-sorted the row to the top of `GET /history ORDER BY created_at DESC`.
|
||||
# revival_completed_at (migration 256) is the audit trail for "when did a
|
||||
# revival LAST successfully rewrite this row" — distinct from
|
||||
# revival_attempted_at (255), which is stamped on every claim regardless
|
||||
# of outcome (throttle loss / recompute failure included).
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE avito_imv_evaluations
|
||||
SET estimate_id = CAST(:orig AS uuid)
|
||||
WHERE estimate_id = CAST(:temp AS uuid)
|
||||
"""
|
||||
),
|
||||
{"orig": str(estimate_id), "temp": str(temp_id)},
|
||||
)
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE trade_in_estimates SET
|
||||
median_price = :median_price,
|
||||
range_low = :range_low,
|
||||
range_high = :range_high,
|
||||
median_price_per_m2 = :median_ppm2,
|
||||
confidence = :confidence,
|
||||
confidence_explanation = :explanation,
|
||||
n_analogs = :n_analogs,
|
||||
analogs = CAST(:analogs_json AS jsonb),
|
||||
actual_deals = CAST(:deals_json AS jsonb),
|
||||
sources_used = CAST(:sources_json AS jsonb),
|
||||
data_freshness_minutes = :freshness,
|
||||
canonical_address = :canonical_address,
|
||||
house_cadnum = :house_cadnum,
|
||||
house_fias_id = :house_fias_id,
|
||||
dadata_qc_geo = :dadata_qc_geo,
|
||||
dadata_metro = CAST(:dadata_metro_json AS jsonb),
|
||||
expected_sold_price = :expected_sold_price,
|
||||
expected_sold_range_low = :expected_sold_range_low,
|
||||
expected_sold_range_high = :expected_sold_range_high,
|
||||
expected_sold_per_m2 = :expected_sold_per_m2,
|
||||
asking_to_sold_ratio = :asking_to_sold_ratio,
|
||||
ratio_basis = :ratio_basis,
|
||||
relaxations = CAST(:relaxations_json AS jsonb),
|
||||
reliability = :reliability,
|
||||
revival_completed_at = NOW()
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": str(estimate_id),
|
||||
"median_price": result.median_price_rub,
|
||||
"range_low": result.range_low_rub,
|
||||
"range_high": result.range_high_rub,
|
||||
"median_ppm2": result.median_price_per_m2,
|
||||
"confidence": result.confidence,
|
||||
"explanation": result.confidence_explanation,
|
||||
"n_analogs": result.n_analogs,
|
||||
"analogs_json": json.dumps(
|
||||
[a.model_dump(mode="json") for a in result.analogs], ensure_ascii=False
|
||||
),
|
||||
"deals_json": json.dumps(
|
||||
[a.model_dump(mode="json") for a in result.actual_deals], ensure_ascii=False
|
||||
),
|
||||
"sources_json": json.dumps(result.sources_used, ensure_ascii=False),
|
||||
"freshness": result.data_freshness_minutes,
|
||||
"canonical_address": result.canonical_address,
|
||||
"house_cadnum": result.house_cadnum,
|
||||
"house_fias_id": result.house_fias_id,
|
||||
"dadata_qc_geo": _precision_to_qc_geo(result.address_precision),
|
||||
"dadata_metro_json": json.dumps(result.metro_nearest, ensure_ascii=False),
|
||||
"expected_sold_price": result.expected_sold_price_rub,
|
||||
"expected_sold_range_low": result.expected_sold_range_low_rub,
|
||||
"expected_sold_range_high": result.expected_sold_range_high_rub,
|
||||
"expected_sold_per_m2": result.expected_sold_per_m2,
|
||||
"asking_to_sold_ratio": result.asking_to_sold_ratio,
|
||||
"ratio_basis": result.ratio_basis,
|
||||
"relaxations_json": json.dumps(result.relaxations, ensure_ascii=False),
|
||||
"reliability": result.reliability,
|
||||
},
|
||||
)
|
||||
db.execute(
|
||||
text("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": str(temp_id)},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
"estimate revived: id=%s median=%d n=%d confidence=%s reliability=%s",
|
||||
estimate_id,
|
||||
result.median_price_rub,
|
||||
result.n_analogs,
|
||||
result.confidence,
|
||||
result.reliability,
|
||||
)
|
||||
# created_at on the returned object must mirror the DB row (untouched
|
||||
# original request date, NOT the temp row's NOW()) — see UPDATE above.
|
||||
return result.model_copy(update={"estimate_id": estimate_id, "created_at": row.created_at})
|
||||
|
||||
|
||||
@router.post("/estimate", response_model=AggregatedEstimate)
|
||||
async def estimate(
|
||||
payload: TradeInEstimateInput,
|
||||
|
|
@ -171,8 +427,22 @@ async def estimate(
|
|||
# явный 503 — так любая БУДУЩАЯ реальная ошибка становится видимой, а не
|
||||
# «глотается» шлюзом. HTTPException пробрасываем как есть (это не сбой).
|
||||
# created_by (#656) прокидываем в estimate_quality для скоупа /history.
|
||||
# ЭТАП 4 B2C (152-ФЗ): require_consent=True только когда нет
|
||||
# X-Authenticated-User — сегодня rbac_guard (app/core/rbac.py) уже требует
|
||||
# этот заголовок на любом non-public пути, так что эта ветка пока
|
||||
# недостижима в проде (анонимный /estimate ещё не открыт другими частями
|
||||
# ЭТАП 4/B2C работ) — гейт готов ЗАРАНЕЕ, на момент открытия анонимного
|
||||
# доступа. client_ip — proof-of-consent (estimate_quality персистит его
|
||||
# на trade_in_estimates только когда require_consent=True; B2B-пилоты
|
||||
# остаются NULL, см. estimator.py::_estimate_consent_persist_fields).
|
||||
try:
|
||||
result = await estimate_quality(payload, db, created_by=x_authenticated_user)
|
||||
result = await estimate_quality(
|
||||
payload,
|
||||
db,
|
||||
created_by=x_authenticated_user,
|
||||
client_ip=_client_ip(request),
|
||||
require_consent=x_authenticated_user is None,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
|
|
@ -235,21 +505,22 @@ def get_estimate(
|
|||
"""
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
f"""
|
||||
SELECT id, median_price, range_low, range_high, median_price_per_m2,
|
||||
confidence, confidence_explanation, n_analogs,
|
||||
analogs, actual_deals, sources_used, data_freshness_minutes,
|
||||
expires_at, address, lat, lon,
|
||||
expires_at, retain_until, address, lat, lon,
|
||||
area_m2, rooms, floor, total_floors,
|
||||
year_built, house_type, repair_state, has_balcony,
|
||||
canonical_address, house_cadnum, house_fias_id,
|
||||
dadata_qc_geo, dadata_metro,
|
||||
expected_sold_price, expected_sold_range_low,
|
||||
expected_sold_range_high, expected_sold_per_m2,
|
||||
asking_to_sold_ratio, ratio_basis, created_by, created_at
|
||||
asking_to_sold_ratio, ratio_basis, created_by, created_at,
|
||||
relaxations, reliability
|
||||
FROM trade_in_estimates
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
AND expires_at > NOW()
|
||||
AND {ESTIMATE_READABLE_SQL}
|
||||
"""
|
||||
),
|
||||
{"id": str(estimate_id)},
|
||||
|
|
@ -260,6 +531,22 @@ def get_estimate(
|
|||
|
||||
_assert_estimate_access(row.created_by, x_authenticated_user)
|
||||
|
||||
# #incident-2026-08-10: строка «мертва» (median_price<=0/NULL) — посчитана
|
||||
# ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825). Пробуем
|
||||
# пересчитать её на месте (throttled, race-safe — см. докстринг
|
||||
# _try_revive_dead_estimate) через тот же путь, что и POST /estimate.
|
||||
# Живую строку (median_price>0) не трогаем вообще. asyncio.run() — sync↔
|
||||
# async мост (тот же паттерн, что app/scheduler_main.py): get_estimate
|
||||
# остаётся `def` (Starlette гоняет его в threadpool, как сейчас), поэтому
|
||||
# ОСТАЛЬНЫЕ синхронные db.execute() ниже по функции не переезжают на event
|
||||
# loop — только сама попытка revival временно занимает свой поток на время
|
||||
# await estimate_quality(). Любая ошибка расчёта — не 500: revived is None,
|
||||
# и функция просто продолжает как раньше, отдавая сохранённую строку.
|
||||
if row.median_price is None or row.median_price <= 0:
|
||||
revived = asyncio.run(_try_revive_dead_estimate(db, estimate_id, row))
|
||||
if revived is not None:
|
||||
return revived
|
||||
|
||||
from app.services.estimator import (
|
||||
_canonical_sources,
|
||||
_cv_from_ppm2,
|
||||
|
|
@ -358,6 +645,7 @@ def get_estimate(
|
|||
analogs=analogs,
|
||||
actual_deals=actual_deals,
|
||||
expires_at=row.expires_at,
|
||||
retain_until=row.retain_until,
|
||||
target_address=row.address,
|
||||
target_lat=row.lat,
|
||||
target_lon=row.lon,
|
||||
|
|
@ -395,6 +683,14 @@ def get_estimate(
|
|||
cv=cv,
|
||||
source_counts=source_counts,
|
||||
created_at=row.created_at,
|
||||
# PR #2823 open follow-up (fixed incident 2026-08-10, migration 255):
|
||||
# relaxations/reliability теперь персистятся — GET-rehydrate больше не
|
||||
# теряет красный баннер «точность снижена» при открытии по ссылке.
|
||||
# getattr defensive: старые in-memory test doubles / любая строка без
|
||||
# этих колонок (не должно случаться после миграции) деградируют в
|
||||
# дефолт схемы (ok / []), а не падают AttributeError.
|
||||
relaxations=list(getattr(row, "relaxations", None) or []),
|
||||
reliability=getattr(row, "reliability", None) or "ok",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -419,14 +715,15 @@ def estimate_pdf(
|
|||
SELECT id, median_price, range_low, range_high, median_price_per_m2,
|
||||
confidence, confidence_explanation, n_analogs,
|
||||
analogs, actual_deals, sources_used, data_freshness_minutes,
|
||||
expires_at,
|
||||
expires_at, retain_until,
|
||||
address, lat, lon, area_m2, rooms, floor, total_floors,
|
||||
year_built, house_type, repair_state, has_balcony,
|
||||
canonical_address, house_cadnum, house_fias_id,
|
||||
dadata_qc_geo, dadata_metro,
|
||||
expected_sold_price, expected_sold_range_low,
|
||||
expected_sold_range_high, expected_sold_per_m2,
|
||||
asking_to_sold_ratio, ratio_basis, created_by
|
||||
asking_to_sold_ratio, ratio_basis, created_by,
|
||||
relaxations, reliability
|
||||
FROM trade_in_estimates
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
"""
|
||||
|
|
@ -439,8 +736,12 @@ def estimate_pdf(
|
|||
|
||||
_assert_estimate_access(row.created_by, x_authenticated_user)
|
||||
|
||||
if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC):
|
||||
raise HTTPException(status_code=410, detail="estimate expired (24h TTL)")
|
||||
# PR-D1: тот же гейт, что в get_estimate (см. ESTIMATE_READABLE_SQL) — раньше
|
||||
# здесь была независимая Python-проверка expires_at, разошедшаяся с SQL-
|
||||
# фильтром GET-ручки. "estimate expired (24h TTL)" убрано из текста: при
|
||||
# годовом retain_until упоминание 24ч в ответе API стало бы ложью.
|
||||
if not estimate_readable(row.expires_at, row.retain_until):
|
||||
raise HTTPException(status_code=410, detail="estimate expired")
|
||||
|
||||
from app.services.estimator import _qc_geo_to_precision
|
||||
|
||||
|
|
@ -463,6 +764,7 @@ def estimate_pdf(
|
|||
analogs=analogs,
|
||||
actual_deals=actual_deals,
|
||||
expires_at=row.expires_at,
|
||||
retain_until=row.retain_until,
|
||||
target_address=row.address,
|
||||
target_lat=row.lat,
|
||||
target_lon=row.lon,
|
||||
|
|
@ -482,6 +784,10 @@ def estimate_pdf(
|
|||
house_fias_id=row.house_fias_id,
|
||||
address_precision=_qc_geo_to_precision(row.dadata_qc_geo),
|
||||
metro_nearest=(row.dadata_metro or []),
|
||||
# migration 255 — та же сноска «точность снижена», что и на JSON GET,
|
||||
# теперь и в PDF-регенерации сохранённой оценки (см. get_estimate).
|
||||
relaxations=list(getattr(row, "relaxations", None) or []),
|
||||
reliability=getattr(row, "reliability", None) or "ok",
|
||||
)
|
||||
input_snapshot = {
|
||||
"address": row.address,
|
||||
|
|
@ -750,7 +1056,15 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
|
|||
trade_in_estimates с непустым address; NULL при отсутствии адресов.
|
||||
NB: это честный best-effort по persisted оценкам, а не hit-rate реального
|
||||
кэша (отдельного счётчика попаданий не ведём).
|
||||
|
||||
#2660: listings_active сам по себе врал — «активно» на проде не означает
|
||||
«живо» (деактиватор протухших покрывает не все источники). Рядом отдаём
|
||||
listings_active_stale — сколько из них не виделись listings_stale_days
|
||||
(= LISTINGS_FRESH_DAYS эстиматора; прод 2026-08-05: 37 900 активных при
|
||||
20 935 не виденных 14+ дней). Счётчик не прячем, а разделяем.
|
||||
"""
|
||||
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
|
|
@ -760,6 +1074,10 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
|
|||
(SELECT count(*) FROM geocode_cache WHERE expires_at > NOW())
|
||||
AS geocode_cache_fresh,
|
||||
(SELECT count(*) FROM listings WHERE is_active) AS listings_active,
|
||||
(SELECT count(*) FROM listings
|
||||
WHERE is_active
|
||||
AND last_seen_at <= NOW() - (:fresh_days || ' days')::interval)
|
||||
AS listings_active_stale,
|
||||
(SELECT max(scraped_at) FROM listings) AS listings_last_scraped,
|
||||
(SELECT count(*) FROM deals) AS deals,
|
||||
(SELECT count(*) FROM gendesign_cad_buildings) AS cad_buildings,
|
||||
|
|
@ -776,12 +1094,15 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
|
|||
WHERE address IS NOT NULL AND address <> ''
|
||||
) t) AS repeat_address_pct
|
||||
"""
|
||||
)
|
||||
),
|
||||
{"fresh_days": LISTINGS_FRESH_DAYS},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
return dict(row) if row else {}
|
||||
# Порог отдаём рядом с числом — чтобы UI подписывал «не виделись N дней»,
|
||||
# а не заводил второе определение свежести у себя.
|
||||
return (dict(row) | {"listings_stale_days": LISTINGS_FRESH_DAYS}) if row else {}
|
||||
|
||||
|
||||
# ── Stage 4a: house info + IMV benchmark для UI ───────────────────────────────
|
||||
|
|
@ -1806,6 +2127,139 @@ def get_street_deals(
|
|||
|
||||
# ── Sales vs Listings (PR K — Foundation Phase 1 of issue #564) ──────────────
|
||||
|
||||
# #2666 гейт правдоподобия на «медианный торг». Пейринг ДКП↔объявление идёт по
|
||||
# УЛИЦЕ без номера дома (data_quality="street_only", ADR #721): на длинной улице
|
||||
# сделка и объявление могут стоять в разных домах и разных ценовых классах, и
|
||||
# тогда discount_pct — не торг, а разница между двумя чужими друг другу лотами.
|
||||
# Гард #2660 (миграция 211) убрал предвзятые пары «вторичка ↔ новостройка» и тем
|
||||
# самым сделал остаток артефактов ВИДНЫМ: по `%Космонавтов%` 2-комн. медиана
|
||||
# уехала с −11.9% на +36.4%, т.е. пользователю написали бы «продали на 36%
|
||||
# дороже, чем просили». Здесь не чиним пейринг (это ADR-уровень), а перестаём
|
||||
# показывать число, которому нельзя верить.
|
||||
#
|
||||
# Пороги подобраны по проду 2026-08-05 (симуляция эндпоинта на 238 РЕАЛЬНЫХ
|
||||
# пользовательских запросах из trade_in_estimates — тот же address/area/rooms,
|
||||
# что уходил в виджет; 128 из них дали хотя бы одну пару):
|
||||
#
|
||||
# MIN_PAIRS = 10 — бутстрап по 12 «плотным» группам (n ≥ 60 пар): из полной
|
||||
# выборки берём подвыборку размера k и смотрим, насколько медиана подвыборки
|
||||
# отклоняется от полной. p90 |отклонения|: k=5 → 18.8 п.п., k=10 → 12.0,
|
||||
# k=15 → 9.9, k=20 → 8.2. Кривая ломается ровно на 10 (5→10 даёт −6.8 п.п.
|
||||
# шума, 10→15 уже только −2.1, а каждые +5 к порогу стоят ещё ~8-10% улиц).
|
||||
# Совпадает с уже принятым в продукте порогом малой выборки
|
||||
# settings.sell_time_sensitivity_min_n_lots = 10.
|
||||
#
|
||||
# SANE_MIN/MAX = [−60%, +20%] — асимметричны намеренно, у сторон разная природа:
|
||||
# ВЕРХ. В наблюдаемом распределении 128 групп положительный хвост РАЗОРВАН:
|
||||
# +11.1, +10.8, +16.9 — и дальше пусто до +33.7, +34.2, +34.6, +39.0, +52.5,
|
||||
# +70.2, +81.5, +103.1. Отсечка +20% попадает в пустой промежуток, т.е. режет
|
||||
# отдельный кластер, а не край континуума. Сверху её подпирает рынок: ни один
|
||||
# городской бакет asking_to_sold_ratios не даёт плюса вообще (max ratio 0.9132
|
||||
# = −8.7% торга), так что «продали на +20% дороже ask» уже вдвое дальше любого
|
||||
# рыночно объяснимого плюса.
|
||||
# НИЗ. Разрыва нет — минус идёт сплошняком от −5% до −87%, и это ожидаемо:
|
||||
# у большого отрицательного торга есть механизм (занижение цены в ДКП), в
|
||||
# отличие от большого плюса. Поэтому граница грубая, «заведомо не рынок»:
|
||||
# худший городской бакет (студии, ratio 0.7623) = −23.8%, −60% в 2.5 раза
|
||||
# глубже. Режет 6 групп из 128 (−87 … −64).
|
||||
#
|
||||
# Цена гейта на проде: из 128 групп с парами число сохраняют 64 (50%), 59 (46%)
|
||||
# теряют его по «мало пар» и ещё 5 (4%) — по диапазону. Виджет при этом остаётся:
|
||||
# сделки, медиана ₽/м², диапазон и сами пары считаются мимо гейта, гаснет ровно
|
||||
# строка «медианный торг», и вместо неё уходит median_discount_explanation.
|
||||
#
|
||||
# MIN_DISTINCT_LISTINGS = 2 (#2672) — ПАРЫ НЕ ЯВЛЯЮТСЯ НЕЗАВИСИМЫМИ НАБЛЮДЕНИЯМИ,
|
||||
# и MIN_PAIRS этого не видит. DISTINCT ON подбирает по объявлению на сделку, но
|
||||
# ОДНО объявление переиспользуется на многих сделках улицы: у показываемых групп
|
||||
# медиана — 18 сделок на одно различное объявление. До этого порога из 64
|
||||
# показываемых чисел 22 (34%) стояли на ОДНОМ объявлении (худший живой кейс —
|
||||
# `Белинского` 1-комн.: 50 пар, 1 объявление, −50.6%), 50 (78%) — меньше чем на
|
||||
# трёх. «50 пар» там означало не 50 наблюдений рынка, а 50 сделок, поделённых на
|
||||
# ОДНУ цену предложения: число говорило о том, чем эта конкретная квартира
|
||||
# отличалась от типичной сделки, а не о торге на улице.
|
||||
#
|
||||
# Почему именно 2, и почему порог здесь обоснован ИНАЧЕ, чем MIN_PAIRS. Разброс
|
||||
# со стороны объявлений мерили джекнайфом (выкинуть одно объявление, 45 групп,
|
||||
# 118 повторов): p50 3.6, p90 18.8, max 80.4 п.п. — тот же порядок, что и шум
|
||||
# при 5 парах, который при выборе MIN_PAIRS сочли неприемлемым. Но на группах с
|
||||
# ОДНИМ объявлением ни джекнайф, ни кластерный бутстрап не дают числа вообще:
|
||||
# выкидывать нечего, пересэмплировать нечего, отклонение тождественно 0.
|
||||
# Их «нулевая ошибка» — не малая ошибка, а отсутствие измерения, и агрегат по
|
||||
# всем 64 группам от их добавления УЛУЧШАЛСЯ (кластер-бутстрап p90 16.0 → 11.5),
|
||||
# т.е. метрика становилась тем зеленее, чем больше в ней неизмеримого. Поэтому
|
||||
# 2 — не статистический выбор, а граница выразимости: ниже неё нет выборки, о
|
||||
# разбросе которой можно спрашивать, и показывать число = фабриковать точность.
|
||||
# Выше 2 порог уже статистический, и данные (прод 2026-08-06, те же 128 групп)
|
||||
# говорят, что он должен быть выше — но ценой почти всей витрины:
|
||||
# объявлений ≥ 2 → 42 группы (33%), джекнайф p90 17.4;
|
||||
# объявлений ≥ 3 → 14 групп (11%), p90 10.9 (планка MIN_PAIRS — 12.0);
|
||||
# объявлений ≥ 4 → 7 групп ( 5%), p90 5.5.
|
||||
# Порог 3 попадал бы в принятую планку шума, но оставляет 11% витрины и всё
|
||||
# равно не делает число защищаемым (ошибка со стороны СДЕЛОК никуда не делась и
|
||||
# складывается с ней). Выбирать между «9% покрытия» и «выключить строку» —
|
||||
# решение владельца, не гейта; здесь снимается ровно то, что не является
|
||||
# наблюдением рынка в принципе. Понижать MIN_PAIRS в компенсацию нельзя:
|
||||
# вернувшиеся группы стоят на тех же одном-двух объявлениях (ложная точность).
|
||||
#
|
||||
# SANE_MIN ужесточён −60% → −35% (#2672). Исходное подозрение «−60% режет живой
|
||||
# рынок» проверено и ОПРОВЕРГНУТО: до −60% проходило всё, законный механизм
|
||||
# большого минуса (занижение цены в ДКП) сохранён целиком. Ошибка была в другую
|
||||
# сторону — граница пропускала неправдоподобный отрицательный хвост: 26 из 64
|
||||
# показываемых чисел (41%) лежали ниже −23.7%, худшего объяснимого рынком
|
||||
# бакета (asking_to_sold_ratios: студии, ratio 0.7634, 1 519 сделок; ни один
|
||||
# бакет не глубже), самое глубокое показываемое — −58.5%. Мы гасили «+34%» и
|
||||
# показывали «−58.5%», полученный из ТОГО ЖЕ артефакта пейринга. Асимметрия
|
||||
# работала против пользователя: абсурдный плюс сам себя опровергает («продали
|
||||
# дороже, чем просили» — виджету просто не поверят), абсурдный минус выглядит
|
||||
# правдоподобно и подталкивает продавца к выводу, что его улица торгуется за
|
||||
# полцены. −35% ≈ в 1.5 раза глубже худшего рыночного бакета (запас на занижение
|
||||
# в ДКП сохранён) и попадает в разрыв наблюдаемого распределения −37.6 → −33.9.
|
||||
# Живой кейс из ревью: Серов, Ленина 163, 2-комн., 21 пара → −46.5% показывался.
|
||||
#
|
||||
# ПОШТУЧНЫЙ discount_pct В СТРОКАХ ТАБЛИЦЫ (#2672 п.3). Гейт гасил сводное число,
|
||||
# а таблица под ним продолжала показывать проценты, посчитанные из ТЕХ ЖЕ пар:
|
||||
# на живом Космонавтове (2-комн., медиана −37.6% погашена) шесть из первых
|
||||
# двенадцати строк — от +42% до +77%, и все против одной и той же цены
|
||||
# предложения. Масштаб на проде 2026-08-07 (427 реальных запросов из
|
||||
# trade_in_estimates, 135 групп с парами): медиана погашена у 102 групп, и в
|
||||
# них видно 3 678 строк с процентом — 72.8% всех показываемых процентов.
|
||||
#
|
||||
# Гасим строку там, и только там, где причина — свойство САМОЙ ПАРЫ:
|
||||
# а) объявлений < MIN_DISTINCT_LISTINGS — тогда столбец «разница» это
|
||||
# столбец цены сделки, поделённый на одну и ту же константу: он не даёт
|
||||
# ни одного наблюдения сверх уже показанных цен, но выглядит как N торгов;
|
||||
# б) медиана вне санитарного диапазона — по определению медианы это
|
||||
# утверждение О СТРОКАХ: половина из них ещё дальше от рынка, чем она.
|
||||
# «Мало пар» строку НЕ гасит: это свойство ВЫБОРКИ, про отдельную пару оно
|
||||
# ничего не говорит, а микрокопия «пар всего 4, поэтому процент в строке не
|
||||
# показываем» была бы ложной причиной. Цена этого исключения — 3 группы / 20
|
||||
# строк на проде, где медианы нет, а проценты в строках есть.
|
||||
# Флаги (а)/(б) считаются НЕЗАВИСИМО от порядка веток гейта: порядок «мало пар
|
||||
# → одно объявление → диапазон» прячет вторую причину за первой, и на проде 55
|
||||
# групп гаснут как «мало пар», хотя стоят ещё и на ОДНОМ объявлении. По ветке
|
||||
# гейта строки гасились бы не там, где надо.
|
||||
# Цена на проде: из 5 054 строк с процентом гаснет 3 658 (72.4%), остаётся
|
||||
# 1 396. Само число «медианный торг» этой правкой НЕ меняется — 33 группы из
|
||||
# 135 и до, и после (замер обеих версий модуля в одном процессе на ОДНИХ И ТЕХ
|
||||
# ЖЕ живых парах). Обе цены — сделки и объявления — в строке остаются:
|
||||
# убирается не данные, а наша подпись «торг» под их разностью.
|
||||
#
|
||||
# ПОТОЛОК ГЕЙТА (знать до следующей правки — здесь НЕ чинится):
|
||||
# 1. Пейринг по УЛИЦЕ, а не по дому — корень всего перечисленного (ADR #721).
|
||||
# Гейт по различным объявлениям честный промежуточный шаг, а не решение:
|
||||
# он убирает числа, которые не являются наблюдением, но оставшиеся всё ещё
|
||||
# сравнивают сделку в одном доме с объявлением в другом.
|
||||
# 2. В группах, ПРОШЕДШИХ гейт, поштучные проценты остаются как есть — включая
|
||||
# 426 строк из 1 396 (31%), лежащих вне того же диапазона [−35%, +20%], по
|
||||
# которому мы гасим медиану. Отдельного порога для ОДНОЙ пары у нас нет:
|
||||
# диапазон калиброван на медианах групп, а у одной сделки законный разброс
|
||||
# шире (занижение цены в ДКП — механизм поштучный, не медианный). Считать
|
||||
# его = вводить некалиброванный порог, чего #2672 прямо предостерегает.
|
||||
SALES_VS_LISTINGS_MIN_PAIRS = 10
|
||||
SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS = 2
|
||||
SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT = -35.0
|
||||
SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT = 20.0
|
||||
|
||||
|
||||
@router.get("/sales-vs-listings", response_model=SalesVsListingsResponse)
|
||||
def get_sales_vs_listings(
|
||||
|
|
@ -1945,12 +2399,122 @@ def get_sales_vs_listings(
|
|||
|
||||
discounts = sorted(p.discount_pct for p in pairs if p.discount_pct is not None)
|
||||
median_discount = round(_percentile(discounts, 0.5), 2) if discounts else None
|
||||
# #2672: сколько РАЗЛИЧНЫХ объявлений стоит за этими парами. len(discounts)
|
||||
# считает сделки, а не наблюдения рынка — одно объявление попадает в пару
|
||||
# к десяткам сделок улицы (см. шапку секции).
|
||||
n_distinct_listings = len(
|
||||
{p.listing_id for p in pairs if p.discount_pct is not None and p.listing_id is not None}
|
||||
)
|
||||
|
||||
# #2672 п.3: те же две проверки, но применённые к КАЖДОЙ СТРОКЕ таблицы, а не
|
||||
# к сводному числу (обоснование — в шапке секции, блок «ПОШТУЧНЫЙ ПРОЦЕНТ»).
|
||||
# Считаются ДО гейта, потому что гейт обнуляет median_discount, и порядок его
|
||||
# веток (мало пар → одно объявление → диапазон) прячет вторую причину за
|
||||
# первой: на проде 55 групп гасятся как «мало пар», хотя стоят ещё и на ОДНОМ
|
||||
# объявлении. Для строк важна причина, а не то, какая ветка сработала раньше.
|
||||
pairs_stand_on_one_listing = n_distinct_listings < SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS
|
||||
median_is_implausible = median_discount is not None and not (
|
||||
SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT
|
||||
<= median_discount
|
||||
<= SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT
|
||||
)
|
||||
|
||||
# #2666 гейт правдоподобия (обоснование порогов — в шапке секции). Число либо
|
||||
# отдаётся, либо гасится с объяснением ПОЧЕМУ — молча пустое поле пользователь
|
||||
# прочитает как поломку, а не как честность.
|
||||
median_discount_explanation: str | None = None
|
||||
if median_discount is not None:
|
||||
if len(discounts) < SALES_VS_LISTINGS_MIN_PAIRS:
|
||||
# Формулировка — ФАКТ про выборку, а не обещание надёжности выше
|
||||
# порога: 10 пар тоже не гарантия (см. «ПОТОЛОК ГЕЙТА» выше —
|
||||
# пары псевдореплики), обещать «от 10 надёжно» мы не вправе.
|
||||
median_discount_explanation = (
|
||||
f"Медианный торг не показываем: пар «сделка ↔ объявление» всего "
|
||||
f"{len(discounts)} — на такой выборке медиана гуляет на десятки "
|
||||
f"процентных пунктов."
|
||||
)
|
||||
elif pairs_stand_on_one_listing:
|
||||
# Числа стоят В КОНЦЕ клауз намеренно: «различных объявлений всего 1»
|
||||
# грамматично при любом значении, «на 1 различных объявлений» — нет.
|
||||
median_discount_explanation = (
|
||||
f"Медианный торг не показываем: сделок {len(discounts)}, а разных "
|
||||
f"объявлений для сравнения всего {n_distinct_listings} — такой процент "
|
||||
f"говорит о цене одной конкретной квартиры, а не о торге на улице."
|
||||
)
|
||||
elif median_is_implausible:
|
||||
# Типографский минус (U+2212) — как в fmtDiscount на фронте.
|
||||
shown = f"{median_discount:+.1f}".replace("-", "−")
|
||||
# Про «пары строятся по улице, а не по дому» здесь НЕ пишем: ровно
|
||||
# следующим блоком это говорит street_only-дисклеймер (карточка) /
|
||||
# хвост note (v2-mappers). Проверено скриншотом — две формулировки
|
||||
# подряд читались как стена текста.
|
||||
median_discount_explanation = (
|
||||
f"Медианный торг не показываем: расчёт дал неправдоподобное значение "
|
||||
f"({shown}%) — такого торга на рынке не бывает."
|
||||
)
|
||||
if median_discount_explanation is not None:
|
||||
logger.info(
|
||||
"sales-vs-listings: median_discount gated street=%r rooms=%d "
|
||||
"n_pairs=%d distinct_listings=%d value=%+.2f%%",
|
||||
street_name,
|
||||
rooms,
|
||||
len(discounts),
|
||||
n_distinct_listings,
|
||||
median_discount,
|
||||
)
|
||||
median_discount = None
|
||||
|
||||
# #2672 п.3: под погашенной медианой строки таблицы продолжали показывать
|
||||
# проценты из ТЕХ ЖЕ пар (живой кейс — Космонавтов: +76%, +73%, +63% против
|
||||
# одной и той же цены предложения). Гасим их там, и только там, где причина —
|
||||
# свойство самой пары; «мало пар» свойство ВЫБОРКИ, про отдельную строку оно
|
||||
# ничего не говорит, поэтому одну строку не трогает (обоснование и цена —
|
||||
# в шапке секции). Обе цены остаются в строке: мы убираем не данные, а нашу
|
||||
# подпись «торг» под разностью, которой не можем ручаться.
|
||||
if discounts and (pairs_stand_on_one_listing or median_is_implausible):
|
||||
if pairs_stand_on_one_listing:
|
||||
# Оба числа названы совместно с фразой медианы: там «сделок N», здесь
|
||||
# «одна и та же цена» — читателю видно и сколько строк, и на скольких
|
||||
# объявлениях они стоят.
|
||||
row_explanation = (
|
||||
"Проценты по каждой сделке тоже не показываем: все они считаются "
|
||||
"против одной и той же цены объявления."
|
||||
)
|
||||
else:
|
||||
# Медиана вне диапазона — это утверждение О СТРОКАХ: по определению
|
||||
# медианы половина из них лежит по дальнюю сторону от неё, т.е. тоже
|
||||
# вне рыночного диапазона. Значение здесь НЕ повторяем: в ветке
|
||||
# диапазона оно уже названо предыдущим предложением (вышло бы дважды
|
||||
# в одном абзаце), а в ветке «мало пар» мы его намеренно не
|
||||
# показываем — и печатать его в пояснении было бы отказом на словах.
|
||||
row_explanation = (
|
||||
"Проценты по каждой сделке тоже не показываем: половина из них — "
|
||||
"за пределами того, как торгуется рынок."
|
||||
)
|
||||
for pair in pairs:
|
||||
pair.discount_pct = None
|
||||
median_discount_explanation = (
|
||||
f"{median_discount_explanation} {row_explanation}"
|
||||
if median_discount_explanation
|
||||
else row_explanation
|
||||
)
|
||||
logger.info(
|
||||
"sales-vs-listings: per-row discount_pct gated street=%r rooms=%d rows=%d "
|
||||
"distinct_listings=%d reason=%s",
|
||||
street_name,
|
||||
rooms,
|
||||
len(discounts),
|
||||
n_distinct_listings,
|
||||
"one_listing" if pairs_stand_on_one_listing else "implausible_median",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"sales-vs-listings: street=%r deals=%d with_listings=%d linkage=%.1f%% median_disc=%s",
|
||||
"sales-vs-listings: street=%r deals=%d with_listings=%d distinct_listings=%d "
|
||||
"linkage=%.1f%% median_disc=%s",
|
||||
street_name,
|
||||
total_deals,
|
||||
deals_with_listings,
|
||||
n_distinct_listings,
|
||||
linkage_rate_pct,
|
||||
f"{median_discount:+.2f}%" if median_discount is not None else "n/a",
|
||||
)
|
||||
|
|
@ -1964,6 +2528,7 @@ def get_sales_vs_listings(
|
|||
deals_with_listings=deals_with_listings,
|
||||
linkage_rate_pct=linkage_rate_pct,
|
||||
median_discount_pct=median_discount,
|
||||
median_discount_explanation=median_discount_explanation,
|
||||
# street_sales_vs_listings матчит по УЛИЦЕ (не по дому, #721 ADR) →
|
||||
# даже при deals_with_listings>0 это street-level, не house. house_linked НЕ emit'им.
|
||||
data_quality="street_only" if total_deals > 0 else "no_data",
|
||||
|
|
|
|||
20
tradein-mvp/backend/app/api/v1/version.py
Normal file
20
tradein-mvp/backend/app/api/v1/version.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""GET /api/v1/trade-in/version — build metadata (product version + short SHA +
|
||||
build date), source `app/core/version.py`.
|
||||
|
||||
Публичный (без авторизации, см. `app/core/rbac.py::_PUBLIC_PATHS`) — это не
|
||||
секрет, а быстрая справка для клиента/поддержки/смоук-теста, читающая только
|
||||
process env / уже загруженные при импорте константы (без похода в БД)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.core.version import APP_VERSION, BUILD_DATE, BUILD_SHA
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/version")
|
||||
def get_version() -> dict[str, str]:
|
||||
"""{"version": "1.0.0", "sha": "a1b2c3d", "built_at": "2026-08-10T12:00:00Z"}."""
|
||||
return {"version": APP_VERSION, "sha": BUILD_SHA, "built_at": BUILD_DATE}
|
||||
|
|
@ -109,6 +109,61 @@ class Settings(BaseSettings):
|
|||
login_rate_limit_window_s: int = Field(
|
||||
default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S"
|
||||
)
|
||||
# Глобальный (независимый от IP) счётчик неудачных входов НА ИМЯ (#2571).
|
||||
# Лимит выше по паре (username, IP) распределённый перебор обходит: с каждого
|
||||
# нового адреса ему дают свежие login_rate_limit попыток. Здесь ключ — ТОЛЬКО
|
||||
# имя, поэтому попытки со всех адресов складываются.
|
||||
#
|
||||
# Превышение порога НЕ блокирует учётку (это был бы вектор DoS против
|
||||
# конкретного человека — злоумышленник выключал бы чужой вход по своему
|
||||
# желанию), а растит задержку ответа: 1с, 2с, 4с… до потолка. Порог 20/час
|
||||
# выбран так, чтобы живой человек с опечатками до него не доходил.
|
||||
login_username_fail_threshold: int = Field(
|
||||
default=20, validation_alias="LOGIN_USERNAME_FAIL_THRESHOLD"
|
||||
)
|
||||
login_username_fail_window_s: int = Field(
|
||||
default=3600, validation_alias="LOGIN_USERNAME_FAIL_WINDOW_S"
|
||||
)
|
||||
# Потолок задержки одного ответа. Держим невысоким сознательно: задержка —
|
||||
# это ещё и цена, которую платит легитимный владелец имени, пока его
|
||||
# перебирают. 8с ощутимо режут перебор, но не выглядят как «сайт лёг».
|
||||
login_username_throttle_max_delay_s: float = Field(
|
||||
default=8.0, validation_alias="LOGIN_USERNAME_THROTTLE_MAX_DELAY_S"
|
||||
)
|
||||
# ── #2665: проверка пароля вне событийного цикла + СОЗНАТЕЛЬНЫЙ потолок ────
|
||||
# Замер в прод-контейнере 2026-08-06: bcrypt cost 12 (все живые хеши —
|
||||
# `$2b$12$`) = 282 мс медиана. Пока `verify_password` звался прямо в
|
||||
# `async def login`, эти 282 мс были простоем ВСЕГО API, и они же были
|
||||
# единственным настоящим потолком темпа логинов — замерено 3.6 попытки/с при
|
||||
# стойле событийного цикла до 836 мс. Обе половины чинятся вместе, см.
|
||||
# `app.core.password.verify_password_bounded`.
|
||||
#
|
||||
# `workers` — это и есть потолок темпа: не больше workers/282мс проверок в
|
||||
# секунду, сколько бы соединений ни пришло. Дефолт 1 выбран так, чтобы
|
||||
# ПОСЛЕ выноса в пул потолок остался тем же (~3.5/с), что случайно давала
|
||||
# блокировка цикла: вынос не должен ускорять перебор. Поднимать имеет смысл
|
||||
# только вместе с осознанным ответом «во сколько раз мы согласны ускорить
|
||||
# перебор ради параллельных входов».
|
||||
# ge=1: 0 или -1 роняют ThreadPoolExecutor прямо НА ИМПОРТЕ («max_workers must
|
||||
# be greater than 0») — контейнер уходит в crash-loop, и причина видна только
|
||||
# в трейсбеке старта. Пусть отказ будет на валидации настроек, с именем поля.
|
||||
login_password_verify_workers: int = Field(
|
||||
default=1, ge=1, validation_alias="LOGIN_PASSWORD_VERIFY_WORKERS"
|
||||
)
|
||||
# Сколько запросов одновременно допускаются к проверке (считая тех, кто ждёт
|
||||
# очереди в пуле). Сверх — сразу 429, без ожидания. Не режет темп (его режут
|
||||
# workers), а держит конечной ОЧЕРЕДЬ: каждый ждущий запрос удерживает
|
||||
# соединение к БД (сессия реестра открыта после SELECT в
|
||||
# `get_user_by_username`), а в QueuePool их всего 5+10. Неограниченная
|
||||
# очередь выбрала бы пул и положила API ровно так же, как блокировка цикла,
|
||||
# только другим способом. 4 из 15 соединений и худшее ожидание
|
||||
# 4/1×282мс ≈ 1.1с — цена, которую живой вход переживает.
|
||||
# ge=1: 0 читается как «выключить лимит», а означал бы обратное — КАЖДЫЙ вход
|
||||
# получает 429 навсегда и молча (слотов нет ни одного). Выключать тут нечего:
|
||||
# потолок — это workers, а очередь без границы выбирает пул соединений к БД.
|
||||
login_password_verify_max_inflight: int = Field(
|
||||
default=4, ge=1, validation_alias="LOGIN_PASSWORD_VERIFY_MAX_INFLIGHT"
|
||||
)
|
||||
|
||||
# ── Эпик «единый вход»: общий реестр людей в БД `auth` ─────────────────────
|
||||
# DSN БД `auth` (роль auth_app) — единый реестр людей «Меры» (trade-in) и
|
||||
|
|
@ -773,6 +828,64 @@ class Settings(BaseSettings):
|
|||
# допуском на перерыв в работе scraper'а. ENV: AVITO_STALE_TTL_DAYS.
|
||||
avito_stale_ttl_days: int = 10
|
||||
|
||||
# ── ЭТАП 4 B2C launch — retention / erasure (152-ФЗ) ────────────────────
|
||||
# trade_in_estimates.expires_at TTL (часы от момента создания). Раньше был
|
||||
# хардкод `timedelta(hours=24)` в estimator.py (x2: главный INSERT +
|
||||
# _empty_estimate fallback) — вынесено в настройку, чтобы retention-период
|
||||
# не требовал правки кода. 24ч — продуктовое решение MVP (оценка живёт
|
||||
# "сессию" клиента, не архив); юридически обоснованный срок хранения адреса
|
||||
# физлица для анонимного B2C — решение не инженера, см. итоговый комментарий
|
||||
# к задаче. ENV: TRADE_IN_ESTIMATE_RETENTION_HOURS.
|
||||
trade_in_estimate_retention_hours: int = 24
|
||||
|
||||
# trade_in_leads.expires_at TTL (дни от момента создания, migration 231).
|
||||
# У trade_in_leads раньше вообще не было срока хранения — лид (телефон +
|
||||
# согласие) жил в БД бессрочно. 180 дней (6 месяцев) — рабочий default для
|
||||
# НЕконвертированных маркетинговых лидов (типичный индустриальный диапазон
|
||||
# 90-180 дней при отсутствии дальнейшего договорного отношения с клиентом);
|
||||
# если лид конвертировался в реальную сделку/договор — для него должен
|
||||
# действовать ДРУГОЙ (договорной) срок хранения, но в кодовой базе нет
|
||||
# механизма отметки "лид конвертирован" — этого разграничения здесь НЕТ,
|
||||
# см. итоговый комментарий к задаче (конкретный юридически обоснованный
|
||||
# срок — решение DPO/юриста, не инженера). ENV: TRADE_IN_LEAD_RETENTION_DAYS.
|
||||
trade_in_lead_retention_days: int = 180
|
||||
|
||||
# ── Платный отчёт живёт год (retain_until, migration 240, PR #2754) ─────
|
||||
# trade_in_estimates.retain_until TTL (дни ОТ ОПЛАТЫ) — срок жизни ССЫЛКИ/
|
||||
# СТРОКИ для оплаченной оценки, независимый от expires_at (актуальность
|
||||
# расчёта, 24ч, глобальный для ВСЕХ строк). НЕ трогает expires_at — см.
|
||||
# migration 240 докстринг. Отдельная колонка, а не подъём expires_at:
|
||||
# expires_at печатается в PDF/UI как «актуальность расчёта» и одинаков
|
||||
# для всех строк, поднять его до года = соврать в документе клиента про
|
||||
# свежесть цифры + нарушить минимизацию ПДн для неоплаченных B2C-адресов.
|
||||
# Единственный источник числа «12 месяцев» на фронте —
|
||||
# `mera-public/content.ts::PAID_REPORT_RETENTION_MONTHS`; текст оферты,
|
||||
# экран после оплаты и SQL продления retain_until при оплате (платёжный
|
||||
# код, отдельный PR) обязаны читать его оттуда, а не хардкодить — иначе
|
||||
# классический исход "в оферте 12 месяцев, в конфиге 365 дней, на экране
|
||||
# «год»". ENV: TRADE_IN_PAID_RETENTION_DAYS.
|
||||
trade_in_paid_retention_days: int = 365
|
||||
|
||||
# ── Revival на GET /estimate/{id} (incident 2026-08-10) ─────────────────
|
||||
# Throttle повторных попыток пересчёта «мёртвой» (median_price<=0/NULL)
|
||||
# сохранённой строки — записи, посчитанные ДО фикса оценщика (#oblast-E/F,
|
||||
# PR #2823/#2825) и навсегда застрявшие с median_price=0. GET пытается
|
||||
# пересчитать такую строку через тот же estimate_quality(), что и POST
|
||||
# (app/api/v1/trade_in.py::_try_revive_dead_estimate), не чаще одного раза
|
||||
# в это число минут на строку — иначе каждый refresh страницы бил бы по
|
||||
# геокодеру/DaData для объективно мёртвого адреса. 10 минут — компромисс:
|
||||
# достаточно редко, чтобы не спамить внешние сервисы, достаточно быстро,
|
||||
# чтобы повторный визит клиента после нашего фикса увидел живую цену. ENV:
|
||||
# TRADE_IN_REVIVAL_THROTTLE_MINUTES.
|
||||
trade_in_revival_throttle_minutes: int = 10
|
||||
|
||||
# Батч-размер физического DELETE в purge_expired_trade_in_data (нельзя одним
|
||||
# DELETE по всей таблице — долгая блокировка на большом бэклоге). Задача сама
|
||||
# крутит цикл батчей за один прогон (см. _DEFAULT_MAX_BATCHES в таске) —
|
||||
# это ограничивает ОДНУ транзакцию, не общий прогресс. ENV:
|
||||
# TRADE_IN_PURGE_BATCH_SIZE.
|
||||
trade_in_purge_batch_size: int = 500
|
||||
|
||||
# ── Avito SERP ЕКБ гео-фильтр (per-card city-slug) ─────────────────────
|
||||
# Avito при редких/дорогих комбо (4+ комн.) добивает выдачу «по всей России»
|
||||
# (Москва/Челябинск/Омск и т.д.). Каждая карточка несёт СВОЙ href с city-slug
|
||||
|
|
@ -877,5 +990,36 @@ class Settings(BaseSettings):
|
|||
# message_thread_id топика внутри support-группы, в который идут зеркала.
|
||||
telegram_support_topic_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_TOPIC_ID")
|
||||
|
||||
# ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ──────────
|
||||
# См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит
|
||||
# роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch.
|
||||
# PAYMENTS_ENABLED=false (дефолт) держит контур выключенным полностью:
|
||||
# ни один из последующих PR (C/D/E) не должен активироваться без явного
|
||||
# включения в .env.runtime прод-стека.
|
||||
tbank_terminal_key: str = Field(default="", validation_alias="TBANK_TERMINAL_KEY")
|
||||
# Пароль терминала — участвует в подписи Token (Init) и проверке подписи
|
||||
# входящих нотификаций. SecretStr по прецеденту auth_db_password (строка
|
||||
# 197 выше): не должен всплыть в логах/repr/Sentry breadcrumbs.
|
||||
tbank_password: SecretStr = Field(default=SecretStr(""), validation_alias="TBANK_PASSWORD")
|
||||
tbank_api_base_url: str = Field(
|
||||
default="https://securepay.tinkoff.ru", validation_alias="TBANK_API_BASE_URL"
|
||||
)
|
||||
tbank_notification_url: str = Field(default="", validation_alias="TBANK_NOTIFICATION_URL")
|
||||
tbank_success_url: str = Field(default="", validation_alias="TBANK_SUCCESS_URL")
|
||||
tbank_fail_url: str = Field(default="", validation_alias="TBANK_FAIL_URL")
|
||||
# "O" — одностадийная (оплата сразу), "T" — двухстадийная (холд + Confirm).
|
||||
# Дефолт "T": выбрана схема с холдом (гибрид «Проба → холд → отчёт по
|
||||
# ссылке», ядро — вариант B) — источник решения `mera-b2c-paid-flow-
|
||||
# decision.md` §1 в корне репо, НЕ recon-док (тот сам по себе выбирает
|
||||
# "O" — устарел этим решением). Не переставляй дефолт обратно на "O", не
|
||||
# сверившись с decision-доком.
|
||||
tbank_pay_type: Literal["O", "T"] = Field(default="T", validation_alias="TBANK_PAY_TYPE")
|
||||
tbank_receipt_enabled: bool = Field(default=False, validation_alias="TBANK_RECEIPT_ENABLED")
|
||||
tbank_taxation: str = Field(default="", validation_alias="TBANK_TAXATION")
|
||||
tbank_ffd_version: str = Field(default="", validation_alias="TBANK_FFD_VERSION")
|
||||
# Kill-switch всего контура. false — checkout/notify (появятся в PR-D)
|
||||
# обязаны отказывать сразу, ничего не вызывая у T-Bank.
|
||||
payments_enabled: bool = Field(default=False, validation_alias="PAYMENTS_ENABLED")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
|
|
@ -5,14 +5,28 @@ bcrypt тихо обрезает пароли длиннее 72 байт (UTF-8)
|
|||
`hash_password` явно ловит это и падает с ValueError вместо тихого поведения.
|
||||
`verify_password` на длинном пароле возвращает False (не raise) — сравнение
|
||||
паролей не должно ронять запрос авторизации.
|
||||
|
||||
#2665: из `async def` зови ТОЛЬКО `verify_password_bounded` — см. её docstring.
|
||||
Синхронный `verify_password` остаётся для sync-кода (сидов, тестов, CLI) и как
|
||||
тело, которое исполняется в пуле.
|
||||
|
||||
Правило про пул относится к СВЕРКЕ, не к хешированию. `hash_password` — тот же
|
||||
cost 12 и те же ~282 мс на цикле — сознательно остаётся синхронным в
|
||||
`app/api/v1/team.py` (заведение сотрудника, смена пароля): это редкая операция
|
||||
АУТЕНТИФИЦИРОВАННОГО менеджера, её нельзя вызвать анонимно и потому нельзя
|
||||
превратить в поток. Станет их много — переносить тем же приёмом.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import bcrypt
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BCRYPT_MAX_BYTES = 72
|
||||
|
|
@ -59,3 +73,209 @@ def verify_password(plain: str, hashed: str) -> bool:
|
|||
# Malformed hash (напр. не-bcrypt строка в БД) — не должно ронять login.
|
||||
logger.warning("verify_password: malformed hash rejected: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
class PasswordVerifyOverloadedError(RuntimeError):
|
||||
"""Свободных слотов на проверку пароля нет. Вызывающий обязан ответить 429."""
|
||||
|
||||
|
||||
# Пул, в котором крутится bcrypt. `max_workers` — не тюнинг пропускной
|
||||
# способности, а САМ ПОТОЛОК ТЕМПА: проверок в секунду не больше, чем
|
||||
# workers / 282мс, независимо от числа соединений. Читается один раз на импорте
|
||||
# — размер пула по определению статичен (см. `login_password_verify_workers`).
|
||||
_VERIFY_POOL = ThreadPoolExecutor(
|
||||
max_workers=settings.login_password_verify_workers,
|
||||
thread_name_prefix="pw-verify",
|
||||
)
|
||||
|
||||
# Сколько проверок сейчас в работе ИЛИ ждут очереди в пуле. Обычный int без
|
||||
# лока — намеренно: и инкремент, и декремент выполняются в потоке событийного
|
||||
# цикла, между чтением и записью нет ни одного `await`, так что чередования
|
||||
# внутри пары нет. Счётчик, а не `asyncio.Semaphore`: мы никогда не ЖДЁМ на нём
|
||||
# (сверх лимита — сразу отказ), а int не имеет привязки к конкретному циклу и
|
||||
# потому одинаково честен под несколькими event loop'ами в тестах.
|
||||
_verify_inflight = 0
|
||||
|
||||
# То же самое, но в разрезе ключа (#2714). Запись живёт РОВНО пока ключ держит
|
||||
# хотя бы слот и удаляется на нуле: размер словаря ограничен числом слотов
|
||||
# (`login_password_verify_max_inflight`), а не числом когда-либо виденных
|
||||
# адресов — иначе перебор с ротацией IP растил бы его без границы.
|
||||
_verify_inflight_by_key: dict[str, int] = {}
|
||||
|
||||
|
||||
def _per_key_slot_cap() -> int:
|
||||
"""Сколько слотов из общего лимита разрешено ОДНОМУ ключу.
|
||||
|
||||
Половина — минимальное деление, при котором один источник, сколько бы он ни
|
||||
слал, физически не может занять всё: вторая половина остаётся тем, кто
|
||||
приходит впервые. Настройкой не сделано сознательно — это доля, а не
|
||||
величина, и подкручивать её нечем: 100% возвращает поведение, ради отказа
|
||||
от которого правка написана.
|
||||
|
||||
Читается на каждом вызове, а не на импорте, — как `_throttle_delay_s`:
|
||||
иначе тестовый monkeypatch лимита не влиял бы на долю.
|
||||
|
||||
`max(1, …)`: при `max_inflight=1` половина округлилась бы в 0, и КАЖДЫЙ вход
|
||||
получал бы отказ молча (свободных слотов нет ни у кого). Молчаливый отказ
|
||||
всем — ровно тот класс поломки, от которого страхует `ge=1` на самой
|
||||
настройке; здесь тот же страховочный пол, но от деления.
|
||||
"""
|
||||
return max(1, settings.login_password_verify_max_inflight // 2)
|
||||
|
||||
|
||||
def verify_slots_saturated(key: str) -> bool:
|
||||
"""Тот же предикат, по которому отказывает `verify_password_bounded`, но БЕЗ взятия слота.
|
||||
|
||||
Нужен вызывающему ровно затем, чтобы отказать ДО похода в БД (#2715). Гейт
|
||||
стоял ПОСЛЕ выборки пользователя, и каждый заведомо отклоняемый запрос всё
|
||||
равно брал соединение из пула и делал SELECT по имени — тогда, когда система
|
||||
уже перегружена. Хуже того, под насыщением эта выборка оставалась
|
||||
ЕДИНСТВЕННОЙ работой на пути отказа: bcrypt, который ровняет время ответа
|
||||
для существующего и несуществующего имени, ниже по течению и до него не
|
||||
доходит, так что разницу «строка найдена / не найдена» ничто не маскировало.
|
||||
|
||||
Предчек, а не решение: авторитетная проверка остаётся внутри
|
||||
`verify_password_bounded` — она зовёт ЭТУ ЖЕ функцию, так что разъехаться
|
||||
двум условиям нечем, и инвариант «одна точка выноса = одна точка учёта»
|
||||
цел (слот здесь не резервируется и не отдаётся).
|
||||
|
||||
Учитывает и общий потолок, и долю на ключ (#2714) — иначе предчек не
|
||||
покрывал бы главный случай: при флуде с ОДНОГО адреса первым упирается
|
||||
именно доля, и большинство отказов снова ходило бы в базу.
|
||||
"""
|
||||
return (
|
||||
_verify_inflight >= settings.login_password_verify_max_inflight
|
||||
or _verify_inflight_by_key.get(key, 0) >= _per_key_slot_cap()
|
||||
)
|
||||
|
||||
|
||||
async def verify_password_bounded(plain: str, hashed: str, *, key: str) -> bool:
|
||||
"""`verify_password`, унесённая с событийного цикла И с сознательным потолком темпа (#2665).
|
||||
|
||||
ДВЕ ПОЛОВИНЫ ОДНОЙ ПРАВКИ, И ЖИВУТ ОНИ ЗДЕСЬ ВМЕСТЕ НЕ ИЗ ЛЮБВИ К ПОРЯДКУ.
|
||||
Порознь каждая делает хуже, чем было:
|
||||
- вынести bcrypt в пул, не поставив потолок → перебор УСКОРЯЕТСЯ (замер
|
||||
ниже: 3.6/с → 16/с на дефолтном executor'е);
|
||||
- поставить потолок, не вынося bcrypt → 282 мс простоя всего API на каждую
|
||||
попытку остаются.
|
||||
Поэтому единственная точка выноса в поток и единственная точка учёта слотов —
|
||||
одна и та же функция: состояние «вынесено, но потолка нет» невыразимо.
|
||||
|
||||
Замер в прод-контейнере (2026-08-06, cost 12, все живые хеши `$2b$12$`):
|
||||
verify_password = 282 мс медиана;
|
||||
вызов прямо в `async def` — 3.6 проверки/с, стойло событийного цикла 836 мс
|
||||
(это и был «потолок» — случайный, ценой отказа в обслуживании всего API);
|
||||
`asyncio.to_thread` без потолка — 16 проверок/с, стойло 6 мс.
|
||||
Отсюда дефолт `workers=1`: потолок остаётся тем же ~3.5/с, что был, а API
|
||||
перестаёт стоять. Числа перепроверяемы: tests/test_password.py.
|
||||
|
||||
Потолок держится ПРОЦЕССОМ, а не общим хранилищем. Это проверено, а не
|
||||
предположено: прод-бэкенд запущен `uvicorn app.main:app` без `--workers`
|
||||
(один процесс), а `REDIS_URL` в окружении tradein-backend НЕ ЗАДАН вовсе
|
||||
(`printenv | grep -c ^REDIS_URL=` → 0, находка эпика #2674 — кэш поиска всю
|
||||
жизнь стучится в localhost и получает отказ). Потолок на Redis был бы
|
||||
потолком, который молча не работает.
|
||||
Ceiling: появятся `--workers N` (или `WEB_CONCURRENCY=N` в `.env.runtime` —
|
||||
uvicorn читает число процессов и оттуда, а файл правится руками на VPS) —
|
||||
темп множится на N, как и у соседних in-memory лимитеров в
|
||||
app/api/v1/auth.py; тогда потолок надо переносить в общее хранилище,
|
||||
предварительно убедившись, что оно реально доступно.
|
||||
|
||||
ДОЛЯ НА КЛЮЧ (#2714). Слоты — общий котёл, и потолок исправно бил по своим:
|
||||
пока флуд держал все четыре, легитимный вход с ВЕРНЫМ паролем получал 429
|
||||
столько раз, сколько пытался. Поэтому *key* (у единственного вызывающего —
|
||||
IP клиента) не берёт больше `_per_key_slot_cap()`: сколько бы один источник
|
||||
ни слал, половина ёмкости остаётся тем, кто приходит впервые. Учёт по ключу
|
||||
живёт ЗДЕСЬ ЖЕ и отдаётся тем же `_release_verify_slot` — инвариант «одна
|
||||
точка выноса = одна точка учёта» не делится надвое.
|
||||
|
||||
Чего это НЕ делает, и это не оговорка ради приличия. Ключом может быть
|
||||
только IP, а IP:
|
||||
- подделывается, если между нами и клиентом окажется ещё один прокси
|
||||
(сейчас доверенный хоп ровно один — Caddy, `ratelimit._client_ip` берёт
|
||||
правый элемент XFF; появится второй — ключ станет клиентским вводом);
|
||||
- разделяется: за NAT/корпоративным шлюзом вся организация приходит с
|
||||
одного адреса и делит одну долю с чужим перебором. СОСЕДЯМ ПО АДРЕСУ
|
||||
СТАЛО ХУЖЕ, и это честный размен, а не побочный эффект: при флуде в
|
||||
3 запроса/с с того же адреса свои входят 69% попыток против 94% до
|
||||
правки, а порог, за которым сосед перестаёт входить, падает с ~14 до
|
||||
~7 запросов/с. Взамен вход С ЧУЖИХ адресов идёт 100% против 37%;
|
||||
размен принят сознательно — офис за одним NAT это единицы адресов,
|
||||
а «все остальные» это все;
|
||||
- меняется: ботнет или ротация прокси дают злоумышленнику столько ключей,
|
||||
сколько ему нужно, и доля на ключ перестаёт быть ограничением.
|
||||
То есть это ПОДНИМАЕТ СТОИМОСТЬ атаки (одного адреса больше не хватает,
|
||||
чтобы закрыть вход всем), но не закрывает её. Закрывают принципиально
|
||||
только доказательство работы на входе или второй фактор — отдельный разговор
|
||||
и отдельная цена.
|
||||
|
||||
Raises:
|
||||
PasswordVerifyOverloadedError: очередь на проверку заполнена
|
||||
(`login_password_verify_max_inflight`) ЛИБО *key* уже держит свою
|
||||
долю (`_per_key_slot_cap`). Отказ мгновенный: ждать нельзя, ждущий
|
||||
запрос держит соединение к БД. Оба случая неразличимы снаружи
|
||||
намеренно — отказ приходит ДО сверки и потому ничего не сообщает о
|
||||
том, существует ли учётка.
|
||||
"""
|
||||
global _verify_inflight
|
||||
|
||||
# АВТОРИТЕТНАЯ проверка. Вызывающий может спросить то же самое заранее
|
||||
# (`verify_slots_saturated`, #2715), но решение принимается здесь и только
|
||||
# здесь — предчек экономит поход в БД, а не заменяет этот отказ.
|
||||
if verify_slots_saturated(key):
|
||||
raise PasswordVerifyOverloadedError
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
_verify_inflight += 1
|
||||
_verify_inflight_by_key[key] = _verify_inflight_by_key.get(key, 0) + 1
|
||||
try:
|
||||
work = _VERIFY_POOL.submit(verify_password, plain, hashed)
|
||||
except BaseException:
|
||||
# Работа в пул НЕ встала — колбэка не будет, слот отдаём здесь. Иначе
|
||||
# утёкший слот навсегда отнимает у входа часть и без того малой ёмкости.
|
||||
_release_verify_slot(key)
|
||||
raise
|
||||
|
||||
# Слот освобождает ЗАВЕРШЕНИЕ РАБОТЫ, а не выход из этой корутины. Отмена
|
||||
# (клиент отвалился, таймаут) прекращает корутину, но УЖЕ НАЧАТУЮ сверку не
|
||||
# снимает — поток занят ею все 282 мс. Отдавай мы слот в `finally`, на это
|
||||
# время слот считался бы свободным: одновременно работающих сверок стало бы
|
||||
# больше, чем разрешено, и очередь пула поехала бы вслед за ними.
|
||||
# (Ещё не начатую работу отмена как раз снимает — `cancel()` пробрасывается
|
||||
# на future пула, — так что вреда от неё нет; проблема ровно в начатой.)
|
||||
#
|
||||
# Именно поэтому колбэк висит на future ПУЛА, а не на обёртке из
|
||||
# `run_in_executor`: у обёртки «готово» наступает и при отмене — тест
|
||||
# `test_bounded_slot_freed_by_the_work_not_by_cancellation` ловит эту разницу.
|
||||
work.add_done_callback(lambda _f: _schedule_verify_slot_release(loop, key))
|
||||
return await asyncio.wrap_future(work)
|
||||
|
||||
|
||||
def _schedule_verify_slot_release(loop: asyncio.AbstractEventLoop, key: str) -> None:
|
||||
"""Возвращает слот по факту завершения работы в пуле (см. вызывающую).
|
||||
|
||||
Колбэк future пула исполняется В ПОТОКЕ ПУЛА, а счётчики — собственность
|
||||
потока событийного цикла (на том и держится арифметика без лока), поэтому
|
||||
декремент переносим в цикл через `call_soon_threadsafe`.
|
||||
"""
|
||||
try:
|
||||
loop.call_soon_threadsafe(_release_verify_slot, key)
|
||||
except RuntimeError:
|
||||
# Цикл уже закрыт (остановка процесса) — освобождать нечего и некому.
|
||||
logger.debug("verify slot release skipped: event loop is closed")
|
||||
|
||||
|
||||
def _release_verify_slot(key: str) -> None:
|
||||
"""Единственное место, где слот отдают: и общий счётчик, и счётчик ключа.
|
||||
|
||||
Оба — одним движением и здесь же, а не по одному на каждом пути выхода:
|
||||
разъедься они, и достаточно забыть одну строчку, чтобы ключ навсегда унёс
|
||||
с собой долю ёмкости, которую никто уже не вернёт.
|
||||
"""
|
||||
global _verify_inflight
|
||||
_verify_inflight -= 1
|
||||
left = _verify_inflight_by_key.get(key, 0) - 1
|
||||
if left > 0:
|
||||
_verify_inflight_by_key[key] = left
|
||||
else:
|
||||
_verify_inflight_by_key.pop(key, None)
|
||||
|
|
|
|||
|
|
@ -114,8 +114,13 @@ class SlidingWindowLimiter:
|
|||
return self._window_s - (now - bucket[0])
|
||||
return None
|
||||
|
||||
def record(self, key: str) -> None:
|
||||
"""Регистрирует одну успешную попытку под *key*."""
|
||||
def record(self, key: str) -> int:
|
||||
"""Регистрирует одну попытку под *key* и возвращает их число в окне ПОСЛЕ неё.
|
||||
|
||||
Счётчик нужен вызывающим, которым мало булева «за лимитом / нет»: login
|
||||
(#2571) по нему считает НАСКОЛЬКО перебран порог и растит задержку ответа
|
||||
пропорционально. Значение можно игнорировать — `check()` так и делает.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
bucket = self._hits[key]
|
||||
self._prune(bucket, now)
|
||||
|
|
@ -125,6 +130,7 @@ class SlidingWindowLimiter:
|
|||
if len(self._hits) > 10000:
|
||||
for k in [k for k, v in self._hits.items() if not v]:
|
||||
del self._hits[k]
|
||||
return len(bucket)
|
||||
|
||||
def check(self, key: str) -> float | None:
|
||||
"""Комбинированная проверка+регистрация (peek+record за один вызов) —
|
||||
|
|
|
|||
|
|
@ -82,6 +82,10 @@ _PUBLIC_PATHS = frozenset(
|
|||
"/api/v1/trade-in/support/anon/messages",
|
||||
"/api/v1/trade-in/support/anon/unread",
|
||||
"/api/v1/trade-in/support/anon/read",
|
||||
# Версионирование (VERSION-файл + build-args, см. app/core/version.py):
|
||||
# не секрет, читает только process env — быстрая справка для клиента/
|
||||
# поддержки/смоук-теста, не должна требовать сессию.
|
||||
"/api/v1/trade-in/version",
|
||||
}
|
||||
)
|
||||
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
|
||||
|
|
|
|||
83
tradein-mvp/backend/app/core/version.py
Normal file
83
tradein-mvp/backend/app/core/version.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Product version metadata — единственный источник правды: `tradein-mvp/VERSION`.
|
||||
|
||||
`APP_VERSION` / `BUILD_SHA` / `BUILD_DATE` обычно приходят как runtime env,
|
||||
запечённые в образ через build-args в `backend/Dockerfile`
|
||||
(см. `.forgejo/workflows/deploy-tradein.yml`, job `build-backend`) — там же
|
||||
ARG'и читают сам `VERSION`-файл, короткий `git rev-parse --short HEAD` и
|
||||
`date -u +%Y-%m-%dT%H:%M:%SZ`.
|
||||
|
||||
Локальный запуск (`uvicorn app.main:app` без Docker-сборки) не задаёт эти env —
|
||||
тогда версия читается напрямую из `VERSION` (поиск вверх по дереву каталогов,
|
||||
см. `_find_version_file`), sha фолбэчит на `"dev"`, дата — на момент импорта
|
||||
модуля. Ничего здесь не должно падать при отсутствии env (потребитель —
|
||||
и PDF-колонтитул, и публичный `GET /api/v1/trade-in/version`).
|
||||
|
||||
Номер версии НЕ дублируется больше нигде в коде — читай `APP_VERSION` отсюда.
|
||||
Раньше рядом существовали два независимых хардкода (`_REPORT_ENGINE_VERSION`
|
||||
в trade_in_pdf.py, `ui-config.ts`'s `version` на фронте) — оба снесены, PDF и
|
||||
`/trade-in/v2` теперь показывают ровно один номер, взятый из этого модуля /
|
||||
`@/lib/buildInfo` соответственно; не заводи третий.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_DEFAULT_VERSION = "0.0.0"
|
||||
# Сколько уровней родителей проверять в поисках VERSION — с запасом покрывает
|
||||
# и локальный layout (backend/app/core/version.py → ../../../VERSION ==
|
||||
# tradein-mvp/VERSION, 3 уровня), и Docker runner layout (/app/app/core/
|
||||
# version.py → /app/VERSION, 2 уровня, см. backend/Dockerfile COPY VERSION).
|
||||
_MAX_ANCESTORS = 6
|
||||
|
||||
|
||||
def _find_version_file() -> Path | None:
|
||||
here = Path(__file__).resolve()
|
||||
for ancestor in list(here.parents)[:_MAX_ANCESTORS]:
|
||||
candidate = ancestor / "VERSION"
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _read_version_file() -> str:
|
||||
path = _find_version_file()
|
||||
if path is None:
|
||||
return _DEFAULT_VERSION
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return _DEFAULT_VERSION
|
||||
return text or _DEFAULT_VERSION
|
||||
|
||||
|
||||
def _default_build_date() -> str:
|
||||
return dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
# Читаются один раз при импорте модуля (совпадает с паттерном `settings =
|
||||
# Settings()` в app/core/config.py) — процесс живёт с одним образом/деплоем,
|
||||
# перечитывать на каждый запрос незачем.
|
||||
APP_VERSION: str = os.environ.get("APP_VERSION") or _read_version_file()
|
||||
BUILD_SHA: str = os.environ.get("BUILD_SHA") or "dev"
|
||||
BUILD_DATE: str = os.environ.get("BUILD_DATE") or _default_build_date()
|
||||
|
||||
|
||||
def format_build_date_human(build_date: str = BUILD_DATE) -> str:
|
||||
"""ISO-8601 UTC → `ДД.ММ.ГГГГ` для пользовательского отображения (PDF
|
||||
колонтитул). Никогда не бросает исключение — при неразборчивой строке
|
||||
возвращает её как есть (это футер отчёта, не API-контракт)."""
|
||||
try:
|
||||
parsed = dt.datetime.fromisoformat(build_date.replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
return build_date
|
||||
return parsed.strftime("%d.%m.%Y")
|
||||
|
||||
|
||||
def product_version_line(product_name: str = "Мера") -> str:
|
||||
"""`Мера v1.0.0 · a1b2c3d · 10.08.2026` — решение владельца продукта
|
||||
2026-08-10 (SemVer + короткий SHA + дата сборки). Используется в PDF
|
||||
колонтитуле; тот же набор значений отдаёт `GET /api/v1/trade-in/version`."""
|
||||
return f"{product_name} v{APP_VERSION} · {BUILD_SHA} · {format_build_date_human()}"
|
||||
|
|
@ -29,10 +29,12 @@ from app.api.v1 import (
|
|||
geocode,
|
||||
lead,
|
||||
me,
|
||||
privacy_admin,
|
||||
search,
|
||||
support,
|
||||
team,
|
||||
trade_in,
|
||||
version,
|
||||
)
|
||||
from app.core.auth_db import get_auth_engine
|
||||
from app.core.config import settings
|
||||
|
|
@ -143,6 +145,19 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||
get_auth_engine()
|
||||
logger.info("identity_store=auth: DSN общего реестра людей (БД `auth`) сконфигурирован")
|
||||
|
||||
# Платёжный контур МЕРЫ (Т-Банк эквайринг, PR-B — схема-only). Роутер/сервис
|
||||
# ещё не существуют (PR-D), но kill-switch и конфиг уже здесь: если кто-то
|
||||
# включит PAYMENTS_ENABLED=true в .env.runtime раньше, чем заведёт
|
||||
# TBANK_TERMINAL_KEY/TBANK_PASSWORD, лучше не поднимать контейнер вообще,
|
||||
# чем молча остаться без терминала/пароля на боевом трафике оплат.
|
||||
if settings.payments_enabled and (
|
||||
not settings.tbank_terminal_key or not settings.tbank_password.get_secret_value()
|
||||
):
|
||||
raise RuntimeError(
|
||||
"PAYMENTS_ENABLED=true, но TBANK_TERMINAL_KEY/TBANK_PASSWORD пусты — "
|
||||
"задай оба в .env.runtime до включения платёжного контура"
|
||||
)
|
||||
|
||||
# FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server.
|
||||
# Best-effort: failure does not abort startup, just logs.
|
||||
try:
|
||||
|
|
@ -199,8 +214,10 @@ app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
|||
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
||||
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
||||
app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
|
||||
app.include_router(privacy_admin.router, prefix="/api/v1/admin", tags=["admin-privacy"])
|
||||
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
|
||||
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||||
app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-version"])
|
||||
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(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
|
||||
|
|
|
|||
|
|
@ -40,7 +40,9 @@ class SearchParams(BaseModel):
|
|||
floors_total_max: int | None = Field(default=None, ge=1)
|
||||
|
||||
# --- Quality / cross-source ---
|
||||
has_kadastr: bool = False
|
||||
# has_kadastr снят (#2674): listings.cadastral_number пуст у всех 93 408 строк,
|
||||
# фильтр мог вернуть только пустую выдачу. Лишний query-param FastAPI игнорирует,
|
||||
# так что старые клиенты не ломаются.
|
||||
sources: list[Literal["avito", "cian", "yandex_realty"]] | None = None
|
||||
multi_source_only: bool = False
|
||||
require_avito: bool = False
|
||||
|
|
|
|||
|
|
@ -48,6 +48,15 @@ class TradeInEstimateInput(BaseModel):
|
|||
has_mortgage: bool | None = None
|
||||
# client_name / client_phone удалены (PII purge #1969, DROP COLUMN 167).
|
||||
|
||||
# ЭТАП 4 B2C launch — anonymous consent-before-save (152-ФЗ, migration 229).
|
||||
# Enforcement (НЕ здесь): app.services.estimator.estimate_quality проверяет
|
||||
# `created_by is None and not consent -> 422` ДО первого INSERT адреса в
|
||||
# trade_in_estimates. Здесь поле намеренно `bool | None = None`, а НЕ
|
||||
# `Literal[True]` (как TradeInLeadInput.consent) — сделать True строго-
|
||||
# обязательным на уровне Pydantic сломало бы B2B-пилотов: их согласие
|
||||
# закрыто договором, а не UI-чекбоксом, и их фронт НЕ шлёт это поле вовсе.
|
||||
consent: bool | None = None
|
||||
|
||||
|
||||
class AnalogLot(BaseModel):
|
||||
address: str
|
||||
|
|
@ -187,6 +196,10 @@ class AggregatedEstimate(BaseModel):
|
|||
analogs: list[AnalogLot]
|
||||
actual_deals: list[AnalogLot] # реальные продажи last 12 mo
|
||||
expires_at: datetime
|
||||
# PR-D1: срок жизни ССЫЛКИ/СТРОКИ (оплаченный доступ), НЕ актуальности
|
||||
# расчёта — тот остаётся expires_at (не путать, см. migration 240).
|
||||
# NULL = неоплачено (весь текущий трафик, B2B pilots включительно).
|
||||
retain_until: datetime | None = None
|
||||
# ── Дополнительные метаданные ──
|
||||
target_address: str | None = None # geocoded full address
|
||||
target_lat: float | None = None
|
||||
|
|
@ -306,6 +319,41 @@ class AggregatedEstimate(BaseModel):
|
|||
cv: float | None = None
|
||||
source_counts: dict[str, int] = Field(default_factory=dict)
|
||||
created_at: datetime | None = None
|
||||
# ── #oblast-F (never-block relaxation cascade, product decision 2026-08-10,
|
||||
# #oblast-E priority RESTORED same day — see estimator.py module
|
||||
# docstring for the full 3-way headline-source rule) ──────────────────
|
||||
# Product requirement: an estimate is ALWAYS surfaced — a thin base sample
|
||||
# (< HEADLINE_LISTINGS_MIN_N) no longer means "недостаточно данных". First
|
||||
# estimator.estimate_quality() progressively relaxes the analog SEARCH
|
||||
# (room-count adjacency → freshness window → novostroyki segment → radius)
|
||||
# trying to grow the sample past the threshold; if it's STILL thin,
|
||||
# _price_from_inputs() prefers a usable ДКП deals corridor over a noisy
|
||||
# thin listings median when one is available (restored #oblast-E
|
||||
# priority — the Серов repro: 3 listings must not outrank 54 deals), and
|
||||
# only falls back to the thin listings median itself when no corridor
|
||||
# exists. Real refusal happens only at genuine zero (no listings AND no
|
||||
# usable anchor/deals).
|
||||
# relaxations — RU-подписи КАЖДОГО применённого (реально помогшего) шага
|
||||
# ослабления, готовые к показу пользователю как честный дисклеймер рядом с
|
||||
# confidence_explanation. Пусто — базовой (4-tier) выборки хватило, каскад
|
||||
# не понадобился (обычный случай). Возможные значения (дословно, фронт
|
||||
# может на них завязываться): "снят фильтр по году постройки",
|
||||
# "учтены студии", "комнатность ±1", "объявления за 60 дней",
|
||||
# "учтены новостройки", "площадь ±25%", "радиус расширен до {N} м",
|
||||
# "оценка по сделкам — мало объявлений рядом" (headline ceded to the ДКП
|
||||
# deals corridor because the base listings sample was thin — a source
|
||||
# SWITCH, not a search widening, but surfaced the same way).
|
||||
# reliability — надёжность итоговой выборки, ПРОИЗВОДНАЯ от n_analogs
|
||||
# (>=8 → ok; 3..7 → low; <3 → very_low), с доп. даунгрейдом ok→low, если
|
||||
# relaxations непусто (выборка набралась только ценой ослаблений); капается
|
||||
# на 'low' (не 'very_low'), когда headline ушёл по сделкам из-за тонкой
|
||||
# выборки — реальный ДКП-коридор это настоящий сигнал, не «почти ничего».
|
||||
# НЕ персистится на GET-rehydrate (пусто/"ok" по умолчанию там — известное
|
||||
# ограничение, каскад не переигрывается из сохранённых analogs). НЕ
|
||||
# путать с `confidence` (Literal low/medium/high — старая метрика на
|
||||
# основе уникальных адресов/IQR, см. её собственный докстринг выше).
|
||||
relaxations: list[str] = Field(default_factory=list)
|
||||
reliability: Literal["ok", "low", "very_low"] = "ok"
|
||||
# ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку
|
||||
# при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ──
|
||||
area_m2: float | None = None
|
||||
|
|
@ -417,6 +465,11 @@ class ScheduleConfigUpdate(BaseModel):
|
|||
window_start_hour: int = Field(default=2, ge=0, le=23)
|
||||
window_end_hour: int = Field(default=5, ge=0, le=23)
|
||||
default_params: dict[str, Any] = Field(default_factory=dict)
|
||||
# #2674: явная воля оператора по времени следующего запуска. None (умолчание) —
|
||||
# «не трогай, посчитай сам от такта». Заданное значение уважается как есть, включая
|
||||
# прошедшее/now() — это и есть «запустить сейчас» (планировщик берёт строки с
|
||||
# next_run_at <= NOW()), у которого до сих пор не было API и его делали UPDATE'ом.
|
||||
next_run_at: datetime | None = None
|
||||
|
||||
|
||||
# ── House analytics (house_placement_history backfill) ───────────────────────
|
||||
|
|
@ -604,6 +657,13 @@ class SalesVsListingsResponse(BaseModel):
|
|||
deals_with_listings: int # сколько имеют связанный listing
|
||||
linkage_rate_pct: float # deals_with_listings / total_deals * 100
|
||||
median_discount_pct: float | None # медиана по парам с listing
|
||||
# #2666: None вместе с median_discount_pct=None означает «медианы просто нет»
|
||||
# (пар не нашлось). Непустая строка = медиана посчиталась, но не прошла гейт
|
||||
# правдоподобия (мало пар / значение вне санитарного диапазона — см. пороги
|
||||
# SALES_VS_LISTINGS_* в api/v1/trade_in.py) и намеренно не показывается.
|
||||
# Форма отказа зеркалит confidence_explanation оценщика: пользователю нужен
|
||||
# текст «почему числа нет», иначе пустое место читается как поломка виджета.
|
||||
median_discount_explanation: str | None = None
|
||||
data_quality: str # "house_linked" | "street_only" | "no_data" (#721, ADR v3)
|
||||
pairs: list[SalesListingPair] # все пары, sorted by deal_date DESC
|
||||
|
||||
|
|
|
|||
|
|
@ -19,19 +19,40 @@ from dataclasses import dataclass, field
|
|||
# golden-parity была доказана против legacy cian_detail-модуля до его удаления,
|
||||
# #2397 Part E2; extract_state/ScrapedLot parity-тесты убраны вместе с остальным
|
||||
# legacy scrapers-каталогом, #2397 финальный шаг E — kit единственный живой путь).
|
||||
# RealScraperConfig — тот же read-only адаптер над settings, что и остальные
|
||||
# kit-инжекции (#2131) — сохраняет proxy-поведение (config.cian_proxy_url)
|
||||
# идентичным прежнему прямому импорту settings.
|
||||
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
|
||||
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.scraper_adapters import RealMatcherAdapter, RealScraperConfig
|
||||
from app.services.scraper_adapters import (
|
||||
RealMatcherAdapter,
|
||||
RealProxyProvider,
|
||||
RealScraperConfig,
|
||||
)
|
||||
from app.services.scraper_settings import get_scraper_delay
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _PoolCurlConfig(RealScraperConfig):
|
||||
"""RealScraperConfig с принудительно включённым pool-режимом curl (#2830).
|
||||
|
||||
`USE_PROXY_POOL_CURL` задан только контейнеру `scraper` (docker-compose.prod.yml
|
||||
services.scraper.environment), а этот бэкфилл запускается ручкой
|
||||
`POST /admin/scrape/cian-price-history` в контейнере `backend`, где переменной нет
|
||||
→ `settings.use_proxy_pool_curl` = False. С ней `providers/_proxy.py::curl_proxy_url`
|
||||
ИГНОРИРУЕТ переданный `proxy_provider` и уходит на статичный `SCRAPER_PROXY_URL`:
|
||||
один `proxy_provider=` был бы правкой без эффекта (зелёный тест, нулевой прод).
|
||||
|
||||
Флаг — рубильник раскатки pool-режима для планировщика, а не решение «этому пути
|
||||
пул не нужен»: инцидент 2026-08-10 (#2830) — ровно про то, что нужен именно ему.
|
||||
"""
|
||||
|
||||
@property
|
||||
def use_proxy_pool_curl(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
class CianPriceHistoryResult:
|
||||
checked: int = 0
|
||||
|
|
@ -60,6 +81,11 @@ async def backfill_cian_price_history(
|
|||
result = CianPriceHistoryResult()
|
||||
t0 = time.time()
|
||||
delay = get_scraper_delay("cian") # default 5.0s
|
||||
# Egress через пул с учётом `scrape_proxy_source_bans` (#2830): узел выбирает
|
||||
# `curl_proxy_url` внутри `fetch_detail`, он же на выходе возвращает вердикт
|
||||
# (mark_banned на CianBlockedError / mark_health / release).
|
||||
scraper_config = _PoolCurlConfig()
|
||||
proxy_provider = RealProxyProvider()
|
||||
|
||||
if listing_id is not None:
|
||||
rows = (
|
||||
|
|
@ -107,9 +133,27 @@ async def backfill_cian_price_history(
|
|||
url: str = row["source_url"]
|
||||
|
||||
try:
|
||||
# config= обязателен — kit fetch_detail без него не читает cian_proxy_url
|
||||
# (direct connection), а без прокси datacenter-IP блокируется Cian (#806).
|
||||
enrichment = await fetch_detail(url, config=RealScraperConfig())
|
||||
# config= обязателен — без него kit fetch_detail идёт напрямую, а без прокси
|
||||
# datacenter-IP блокируется Cian (#806). proxy_provider= — узел из пула
|
||||
# (#2830): раньше здесь был статичный SCRAPER_PROXY_URL, не знающий про
|
||||
# `scrape_proxy_source_bans`, и 403 от отбитого узла никому не сообщался.
|
||||
enrichment = await fetch_detail(
|
||||
url, config=scraper_config, proxy_provider=proxy_provider
|
||||
)
|
||||
except NoProxyAvailableError as exc:
|
||||
# Fail-closed (#2616): пул пуст/недоступен в проде. Остальные листинги
|
||||
# упрутся в то же самое — рвём батч сразу, а не 50 раз по 5 секунд с
|
||||
# логом, который читается как «Циан нас блокирует».
|
||||
logger.error(
|
||||
"cian_price_history: нет доступного прокси в пуле (%s) — батч прерван "
|
||||
"на listing_id=%s (обработано %d из %d)",
|
||||
exc,
|
||||
lid,
|
||||
i,
|
||||
len(rows),
|
||||
)
|
||||
result.errors += 1
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"cian_price_history: fetch failed listing_id=%s url=%s: %s",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from curl_cffi.requests import AsyncSession
|
||||
|
|
@ -20,9 +21,15 @@ from sqlalchemy import text
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# За сколько дней до протухания кук предупреждать (#2658). Обновление кук — РУЧНАЯ
|
||||
# операция (залить дамп через админку), человеку нужен запас: алерт по факту протухания
|
||||
# приходит, когда сбор уже встал. save_session ставит ttl 30 дней, так что окно широкое.
|
||||
COOKIE_EXPIRY_WARN_DAYS = 5
|
||||
|
||||
# Cookies критичные для Cian auth — фильтр перед сохранением.
|
||||
# Список обновлён по реальному DevTools-дампу из logged-in сессии cian.ru (2026-05-23).
|
||||
# Старые записи оставлены как fallback (backward compat).
|
||||
|
|
@ -146,8 +153,12 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
|
|||
try:
|
||||
# proxies: mobile-proxy egress (#806) — Cian блокирует datacenter-IP даже
|
||||
# при валидных DMIR_AUTH cookies. Без прокси verify всегда вернёт 403.
|
||||
# Пусто (env не задан) → прямое подключение (dev/no-op).
|
||||
_proxy_url = settings.cian_proxy_url
|
||||
# Резолвер по источнику (#2825): пул scrape_proxies с учётом
|
||||
# scrape_proxy_source_bans, fallback на settings.cian_proxy_url только если
|
||||
# пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все забанены/
|
||||
# нездоровы для cian -- ProxyPoolExhaustedError (fail-closed, #2616), см. except
|
||||
# ниже.
|
||||
_proxy_url = resolve_proxy_url_sync("cian")
|
||||
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
|
||||
async with AsyncSession(
|
||||
impersonate="chrome120",
|
||||
|
|
@ -184,6 +195,17 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
|
|||
logger.info("Cian cookies verified — userId=%s", user.get("userId"))
|
||||
|
||||
return result
|
||||
except ProxyPoolExhaustedError as exc:
|
||||
# Fail-closed (#2616, #2825): пул scrape_proxies не пуст, но все узлы забанены
|
||||
# ИМЕННО для cian/нездоровы — НЕ уходим на settings.cian_proxy_url (тот самый
|
||||
# статичный узел мог быть источником бана, см. proxy_egress module docstring).
|
||||
# Явный отказ вместо слепого прохода через заведомо подозрительный egress.
|
||||
logger.error(
|
||||
"Cian cookies verify: пул прокси исчерпан для cian (%s) — verify пропущен, "
|
||||
"cookies НЕ помечены протухшими, retry на следующем такте",
|
||||
exc,
|
||||
)
|
||||
return VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||||
except Exception as exc:
|
||||
# Сетевой/транспортный сбой (timeout, DNS, connection reset и т.п.) — источник
|
||||
# недоступен, НЕ признак протухших cookies (finding 4). Раньше здесь везде
|
||||
|
|
@ -294,6 +316,37 @@ def load_session(db: Session) -> dict[str, str] | None:
|
|||
return cookies
|
||||
|
||||
|
||||
def session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None:
|
||||
"""Когда протухают самые свежезагруженные куки (#2658).
|
||||
|
||||
`load_session` отбирает только ещё валидные записи (expires_at_estimate > NOW()) и на
|
||||
протухших отдаёт None — вызывающий не мог отличить «кук никогда не загружали» от
|
||||
«протухли позавчера» и не мог предупредить ЗАРАНЕЕ.
|
||||
|
||||
valid_only=False (диагностика после None от load_session) — свежайшая запись любая:
|
||||
валидных по определению нет, нужен именно срок протухшей. valid_only=True — та же
|
||||
запись, которую взял бы load_session: для предупреждения «скоро протухнут» нужен срок
|
||||
ИМЕННО используемых кук, иначе при нескольких аккаунтах посчитаем по чужой строке.
|
||||
"""
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT expires_at_estimate FROM cian_session_cookies
|
||||
WHERE NOT CAST(:valid_only AS boolean)
|
||||
OR (expires_at_estimate > NOW()
|
||||
AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at))
|
||||
ORDER BY uploaded_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"valid_only": valid_only},
|
||||
).first()
|
||||
if row is None:
|
||||
return None
|
||||
expires_at: datetime | None = row[0]
|
||||
return expires_at
|
||||
|
||||
|
||||
def mark_session_invalid(db: Session, account_user_id: int) -> None:
|
||||
"""Flag session как expired/invalid (например после 401 во время scrape)."""
|
||||
db.execute(
|
||||
|
|
|
|||
240
tradein-mvp/backend/app/services/data_erasure.py
Normal file
240
tradein-mvp/backend/app/services/data_erasure.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"""Right-to-erasure mechanism (152-ФЗ) — ЭТАП 4 B2C launch, part C.
|
||||
|
||||
WHY:
|
||||
trade_in has no self-service "delete my data" endpoint at all. Both B2B
|
||||
pilots (identified by `created_by` username) and future anonymous B2C
|
||||
users need SOME way to have their personal data physically removed on
|
||||
request, not just after their retention TTL expires
|
||||
(app/tasks/purge_expired_trade_in_data.py handles the TTL path, this
|
||||
module handles the on-demand path).
|
||||
|
||||
WHO CAN BE IDENTIFIED, HONESTLY:
|
||||
- B2B pilot (has a `username`): trivially -- `created_by = username` scopes
|
||||
every estimate they created; leads/support threads follow from there.
|
||||
- Anonymous person: has NO username. This function can ONLY act on
|
||||
identifiers the requester can actually supply:
|
||||
* `estimate_ids` -- if they still have the link/PDF from their estimate
|
||||
(the UUID in the URL/QR-code IS their proof of "this is mine").
|
||||
* `phone` -- if they left a contact-request lead with that phone.
|
||||
Matched by CANONICAL RU DIGITS on both sides (see
|
||||
`_ru_phone_norm_sql` below), not an exact string: lead.py stores
|
||||
`payload.phone` exactly as typed (no E.164 normalization, by
|
||||
design), so "+7 999 123-45-67", "8 (999) 123-45-67" and
|
||||
"89991234567" must all find the same row. Covers ONLY the
|
||||
RU 8-vs-7 trunk-prefix case (exact digit-count identity, no
|
||||
heuristic truncation) -- see the helper's docstring for why.
|
||||
* `tg_chat_id` -- if they messaged @MERAsupport_bot directly (their own
|
||||
Telegram chat id -- not guessable/spoofable by a third party the way
|
||||
a name or IP would be).
|
||||
If an anonymous person has NONE of these (e.g. they only remember the
|
||||
street address, or ran an estimate but never saved anything and didn't
|
||||
log support contact) -- THIS IS HONESTLY UNRESOLVABLE without additional
|
||||
identification. There is no username, no stable session, nothing in the
|
||||
DB schema today that lets a support operator find "the one estimate this
|
||||
specific stranger made three days ago" among many. Do not paper over
|
||||
this: an operator facing that case must say so, not silently pick "the
|
||||
closest match".
|
||||
|
||||
⚠️ TELEGRAM CAVEAT (152-ФЗ, honestly, do not omit):
|
||||
Every tg_support_messages row was, at send time, ALSO mirrored by the bot
|
||||
into the support-group Telegram topic (see app/services/tgbot/bridge.py,
|
||||
186_tg_support.sql). Deleting `tg_support_users` here only removes the
|
||||
copy IN THIS DATABASE. The mirrored copy lives in the Telegram supergroup,
|
||||
outside this function's reach, and is NOT deleted by anything in this
|
||||
codebase. A complete erasure across the whole chain requires a SEPARATE
|
||||
manual step (Telegram Bot API `deleteMessage` per `topic_message_id` in
|
||||
the supergroup) that is out of scope here. Do not cite this function's
|
||||
return value as proof of full erasure of the Telegram-side copy.
|
||||
|
||||
WHAT ELSE IS *NOT* TOUCHED (known gap, flagged, not silently dropped):
|
||||
`user_events` (184_user_events.sql) logs `estimate_request` events with a
|
||||
JSONB payload that includes `address`/`area_m2`/`rooms` and is keyed by
|
||||
`username` (empty string for anonymous callers today) + `ip_address`, with
|
||||
NO FK to trade_in_estimates (decoupled/append-only by explicit design --
|
||||
see that migration's comment). This function does NOT purge user_events:
|
||||
it is an audit/analytics log, not an estimate/lead/support record, and
|
||||
deciding whether "audit trail" is a legitimate 152-ФЗ retention basis that
|
||||
overrides an erasure request is a legal call, not an engineering one. Flag
|
||||
it to whoever handles the request; do not assume it is already covered.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ru_phone_norm_sql(expr: str) -> str:
|
||||
"""SQL-фрагмент: нормализация телефона к каноническому РФ-виду (11 цифр,
|
||||
ведущая '7'), для сравнения "разного форматирования одного и того же номера"
|
||||
(deep-review 2026-08-06, MEDIUM + follow-up).
|
||||
|
||||
Два шага: 1) убрать всё, кроме цифр; 2) если получилось РОВНО 11 цифр с
|
||||
ведущей '8' -- заменить её на '7'. Это ТОЧНОЕ тождество для российской
|
||||
нумерации (8 и +7 -- один и тот же trunk-префикс), не эвристика: длина
|
||||
проверяется явно (=11), заменяется РОВНО одна ведущая цифра. Специально
|
||||
НЕ "последние 10 цифр" -- усечение убрало бы риск ложных совпадений
|
||||
неточно: оно склеивает номера РАЗНЫХ стран с теми же 10 хвостовыми
|
||||
цифрами, а удаление ЧУЖИХ данных по erasure-запросу хуже, чем
|
||||
неудаление своих. Номера другой длины/страны просто не совпадут ни на
|
||||
этом шаге, ни дальше -- безопасный отказ, не false positive.
|
||||
|
||||
`expr` -- ВСЕГДА статичный SQL-фрагмент (имя колонки или
|
||||
`CAST(:bind AS type)`), НИКОГДА значение параметра: эта функция строит
|
||||
структуру запроса из литералов, вызывающих её мест ровно два (см.
|
||||
_PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL ниже) -- ни один телефон
|
||||
не попадает в текст SQL напрямую, только через bind-параметр `:phone`.
|
||||
"""
|
||||
stripped = f"regexp_replace({expr}, '\\D', '', 'g')"
|
||||
return (
|
||||
f"(CASE WHEN length({stripped}) = 11 AND left({stripped}, 1) = '8' "
|
||||
f"THEN '7' || substring({stripped} FROM 2) ELSE {stripped} END)"
|
||||
)
|
||||
|
||||
|
||||
# Предвычисленные один раз -- обе стороны сравнения телефона в erase_person_data
|
||||
# (колонка trade_in_leads.phone / входной CAST(:phone AS text)).
|
||||
_PHONE_COLUMN_NORM_SQL = _ru_phone_norm_sql("phone")
|
||||
_PHONE_PARAM_NORM_SQL = _ru_phone_norm_sql("CAST(:phone AS text)")
|
||||
|
||||
|
||||
def erase_person_data(
|
||||
db: Session,
|
||||
*,
|
||||
username: str | None = None,
|
||||
estimate_ids: Sequence[UUID] | None = None,
|
||||
phone: str | None = None,
|
||||
tg_chat_id: int | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Physically delete a person's data across trade_in tables.
|
||||
|
||||
At least one identifier is required (raises ValueError otherwise -- callers
|
||||
MUST pass an explicit identifier, never "erase everything" by omission).
|
||||
|
||||
Order of operations matters: leads are captured/deleted BEFORE estimates,
|
||||
because trade_in_leads.estimate_id is ON DELETE SET NULL (172) -- once the
|
||||
estimate row is gone, the join key to find "leads that came from this
|
||||
person's estimate" is gone too.
|
||||
|
||||
Returns per-table deleted-row counters. Callers own committing the ambient
|
||||
Session lifecycle in whatever way their layer does (this function DOES
|
||||
commit itself, mirroring app/tasks/*.py conventions, since this is a
|
||||
one-shot admin operation, not a request-scoped unit of work shared with
|
||||
other writes).
|
||||
"""
|
||||
if not any([username, estimate_ids, phone, tg_chat_id]):
|
||||
raise ValueError(
|
||||
"erase_person_data requires at least one identifier: "
|
||||
"username / estimate_ids / phone / tg_chat_id"
|
||||
)
|
||||
|
||||
counters: dict[str, int] = {
|
||||
"trade_in_estimates_deleted": 0,
|
||||
"trade_in_leads_deleted": 0,
|
||||
"web_support_deleted": 0,
|
||||
"tg_support_deleted": 0,
|
||||
}
|
||||
|
||||
# 1. Собрать ПОЛНЫЙ набор estimate_id ДО удаления оценок: явные estimate_ids
|
||||
# (анонимный путь -- человек прислал ссылку/PDF) + все id с
|
||||
# created_by=username (B2B-путь). Нужно захватить это СЕЙЧАС -- после
|
||||
# DELETE FROM trade_in_estimates связанные trade_in_leads.estimate_id
|
||||
# уйдут в NULL (ON DELETE SET NULL, 172), join станет невозможен.
|
||||
all_estimate_ids: set[UUID] = set(estimate_ids or [])
|
||||
if username:
|
||||
owned = (
|
||||
db.execute(
|
||||
text("SELECT id FROM trade_in_estimates WHERE created_by = :username"),
|
||||
{"username": username},
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
all_estimate_ids.update(owned)
|
||||
|
||||
# 2. Лиды -- пока estimate_id ещё живой FK (см. п.1), плюс отдельно по
|
||||
# телефону (лид мог быть оставлен без attach к оценке вовсе).
|
||||
#
|
||||
# ⚠️ Телефон сравнивается по КАНОНИЧЕСКОМУ РФ-виду, не литералом
|
||||
# (deep-review 2026-08-06, MEDIUM + follow-up). app/api/v1/lead.py
|
||||
# сохраняет payload.phone КАК ПРИСЛАЛИ (намеренно -- полная
|
||||
# E.164-нормализация вне scope MVP, см. lead.py::_PHONE_PATTERN),
|
||||
# т.е. одна и та же строка может лежать в БД как "+7 999 123-45-67"
|
||||
# ИЛИ "89991234567" ИЛИ "8 (999) 123-45-67". Точное `phone = :phone`
|
||||
# находит строку только если запрашивающий пришлёт БУКВАЛЬНО ТОТ ЖЕ
|
||||
# формат, каким когда-то ввёл номер -- почти никогда так. Раньше это
|
||||
# молча удаляло 0 строк и всё равно возвращало 200 "данные удалены":
|
||||
# для 152-ФЗ ложное подтверждение удаления хуже честной ошибки.
|
||||
# _PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL (см. _ru_phone_norm_sql
|
||||
# выше) снимают форматирование С ОБЕИХ сторон И схлопывают ведущую
|
||||
# '8' в '7' при 11 цифрах -- покрывает РОВНО RU 8-vs-7 trunk-префикс,
|
||||
# без усечения до "последних 10 цифр" (риск ложного совпадения с
|
||||
# номером другой страны -- см. докстринг helper'а). Номера иных
|
||||
# форматов/длин сравниваются как есть (просто не совпадут). Параметр --
|
||||
# CAST(:phone AS text), НЕ конкатенация значения (psycopg v3 / SQL
|
||||
# injection convention, .claude/rules/backend.md); сам SQL-текст
|
||||
# собран из СТАТИЧНЫХ фрагментов (_PHONE_*_NORM_SQL), в которых нет
|
||||
# ни одного значения параметра.
|
||||
ids_param = [str(i) for i in all_estimate_ids]
|
||||
result = db.execute(
|
||||
text(
|
||||
f"""
|
||||
DELETE FROM trade_in_leads
|
||||
WHERE estimate_id = ANY(CAST(:ids AS uuid[]))
|
||||
OR (
|
||||
CAST(:phone AS text) IS NOT NULL
|
||||
AND {_PHONE_COLUMN_NORM_SQL} = {_PHONE_PARAM_NORM_SQL}
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"ids": ids_param, "phone": phone},
|
||||
)
|
||||
counters["trade_in_leads_deleted"] = result.rowcount or 0
|
||||
|
||||
# 3. Оценки (CASCADE подчищает estimate_photos + avito_imv_evaluations).
|
||||
if all_estimate_ids:
|
||||
result = db.execute(
|
||||
text("DELETE FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"),
|
||||
{"ids": ids_param},
|
||||
)
|
||||
counters["trade_in_estimates_deleted"] = result.rowcount or 0
|
||||
|
||||
# 4. Веб-чат поддержки -- ключ username (сайт закрыт Caddy basic_auth, у
|
||||
# анонима username нет и быть не может, см. 187_web_support_chat.sql).
|
||||
if username:
|
||||
result = db.execute(
|
||||
text("DELETE FROM web_support_threads WHERE username = :username"),
|
||||
{"username": username},
|
||||
)
|
||||
counters["web_support_deleted"] = result.rowcount or 0
|
||||
|
||||
# 5. Telegram-поддержка -- ключ chat_id, ЕДИНСТВЕННЫЙ путь, реально
|
||||
# доступный анониму без username (см. module docstring). ⚠️ Чистит
|
||||
# ТОЛЬКО эту БД -- Telegram-топик со своей копией переписки НЕ
|
||||
# затрагивается, см. ВАЖНЫЙ ФАКТ в docstring выше.
|
||||
if tg_chat_id is not None:
|
||||
result = db.execute(
|
||||
text("DELETE FROM tg_support_users WHERE chat_id = CAST(:chat_id AS bigint)"),
|
||||
{"chat_id": tg_chat_id},
|
||||
)
|
||||
counters["tg_support_deleted"] = result.rowcount or 0
|
||||
|
||||
db.commit()
|
||||
logger.info(
|
||||
"erase_person_data: username=%r estimate_ids=%d phone=%s tg_chat_id=%s -> %s",
|
||||
username,
|
||||
len(all_estimate_ids),
|
||||
"<redacted>" if phone else None,
|
||||
tg_chat_id,
|
||||
counters,
|
||||
)
|
||||
return counters
|
||||
|
||||
|
||||
__all__: list[str] = ["erase_person_data"]
|
||||
|
|
@ -17,6 +17,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -25,6 +26,13 @@ from app.core.config import settings
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# За сколько дней до протухания кук предупреждать (#2674, по образцу #2658 для Циана).
|
||||
# Обновление кук — РУЧНАЯ операция (залить дамп через админку), человеку нужен запас:
|
||||
# сигнал по факту протухания приходит, когда обогащение уже встало. Прод 2026-08-03:
|
||||
# куки протухли, единственным следом был WARNING в docker-логе, который к тому же
|
||||
# теряется при редеплое. save_session ставит ttl 30 дней, так что окно широкое.
|
||||
COOKIE_EXPIRY_WARN_DAYS = 5
|
||||
|
||||
# Cookies критичные для DomClick auth (Sber ID) — фильтр перед сохранением.
|
||||
# Список составлен по реальному DevTools/Cookie-Editor дампу авторизованной
|
||||
# test-аккаунт сессии (Sber ID login), 2026-07-04.
|
||||
|
|
@ -148,6 +156,37 @@ def load_session(db: Session) -> dict[str, str] | None:
|
|||
return cookies
|
||||
|
||||
|
||||
def session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None:
|
||||
"""Когда протухают самые свежезагруженные куки (#2674, зеркалит cian_session #2658).
|
||||
|
||||
`load_session` отбирает только ещё валидные записи (expires_at_estimate > NOW()) и на
|
||||
протухших отдаёт None — вызывающий не мог отличить «кук никогда не загружали» от
|
||||
«протухли позавчера» и не мог предупредить ЗАРАНЕЕ.
|
||||
|
||||
valid_only=False (диагностика после None от load_session) — свежайшая запись любая:
|
||||
валидных по определению нет, нужен именно срок протухшей. valid_only=True — та же
|
||||
запись, которую взял бы load_session: для предупреждения «скоро протухнут» нужен срок
|
||||
ИМЕННО используемых кук, иначе при нескольких аккаунтах посчитаем по чужой строке.
|
||||
"""
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT expires_at_estimate FROM domclick_session_cookies
|
||||
WHERE NOT CAST(:valid_only AS boolean)
|
||||
OR (expires_at_estimate > NOW()
|
||||
AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at))
|
||||
ORDER BY uploaded_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"valid_only": valid_only},
|
||||
).first()
|
||||
if row is None:
|
||||
return None
|
||||
expires_at: datetime | None = row[0]
|
||||
return expires_at
|
||||
|
||||
|
||||
def mark_session_invalid(db: Session, account_cas_id: int) -> None:
|
||||
"""Flag session как expired/invalid (например после блока во время scrape)."""
|
||||
db.execute(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -51,6 +51,7 @@ from matplotlib.figure import Figure # object API, НЕ pyplot — см. _price
|
|||
from matplotlib.patches import Rectangle
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.version import product_version_line
|
||||
from app.schemas.trade_in import AggregatedEstimate, AnalogLot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -229,12 +230,6 @@ _DANGER_SOFT = "#f9eded" # мягкий тон (12% _DANGER на белом)
|
|||
_BORDER = _LINE
|
||||
_BORDER_STRONG = "#b8c8d8" # tokens.line3 — edge карточки/фото, оси графика (сильнее hairline)
|
||||
|
||||
# Декоративная версия «движка отчёта» в футере (см. _page_footer) — зеркалит
|
||||
# tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts::version. Не
|
||||
# brand-данные (одинаковая для всех white-label брендов) — косметическая деталь
|
||||
# HUD, а не версия PDF-модуля/API.
|
||||
_REPORT_ENGINE_VERSION = "v2.0.6"
|
||||
|
||||
# Type scale — консолидировано с ~11 разрозненных значений (7/7.5/8/8.5/9/10/
|
||||
# 11/12/13/14/18pt) до 6 шагов, применяется единообразно на всех 4 страницах.
|
||||
_FS_XS = "8pt" # футеры, дисклеймеры, source badges, sub-captions
|
||||
|
|
@ -505,13 +500,31 @@ def _page_header(brand, report_num: str, report_date: dt.date) -> str: # type:
|
|||
"ДАТА", report_date.strftime("%d.%m.%Y")
|
||||
)
|
||||
|
||||
# Строка версии продукта («Мера v1.0.0 · a1b2c3d · 10.08.2026») — решение
|
||||
# владельца продукта 2026-08-10, см. app/core/version.py::product_version_line.
|
||||
# Отдельная от brand.name строка НАМЕРЕННО: brand.name — white-label вывеска
|
||||
# реселлера (Практика/PRINZIP), а тут — версия самого продукта «Мера»,
|
||||
# одинаковая для всех брендов. Одна nowrap/overflow:hidden строка под
|
||||
# существующим masthead-рядом — не растёт по высоте ни при каком контенте
|
||||
# (клипается по ширине, не переносится), top-margin (25mm) даёт под неё
|
||||
# запас; см. коммит 42a50cf8 про хрупкость running-header бюджета высоты.
|
||||
version_html = (
|
||||
f'<div style="text-align:right;font-size:6.5pt;letter-spacing:0.03em;'
|
||||
f"color:{_MUTED_2};font-family:'IBM Plex Mono','DejaVu Sans Mono',monospace;"
|
||||
f'white-space:nowrap;overflow:hidden;margin-bottom:6pt;">'
|
||||
f"{_html.escape(product_version_line())}</div>"
|
||||
)
|
||||
|
||||
return (
|
||||
f"<div>"
|
||||
f'<div style="display:flex;align-items:center;justify-content:space-between;'
|
||||
f"flex-wrap:wrap;gap:6pt;border-bottom:2pt solid {brand.primary_color};"
|
||||
f'padding-bottom:6pt;margin-bottom:9pt;">'
|
||||
f'padding-bottom:6pt;margin-bottom:3pt;">'
|
||||
f"{mark_html}"
|
||||
f'<span style="display:flex;align-items:center;flex-shrink:0;">{meta_html}</span>'
|
||||
f"</div>"
|
||||
f"{version_html}"
|
||||
f"</div>"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -529,7 +542,11 @@ def _page_footer(
|
|||
|
||||
строка 1 — mono meta (№ отчёта / дата / срок действия); тонкая градиентная
|
||||
линия-разделитель; строка 2 — точка акцента + wordmark (brand.name — НЕ
|
||||
хардкод «МЕРА», white-label остаётся рабочим) + версия движка отчёта.
|
||||
хардкод «МЕРА», white-label остаётся рабочим). Номер версии продукта здесь
|
||||
НЕ дублируется — единственное место вывода версии в PDF — running-header
|
||||
(_page_header → product_version_line()); раньше рядом с wordmark висел
|
||||
decorative "vN.N.N" (_REPORT_ENGINE_VERSION), не связанный с реальной
|
||||
версией продукта — расходился с header на каждой странице, снесён.
|
||||
|
||||
page_note — старый текст footer'а (бренд/подзаголовок/№ страницы/дисклеймер
|
||||
на офер-странице), которого нет в веб-референсе (там нет пагинации). Не
|
||||
|
|
@ -587,9 +604,6 @@ def _page_footer(
|
|||
font-size:{_FS_SM};font-weight:600;letter-spacing:0.28em;color:{_BODY_2};
|
||||
min-width:0;overflow-wrap:anywhere;">
|
||||
{_html.escape(brand.name).upper()}</span>
|
||||
<span style="font-size:7pt;letter-spacing:0.08em;color:{_MUTED_2};
|
||||
flex-shrink:0;white-space:nowrap;">
|
||||
{_REPORT_ENGINE_VERSION}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1050,6 +1064,19 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
|||
)
|
||||
report_num = _report_number(estimate)
|
||||
|
||||
# PR-D1: «Ссылка доступна до …» — срок жизни ОПЛАЧЕННОГО доступа
|
||||
# (retain_until), НЕ путать со «Срок действия данных» (expires_at,
|
||||
# актуальность расчёта) над ней — эта строка не трогается. Рендерится
|
||||
# ТОЛЬКО когда retain_until IS NOT NULL (неоплаченные — весь текущий
|
||||
# трафик — не видят этой строки вообще, поведение бит-в-бит текущее).
|
||||
retain_until_row = (
|
||||
f'<tr><td class="dotted-row">Ссылка доступна до</td>'
|
||||
f'<td class="bold dotted-row">'
|
||||
f"{_mono(estimate.retain_until.date().strftime('%d.%m.%Y'))}</td></tr>"
|
||||
if estimate.retain_until is not None
|
||||
else ""
|
||||
)
|
||||
|
||||
# Короткий адрес (для cover): берём первую часть до запятой
|
||||
full_address = input_snapshot.get("address", "—")
|
||||
address_short = full_address.split(",")[0:3]
|
||||
|
|
@ -1146,6 +1173,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
|||
<td class="bold dotted-row">{_mono(today.strftime("%d.%m.%Y"))}</td></tr>
|
||||
<tr><td class="dotted-row">Срок действия данных</td>
|
||||
<td class="bold dotted-row">до {_mono(expires.strftime("%d.%m.%Y"))}</td></tr>
|
||||
{retain_until_row}
|
||||
<tr><td class="dotted-row">Адрес</td><td class="bold dotted-row">{address}</td></tr>
|
||||
<tr><td class="dotted-row">Год постройки</td>
|
||||
<td class="bold dotted-row">{year_label}</td></tr>
|
||||
|
|
@ -1229,11 +1257,71 @@ def _deals_range(deals: list[AnalogLot], fallback: tuple[int, int]) -> tuple[int
|
|||
return min(prices), max(prices)
|
||||
|
||||
|
||||
def _deals_sourced_thin_listings_note_html(estimate: AggregatedEstimate) -> str:
|
||||
"""#pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): honest
|
||||
footnote for the specific case n_analogs==0 (headline ceded to the ДКП deals
|
||||
corridor, estimator.py `deals_headline_due_to_thin_listings`) BUT
|
||||
estimate.analogs is non-empty (the thin listings that triggered the cession
|
||||
are still shown below as reference cards — never cleared, see estimator.py
|
||||
#1871 ghost-anchor guard). Same tone/plain-sentence style as the web
|
||||
LowConfidenceBanner for this scenario. Empty string (no-op) otherwise —
|
||||
covers both "healthy sample" and "genuinely zero, nothing to show" cases."""
|
||||
if estimate.n_analogs != 0 or not estimate.analogs:
|
||||
return ""
|
||||
return (
|
||||
f'<p style="margin:6pt 0 0 0;font-size:{_FS_SM};color:{_MUTED};line-height:1.35;">'
|
||||
"Оценка построена по зарегистрированным сделкам Росреестра — подходящих "
|
||||
"объявлений поблизости почти нет. Объявления ниже приведены справочно, "
|
||||
"для наглядности рынка.</p>"
|
||||
)
|
||||
|
||||
|
||||
def _reliability_note_html(estimate: AggregatedEstimate, n_shown: int) -> str:
|
||||
"""#pdf-honesty: surfaces `AggregatedEstimate.relaxations`/`reliability`
|
||||
(estimator.py #oblast-F cascade + #oblast-E deals-priority) — the web report
|
||||
already shows this (LowConfidenceBanner); the PDF stayed silent, a
|
||||
client-visible discrepancy between the two. Empty string (no-op) when
|
||||
reliability=='ok' and relaxations is empty — the common, unrelaxed case,
|
||||
byte-identical to the report before these fields existed."""
|
||||
if estimate.reliability == "ok" and not estimate.relaxations:
|
||||
return ""
|
||||
if estimate.relaxations:
|
||||
detail = "Подбор аналогов расширен: " + ", ".join(
|
||||
_html.escape(r) for r in estimate.relaxations
|
||||
)
|
||||
else:
|
||||
# relaxations пуст, но reliability всё же не 'ok' (напр. тонкая выборка,
|
||||
# которую каскад ослаблений не смог расширить, см. estimator.py
|
||||
# #oblast-F) — n_shown, не сырой n_analogs (та же #pdf-honesty логика,
|
||||
# что и в счётчике выше страницы).
|
||||
detail = f"Оценка построена по небольшой выборке ({n_shown} шт.)"
|
||||
return f"""
|
||||
<div style="margin-top:10pt;padding:9pt 12pt;border-left:3pt solid {_WARN};
|
||||
background:{_ACCENT_2_SOFT};font-size:{_FS_SM};color:{_INK};line-height:1.35;">
|
||||
<span style="font-weight:700;color:{_WARN};">Точность оценки снижена.</span>
|
||||
{detail} — данные ниже приведены с этой оговоркой.
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
# ── Page 2: Listings (market) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
|
||||
n_total = estimate.n_analogs
|
||||
# #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): raw
|
||||
# estimate.n_analogs is the count of listings that drove the HEADLINE math —
|
||||
# it is deliberately 0 when the headline was ceded to the ДКП deals corridor
|
||||
# (estimator.py `deals_headline_due_to_thin_listings`), even though the thin
|
||||
# listings that triggered that cession are still shown below as display cards
|
||||
# (estimate.analogs — never cleared, see estimator.py #1871 ghost-anchor
|
||||
# guard comment). Printing raw n_analogs there read as "0 шт." above a
|
||||
# non-empty examples table — a client-visible contradiction. n_analogs is
|
||||
# normally >= len(analogs) (analogs is a top-10-capped SUBSET of what
|
||||
# n_analogs counts, see AnalogLot/AggregatedEstimate docstring) — max() is a
|
||||
# no-op in that common case (count stays the honest FULL n_analogs) and only
|
||||
# changes anything in this one pathological case, where it falls back to
|
||||
# "how many are actually shown" instead of the dishonest zero.
|
||||
n_total = max(estimate.n_analogs, len(estimate.analogs))
|
||||
# #1531: убрана строка-дубль «(с учётом ремонта)». Estimator НЕ фильтрует
|
||||
# аналоги по repair_state (coverage listings.repair_state ~2%, см. estimator.py:160),
|
||||
# а лишь применяет ценовой коэффициент к медиане/диапазону — поэтому отдельного
|
||||
|
|
@ -1292,6 +1380,10 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
|||
examples_rows = _examples_rows(top5)
|
||||
|
||||
heading_html = _section_heading("02", "РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ")
|
||||
# #pdf-honesty — see helper docstrings above. Both no-op ("") in the common
|
||||
# (unrelaxed, non-deals-sourced) case — byte-identical page in that case.
|
||||
deals_sourced_note = _deals_sourced_thin_listings_note_html(estimate)
|
||||
reliability_note = _reliability_note_html(estimate, n_total)
|
||||
|
||||
return f"""
|
||||
<div style="page-break-after:always;">
|
||||
|
|
@ -1306,6 +1398,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
|||
<tr><td style="padding:4pt 0;">Количество объявлений по аналогичным объектам</td>
|
||||
<td class="bold" style="text-align:right;">{_mono(f"{n_total} шт.")}</td></tr>
|
||||
</table>
|
||||
{deals_sourced_note}
|
||||
<div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">
|
||||
<span class="bullet-dot" style="margin-right:5pt;"></span>Источники данных</div>
|
||||
<div style="margin-top:6pt;overflow-wrap:anywhere;">{sources_html}</div>
|
||||
|
|
@ -1330,6 +1423,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
|||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{reliability_note}
|
||||
|
||||
<p style="margin:8pt 0 4pt 0;font-size:{_FS_MD};font-weight:700;">
|
||||
Диапазон цен в объявлениях</p>
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ WHAT this is:
|
|||
pipeline, run inside ONE transaction so a crash leaves the table untouched.
|
||||
|
||||
Cluster key: CANONICAL address via tradein_canon_addr() over the CLEAN address
|
||||
COALESCE(short_address, full_address, address) (cadastral_number is 100% NULL on prod —
|
||||
confirmed in migration 040 — so address is the real building key). The clean source matters:
|
||||
COALESCE(short_address, full_address, address) — the address is the only building key we
|
||||
have (why: the KEY section below; the older claim here, «cadastral_number is 100% NULL on
|
||||
prod», is no longer true — 2 648 of 9 179 rows carry one — and the conclusion no longer
|
||||
rests on it). The clean source matters:
|
||||
`address` can carry район-noise the canon does not strip (e.g. «улица Вайнера, 66 · р-н Центр»
|
||||
→ canon «вайнера66рнцентр»), while `short_address` holds the clean «улица Вайнера, 66»
|
||||
(→ «вайнера66») — preferring the clean field lets such a row cluster with its twin. The canon
|
||||
|
|
@ -92,6 +94,67 @@ BACKFILL (reduces recurrence):
|
|||
(same as 108) so the matching pipeline's Tier-1/Tier-2 finds the keeper next scrape and
|
||||
does not immediately re-split it.
|
||||
|
||||
MERGE JOURNAL — the merge is REVERSIBLE (#2690, migration 230):
|
||||
Every loser gets a row in `house_merge_log` written in the SAME transaction as the merge:
|
||||
the full jsonb snapshot of the deleted row, the keeper's snapshot BEFORE the identity
|
||||
carry-over, the ids of every child row whose FK moved, the full snapshots of every child row
|
||||
a UNIQUE collision destroyed, plus the grounds — which pass, which cluster-key VALUE fired,
|
||||
whether the geo guard was on, and the keeper↔loser distance in metres.
|
||||
|
||||
This exists because the merge used to leave no restorable trace: losers were hard-deleted
|
||||
with their children and the only record of «what went into what» was a log line, in a
|
||||
container whose logs rotate faster than a day. A day after a run nobody could even NAME the
|
||||
pairs, and the only rollback was restoring the whole database.
|
||||
|
||||
Undo: `SELECT * FROM house_merge_undo(batch_id)` inside a transaction — restores the loser
|
||||
rows, points the children back, re-inserts the destroyed children, and un-does the identity
|
||||
carry-over on the keeper, reporting per record what it could and could not restore.
|
||||
|
||||
NOTE the journal is deliberately NEUTRAL to the merge rule: it changes no cluster key, no
|
||||
keeper rule and no guard. It only makes whatever the pass decides reversible — which is the
|
||||
precondition for revisiting those decisions at all (#2690, #1772).
|
||||
|
||||
distance_m is recorded on BOTH passes, including the fias pass whose geo guard is off. That
|
||||
asymmetry — merge allowed without a proximity check — was invisible in data before; now
|
||||
«how many merges happened beyond N metres, on which key» is one query.
|
||||
|
||||
KEY — there is no second, address-independent observation. Measured on prod 2026-08-10 (#2690):
|
||||
#2690 asked for a cluster key that does not come from the normalized address, so that two
|
||||
rows merge on two independent statements of identity rather than one restated twice. Every
|
||||
field `houses` carries was checked against the live table. None qualifies:
|
||||
|
||||
cadastral_number 2 648 filled, ALL 2 648 values DISTINCT → collapses nothing. Provenance:
|
||||
all 2 648 also carry dadata_enriched_at and house_fias_id, i.e. they are
|
||||
DaData's answer to our address string, not a second observation of the
|
||||
building. (The other cadastre we hold, listings.building_cadastral_number,
|
||||
is the KNN geo-nearest hint — 20.1% of its values cover >1 ГАР building;
|
||||
#2674 refused it as an identity key and that stands.)
|
||||
house_fias_id 3 678 filled, ALL DISTINCT → the FIAS pass merges 0 rows today. Same
|
||||
DaData provenance.
|
||||
gar_house_guid the key #2690 rejected, re-measured: of 458 same-guid pairs, 441 share
|
||||
the canon (the guid restates it), 17 do not — and 5 of those 17 are
|
||||
>250 m apart, worst 5 064 km. Still circular, still noisy.
|
||||
zhkh_house_guid looks independent (ГИС ЖКХ is an external registry) and is not: the
|
||||
loader sets it WHERE gar_house_guid = <guid>, i.e. it IS the ГАР guid for
|
||||
4 268 of 4 663 rows. The 395 that differ come from the cadastre fallback
|
||||
— keyed by that same KNN hint. Of its 194 pairs with a DIFFERENT canon,
|
||||
193 come through the fallback, and 30 of the 31 pairs >250 m apart do too.
|
||||
source+ext_house_id, cian_internal_house_id, yandex_jk_id
|
||||
distinct by construction / 39 / 0 rows → nothing to cluster.
|
||||
coordinates a real independent observation, but not an IDENTITY: neighbours share a
|
||||
yard. It is already used the only way it can be — as the guard.
|
||||
year_built+total_floors
|
||||
a FALSE witness, not a corroborator: of the 391 same-canon pairs the
|
||||
guard cannot judge, only 18 agree on both fields (357 have a NULL), while
|
||||
306 pairs the guard rejected at >250 m DO agree — it would confirm merges
|
||||
that are provably wrong.
|
||||
|
||||
Conclusion: do NOT strengthen the key, and do not read the leftover as a backlog. What the
|
||||
canon key + 250 m guard reach IS the ceiling; what is left is counted, not queued — see the
|
||||
residual census (`_RESIDUAL_SQL`), whose buckets keep «the guard was silent» apart from «the
|
||||
guard rejected on the merits». Prod 2026-08-10, 963 excess rows: 568 of them are >250 m apart
|
||||
(median 1 084 m) — those are not duplicates at all, the canon key is wrong about them.
|
||||
|
||||
IDEMPOTENCY:
|
||||
Every UPDATE/DELETE keys off a temp mapping of (loser→keeper). On a clean table the
|
||||
mapping is empty → every statement touches 0 rows → no-op. Re-running is safe.
|
||||
|
|
@ -105,8 +168,10 @@ psycopg v3: all SQL uses CAST(:x AS type), never the colon-colon bound-param cas
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -132,9 +197,24 @@ _COMPLETENESS_EXPR = """
|
|||
|
||||
# Keeper ORDER BY, shared by the ROW_NUMBER() rank and the first_value() keeper pick so they
|
||||
# agree row-for-row. Priority: geom present → most linked listings → most-populated → min id.
|
||||
#
|
||||
# NULLS LAST на listing_cnt (#2674): счётчик приходит из LEFT JOIN listing_counts, поэтому у дома
|
||||
# БЕЗ объявлений он NULL, а `DESC` в Postgres по умолчанию NULLS FIRST — то есть строка с нулём
|
||||
# объявлений обгоняла строку со 192 и забирала роль keeper'а, ровно наоборот задокументированному
|
||||
# правилу. Последствие не косметическое: объявления проигравшего переезжают на запись, на которую
|
||||
# корпус никогда не ссылался, а COALESCE-перенос полей неполон (год постройки / тип дома /
|
||||
# этажность / застройщик не переносятся) — данные богатого проигравшего удаляются безвозвратно.
|
||||
#
|
||||
# ПРОВЕРЕНО ЗАДНИМ ЧИСЛОМ (#2690 п.3, 2026-08-10): первый прогон на исправленном правиле —
|
||||
# 08.08, 821 слияние — разобран по house_merge_log (у проигравшего число объявлений = длина
|
||||
# children_repointed['listings.house_id_fk'], у победителя — что висело на нём до слияния).
|
||||
# Слияний, где победитель беднее проигравшего по объявлениям: 0 из 821. Контрфактика старого
|
||||
# правила на тех же кластерах: 6 из 762 забрали бы пустого победителя (8 объявлений). Мерить
|
||||
# «победителя до слияния» по listings.scraped_at НЕЛЬЗЯ — #2206 двигает его при каждом
|
||||
# ре-подтверждении, отчего появляются 207 несуществующих «худших победителей».
|
||||
_KEEPER_ORDER = f"""
|
||||
(h.geom IS NOT NULL) DESC,
|
||||
listing_cnt DESC,
|
||||
listing_cnt DESC NULLS LAST,
|
||||
({_COMPLETENESS_EXPR}) DESC,
|
||||
h.id ASC
|
||||
"""
|
||||
|
|
@ -166,37 +246,17 @@ _CANON_KEY_EXPR = """
|
|||
"""
|
||||
|
||||
|
||||
def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
|
||||
"""Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression.
|
||||
def _ranked_cte(cluster_key_case: str) -> str:
|
||||
"""Render the `WITH … ranked AS (…)` prelude: cluster → rank → expose the keeper per row.
|
||||
|
||||
Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers
|
||||
are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered
|
||||
row shares one fias by construction).
|
||||
|
||||
apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True.
|
||||
- CANON pass → True: the canon strips город/район, so same-street-number buildings in
|
||||
different region-66 towns share a canon; the guard stops the cross-town over-merge.
|
||||
- FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks
|
||||
proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the
|
||||
geom-first keeper rule simultaneously repairs the broken coordinate).
|
||||
Shared verbatim by the merge mapping (`_mapping_sql`) and the residual census
|
||||
(`_RESIDUAL_SQL`) so the census counts EXACTLY the rows the merge reasons about — a census
|
||||
built from its own copy of the clustering would drift from the pass it describes and the
|
||||
drift would be invisible (it is the same class of error as #2690's cluster key: two
|
||||
expressions that look alike and are not).
|
||||
`cluster_key_case` is a STATIC module constant (never runtime data) — no value injection.
|
||||
"""
|
||||
geo_guard = (
|
||||
"""
|
||||
-- GEO GUARD (canon pass only — #2187). tradein_canon_addr strips город/район, so two
|
||||
-- different buildings sharing a street+number canon («Ленина 5» in different region-66
|
||||
-- towns) collapse to one cluster_key. A loser merges only when geographically next to the
|
||||
-- keeper (<=250 m — covers one building's geocode spread, prod: Мраморская 34к4 dupes at
|
||||
-- 222 m; region-66 towns are km+ apart → 250 m is safe from cross-town). >250 m, or NULL
|
||||
-- geom on either side, → left as separate rows (conservative — never over-merges).
|
||||
AND keeper_geom IS NOT NULL
|
||||
AND loser_geom IS NOT NULL
|
||||
AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250"""
|
||||
if apply_geo_guard
|
||||
else ""
|
||||
)
|
||||
return f"""
|
||||
CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS
|
||||
WITH clustered AS (
|
||||
SELECT
|
||||
id,
|
||||
|
|
@ -248,12 +308,55 @@ def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
|
|||
FROM dup_houses dh
|
||||
JOIN houses h ON h.id = dh.id
|
||||
LEFT JOIN listing_counts lc ON lc.house_id = dh.id
|
||||
)"""
|
||||
|
||||
|
||||
def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str:
|
||||
"""Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression.
|
||||
|
||||
Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers
|
||||
are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered
|
||||
row shares one fias by construction).
|
||||
|
||||
apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True.
|
||||
- CANON pass → True: the canon strips город/район, so same-street-number buildings in
|
||||
different region-66 towns share a canon; the guard stops the cross-town over-merge.
|
||||
- FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks
|
||||
proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the
|
||||
geom-first keeper rule simultaneously repairs the broken coordinate).
|
||||
`cluster_key_case` is a STATIC module constant (never runtime data) — no value injection.
|
||||
"""
|
||||
geo_guard = (
|
||||
"""
|
||||
-- GEO GUARD (canon pass only — #2187). tradein_canon_addr strips город/район, so two
|
||||
-- different buildings sharing a street+number canon («Ленина 5» in different region-66
|
||||
-- towns) collapse to one cluster_key. A loser merges only when geographically next to the
|
||||
-- keeper (<=250 m — covers one building's geocode spread, prod: Мраморская 34к4 dupes at
|
||||
-- 222 m; region-66 towns are km+ apart → 250 m is safe from cross-town). >250 m, or NULL
|
||||
-- geom on either side, → left as separate rows (conservative — never over-merges).
|
||||
AND keeper_geom IS NOT NULL
|
||||
AND loser_geom IS NOT NULL
|
||||
AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250"""
|
||||
if apply_geo_guard
|
||||
else ""
|
||||
)
|
||||
return f"""
|
||||
CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS
|
||||
{_ranked_cte(cluster_key_case)}
|
||||
-- CROSS-FIAS guard (#1772 follow-up): never merge two rows that BOTH carry a non-null but
|
||||
-- DIFFERENT house_fias_id — provably different buildings the cluster key collapsed (canon
|
||||
-- slash-collapse «Сулимова, 32»/«Сулимова, 3/2»). No-op for the fias pass (one fias per
|
||||
-- cluster) and for canon clusters where at most one side carries a fias.
|
||||
SELECT id AS loser_id, keeper_id, norm_address
|
||||
--
|
||||
-- cluster_key / distance_m are carried out of the mapping for the MERGE JOURNAL (#2690):
|
||||
-- cluster_key records WHICH key value fired, distance_m how far apart the two rows were.
|
||||
-- distance_m is computed even when the geo guard is OFF for this pass — that is precisely
|
||||
-- the case where nothing else records the distance, and #2690 had no way to ask
|
||||
-- «how many merges happened at distances the guard would have blocked» from data.
|
||||
SELECT id AS loser_id, keeper_id, norm_address, cluster_key,
|
||||
CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL
|
||||
THEN ST_DistanceSphere(loser_geom, keeper_geom)
|
||||
END AS distance_m
|
||||
FROM ranked
|
||||
WHERE rn > 1
|
||||
AND id <> keeper_id{geo_guard}
|
||||
|
|
@ -272,6 +375,54 @@ _BUILD_MAPPING_SQL = text(_mapping_sql(_CANON_KEY_EXPR))
|
|||
# merge even with NULL geom or >250 m apart (the geom-first keeper rule fixes broken coords).
|
||||
_BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR, apply_geo_guard=False))
|
||||
|
||||
# ── RESIDUAL CENSUS (#2690 п.2/п.4) ───────────────────────────────────────────
|
||||
#
|
||||
# Read-only, run AFTER both passes: how many same-canon rows the merge LEFT BEHIND, and WHY.
|
||||
# Same `ranked` prelude as the canon mapping, minus the guard — so every row the guard filtered
|
||||
# out is counted here, bucketed by the reason it survived.
|
||||
#
|
||||
# WHY this exists. #2690 asked for a second, address-independent key; measured 2026-08-10, there
|
||||
# is none (see the KEY section in the module docstring), so the remainder is a CEILING, not a
|
||||
# backlog — and a ceiling has to be a live number, not a one-off. The one-off rots fast: the
|
||||
# issue's own census (781 excess rows, 06.08) was 963 four days later, after a run deleted 821.
|
||||
#
|
||||
# The buckets are deliberately NOT summed into one «остаток». «Guard was silent» and «guard
|
||||
# rejected» are opposite facts:
|
||||
# residual_no_geom — one side has no coordinates: the guard could not speak. UNKNOWN.
|
||||
# residual_far — both geocoded, >250 m apart: the guard spoke on the merits. These are
|
||||
# NOT duplicates — the canon key is wrong about them (prod 2026-08-10:
|
||||
# 568 rows, median 1084 m). Counting them as «дубли» inflates the debt.
|
||||
# residual_cross_fias — provably different buildings (two different ФИАС UUIDs).
|
||||
# residual_mergeable — passes every guard and STILL was not merged. Must be 0 after a real
|
||||
# run; non-zero is a tripwire on the pass itself, not a census entry.
|
||||
# residual_listings is the user-visible size of the remainder (listings hanging on those rows).
|
||||
_RESIDUAL_SQL = text(
|
||||
f"""
|
||||
{_ranked_cte(_CANON_KEY_EXPR)}
|
||||
SELECT
|
||||
count(*) FILTER (WHERE rn > 1) AS residual_rows,
|
||||
COALESCE(sum(lcnt) FILTER (WHERE rn > 1), 0) AS residual_listings,
|
||||
count(*) FILTER (WHERE rn > 1 AND cross_fias) AS residual_cross_fias,
|
||||
count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist IS NULL)
|
||||
AS residual_no_geom,
|
||||
count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist > 250) AS residual_far,
|
||||
count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist <= 250)
|
||||
AS residual_mergeable
|
||||
FROM (
|
||||
SELECT rn,
|
||||
COALESCE(lc.listing_cnt, 0) AS lcnt,
|
||||
CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL
|
||||
THEN ST_DistanceSphere(loser_geom, keeper_geom)
|
||||
END AS dist,
|
||||
(NULLIF(loser_fias, '') IS NOT NULL
|
||||
AND NULLIF(keeper_fias, '') IS NOT NULL
|
||||
AND lower(loser_fias) <> lower(keeper_fias)) AS cross_fias
|
||||
FROM ranked
|
||||
LEFT JOIN listing_counts lc ON lc.house_id = ranked.id
|
||||
) r
|
||||
"""
|
||||
)
|
||||
|
||||
# Each step keys off _1772_dup_mapping → empty mapping ⇒ 0 rows touched ⇒ idempotent no-op.
|
||||
_STEPS: list[tuple[str, str]] = [
|
||||
# ── Plain re-point (no UNIQUE on the FK column) ───────────────────────────
|
||||
|
|
@ -282,6 +433,7 @@ _STEPS: list[tuple[str, str]] = [
|
|||
SET house_id_fk = m.keeper_id
|
||||
FROM _1772_dup_mapping m
|
||||
WHERE l.house_id_fk = m.loser_id
|
||||
RETURNING m.loser_id, l.id AS child_id
|
||||
""",
|
||||
),
|
||||
(
|
||||
|
|
@ -291,6 +443,7 @@ _STEPS: list[tuple[str, str]] = [
|
|||
SET house_id = m.keeper_id
|
||||
FROM _1772_dup_mapping m
|
||||
WHERE hph.house_id = m.loser_id
|
||||
RETURNING m.loser_id, hph.id AS child_id
|
||||
""",
|
||||
),
|
||||
(
|
||||
|
|
@ -300,6 +453,7 @@ _STEPS: list[tuple[str, str]] = [
|
|||
SET house_id = m.keeper_id
|
||||
FROM _1772_dup_mapping m
|
||||
WHERE hr.house_id = m.loser_id
|
||||
RETURNING m.loser_id, hr.id AS child_id
|
||||
""",
|
||||
),
|
||||
(
|
||||
|
|
@ -309,6 +463,7 @@ _STEPS: list[tuple[str, str]] = [
|
|||
SET house_id = m.keeper_id
|
||||
FROM _1772_dup_mapping m
|
||||
WHERE hrc.house_id = m.loser_id
|
||||
RETURNING m.loser_id, hrc.id AS child_id
|
||||
""",
|
||||
),
|
||||
(
|
||||
|
|
@ -318,6 +473,7 @@ _STEPS: list[tuple[str, str]] = [
|
|||
SET house_id = m.keeper_id
|
||||
FROM _1772_dup_mapping m
|
||||
WHERE ev.house_id = m.loser_id
|
||||
RETURNING m.loser_id, ev.id AS child_id
|
||||
""",
|
||||
),
|
||||
# ── UNIQUE(ext_source, ext_id): delete colliding losers, re-point rest ─────
|
||||
|
|
@ -333,6 +489,7 @@ _STEPS: list[tuple[str, str]] = [
|
|||
AND hs2.ext_source = hs.ext_source
|
||||
AND hs2.ext_id = hs.ext_id
|
||||
)
|
||||
RETURNING hs.house_id AS loser_id, to_jsonb(hs.*) AS row_snapshot
|
||||
""",
|
||||
),
|
||||
(
|
||||
|
|
@ -342,6 +499,7 @@ _STEPS: list[tuple[str, str]] = [
|
|||
SET house_id = m.keeper_id
|
||||
FROM _1772_dup_mapping m
|
||||
WHERE hs.house_id = m.loser_id
|
||||
RETURNING m.loser_id, hs.id AS child_id
|
||||
""",
|
||||
),
|
||||
# ── UNIQUE(normalized_address): delete colliding losers, re-point rest ─────
|
||||
|
|
@ -356,6 +514,7 @@ _STEPS: list[tuple[str, str]] = [
|
|||
WHERE haa2.house_id = m.keeper_id
|
||||
AND haa2.normalized_address = haa.normalized_address
|
||||
)
|
||||
RETURNING haa.house_id AS loser_id, to_jsonb(haa.*) AS row_snapshot
|
||||
""",
|
||||
),
|
||||
(
|
||||
|
|
@ -365,6 +524,7 @@ _STEPS: list[tuple[str, str]] = [
|
|||
SET house_id = m.keeper_id
|
||||
FROM _1772_dup_mapping m
|
||||
WHERE haa.house_id = m.loser_id
|
||||
RETURNING m.loser_id, haa.id AS child_id
|
||||
""",
|
||||
),
|
||||
# ── UNIQUE(house_id, source, room_count, prices_type, period, month_date) ──
|
||||
|
|
@ -386,6 +546,7 @@ _STEPS: list[tuple[str, str]] = [
|
|||
LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id
|
||||
) d
|
||||
WHERE t.id = d.id AND d.rn > 1
|
||||
RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot
|
||||
""",
|
||||
),
|
||||
(
|
||||
|
|
@ -395,6 +556,7 @@ _STEPS: list[tuple[str, str]] = [
|
|||
SET house_id = m.keeper_id
|
||||
FROM _1772_dup_mapping m
|
||||
WHERE hpd.house_id = m.loser_id
|
||||
RETURNING m.loser_id, hpd.id AS child_id
|
||||
""",
|
||||
),
|
||||
# ── UNIQUE(house_id): one evaluation per keeper ───────────────────────────
|
||||
|
|
@ -412,6 +574,7 @@ _STEPS: list[tuple[str, str]] = [
|
|||
LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id
|
||||
) d
|
||||
WHERE t.id = d.id AND d.rn > 1
|
||||
RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot
|
||||
""",
|
||||
),
|
||||
(
|
||||
|
|
@ -421,6 +584,7 @@ _STEPS: list[tuple[str, str]] = [
|
|||
SET house_id = m.keeper_id
|
||||
FROM _1772_dup_mapping m
|
||||
WHERE hie.house_id = m.loser_id
|
||||
RETURNING m.loser_id, hie.id AS child_id
|
||||
""",
|
||||
),
|
||||
# ── UNIQUE(house_id, ext_item_id) ─────────────────────────────────────────
|
||||
|
|
@ -438,6 +602,7 @@ _STEPS: list[tuple[str, str]] = [
|
|||
LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id
|
||||
) d
|
||||
WHERE t.id = d.id AND d.rn > 1
|
||||
RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot
|
||||
""",
|
||||
),
|
||||
(
|
||||
|
|
@ -447,6 +612,7 @@ _STEPS: list[tuple[str, str]] = [
|
|||
SET house_id = m.keeper_id
|
||||
FROM _1772_dup_mapping m
|
||||
WHERE hs.house_id = m.loser_id
|
||||
RETURNING m.loser_id, hs.id AS child_id
|
||||
""",
|
||||
),
|
||||
# ── UNIQUE(house_id, audit_batch) ─────────────────────────────────────────
|
||||
|
|
@ -464,6 +630,7 @@ _STEPS: list[tuple[str, str]] = [
|
|||
LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id
|
||||
) d
|
||||
WHERE t.id = d.id AND d.rn > 1
|
||||
RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot
|
||||
""",
|
||||
),
|
||||
(
|
||||
|
|
@ -473,10 +640,98 @@ _STEPS: list[tuple[str, str]] = [
|
|||
SET house_id = m.keeper_id
|
||||
FROM _1772_dup_mapping m
|
||||
WHERE ama.house_id = m.loser_id
|
||||
RETURNING m.loser_id, ama.id AS child_id
|
||||
""",
|
||||
),
|
||||
]
|
||||
|
||||
# ── MERGE JOURNAL (#2690) ─────────────────────────────────────────────────────
|
||||
#
|
||||
# Every child of houses(id) except `listings` references it through a column named house_id;
|
||||
# listings uses house_id_fk. The undo function reads the column name back out of the journal
|
||||
# key ("таблица.колонка"), so this mapping is what makes the reverse UPDATE possible.
|
||||
_FK_COLUMN = {"listings": "house_id_fk"}
|
||||
|
||||
# The (table, column) pairs the _STEPS pipeline actually handles, derived FROM the steps so the
|
||||
# set cannot drift away from them. Compared against pg_catalog before every merge — see
|
||||
# _assert_all_fk_children_handled.
|
||||
_HANDLED_CHILDREN: frozenset[tuple[str, str]] = frozenset(
|
||||
(tbl, _FK_COLUMN.get(tbl, "house_id")) for tbl in {label.split("(")[0] for label, _ in _STEPS}
|
||||
)
|
||||
|
||||
# Live FK children of houses(id), read from the catalog rather than trusted from a comment.
|
||||
_FK_CHILDREN_SQL = text(
|
||||
"""
|
||||
SELECT CAST(CAST(c.conrelid AS regclass) AS text) AS child_table,
|
||||
a.attname AS fk_column
|
||||
FROM pg_constraint c
|
||||
JOIN unnest(c.conkey) AS k(attnum) ON true
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
|
||||
WHERE c.confrelid = CAST('houses' AS regclass)
|
||||
AND c.contype = 'f'
|
||||
"""
|
||||
)
|
||||
|
||||
# One journal row per loser, written from the mapping BEFORE anything is mutated — so loser_row
|
||||
# is the row as it stood, and keeper_before precedes the identity carry-over.
|
||||
_JOURNAL_INSERT_SQL = text(
|
||||
"""
|
||||
INSERT INTO house_merge_log (
|
||||
batch_id, run_id, initiator, merge_pass, cluster_key, geo_guard, distance_m,
|
||||
norm_address, loser_id, keeper_id, loser_row, keeper_before
|
||||
)
|
||||
SELECT
|
||||
CAST(:batch_id AS uuid),
|
||||
CAST(:run_id AS bigint),
|
||||
CAST(:initiator AS text),
|
||||
CAST(:merge_pass AS text),
|
||||
m.cluster_key,
|
||||
CAST(:geo_guard AS boolean),
|
||||
m.distance_m,
|
||||
m.norm_address,
|
||||
m.loser_id,
|
||||
m.keeper_id,
|
||||
to_jsonb(l.*),
|
||||
to_jsonb(k.*)
|
||||
FROM _1772_dup_mapping m
|
||||
JOIN houses l ON l.id = m.loser_id
|
||||
JOIN houses k ON k.id = m.keeper_id
|
||||
"""
|
||||
)
|
||||
|
||||
# Child bookkeeping lands after the steps ran — only then is it known which rows moved and which
|
||||
# were destroyed by a UNIQUE collision.
|
||||
_JOURNAL_CHILDREN_SQL = text(
|
||||
"""
|
||||
UPDATE house_merge_log
|
||||
SET children_repointed = CAST(:children_repointed AS jsonb),
|
||||
children_deleted = CAST(:children_deleted AS jsonb)
|
||||
WHERE batch_id = CAST(:batch_id AS uuid)
|
||||
AND loser_id = CAST(:loser_id AS bigint)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _assert_all_fk_children_handled(db: Session) -> None:
|
||||
"""Fail the merge if houses(id) gained an FK child the _STEPS pipeline does not handle.
|
||||
|
||||
This is what makes the journal's promise true rather than merely documented. An unhandled
|
||||
child is not a cosmetic gap: 9 of the 11 FKs are ON DELETE CASCADE, so `DELETE FROM houses`
|
||||
would destroy its rows silently — no re-point step touches them, no RETURNING records them,
|
||||
and the journal would claim a complete snapshot it does not have. Migration 133 already
|
||||
broke on prod for exactly this (a missed child); there the failure was loud. Here it would
|
||||
be silent, which is worse. Aborting the transaction costs one skipped merge cycle.
|
||||
"""
|
||||
live = {(r.child_table, r.fk_column) for r in db.execute(_FK_CHILDREN_SQL).all()}
|
||||
unhandled = live - _HANDLED_CHILDREN
|
||||
if unhandled:
|
||||
raise RuntimeError(
|
||||
"merge_duplicate_houses: houses(id) has FK children the merge does not handle: "
|
||||
f"{sorted(unhandled)}. Their rows would be CASCADE-deleted without a journal entry. "
|
||||
"Add a re-point step to _STEPS (and its RETURNING) before merging again."
|
||||
)
|
||||
|
||||
|
||||
# Delete the loser houses — all FK children are re-pointed or CASCADE by now.
|
||||
_DELETE_LOSERS_SQL = text(
|
||||
"""
|
||||
|
|
@ -580,6 +835,15 @@ class DedupMergeResult:
|
|||
listings_repointed: int = 0 # listings.house_id_fk moved loser→keeper
|
||||
children_deleted: int = 0 # collision/dedup deletions across all UNIQUE children
|
||||
children_repointed: int = 0 # survivor child rows moved loser→keeper
|
||||
# Residual census (#2690): same-canon rows STILL in the table after this run, by reason.
|
||||
# Not a backlog — measured 2026-08-10 there is no address-independent key to shrink it with,
|
||||
# so this is the ceiling of what this pass can reach. See _RESIDUAL_SQL.
|
||||
residual_rows: int = 0 # excess same-canon rows left behind (sum of the three buckets)
|
||||
residual_listings: int = 0 # listings hanging on them (the user-visible size)
|
||||
residual_no_geom: int = 0 # guard was SILENT — one side has no coordinates
|
||||
residual_far: int = 0 # guard SPOKE — >250 m apart, i.e. not the same building
|
||||
residual_cross_fias: int = 0 # two different ФИАС UUIDs — provably different buildings
|
||||
residual_mergeable: int = 0 # passed every guard and still unmerged — TRIPWIRE, expect 0
|
||||
dry_run: bool = False
|
||||
duration_sec: float = field(default=0.0)
|
||||
|
||||
|
|
@ -590,6 +854,12 @@ class DedupMergeResult:
|
|||
"listings_repointed": self.listings_repointed,
|
||||
"children_deleted": self.children_deleted,
|
||||
"children_repointed": self.children_repointed,
|
||||
"residual_rows": self.residual_rows,
|
||||
"residual_listings": self.residual_listings,
|
||||
"residual_no_geom": self.residual_no_geom,
|
||||
"residual_far": self.residual_far,
|
||||
"residual_cross_fias": self.residual_cross_fias,
|
||||
"residual_mergeable": self.residual_mergeable,
|
||||
"dry_run": int(self.dry_run),
|
||||
"duration_sec": int(self.duration_sec),
|
||||
}
|
||||
|
|
@ -600,14 +870,20 @@ def _run_merge_pass(
|
|||
*,
|
||||
build_sql: Any,
|
||||
pass_label: str,
|
||||
geo_guard: bool,
|
||||
batch_id: str,
|
||||
run_id: int | None,
|
||||
initiator: str,
|
||||
result: DedupMergeResult,
|
||||
) -> None:
|
||||
"""Run ONE merge pass (fias- or canon-key) inside the caller's open transaction.
|
||||
|
||||
Builds a fresh loser→keeper mapping for this pass's cluster key, re-points every FK child
|
||||
(UNIQUE-collision-safe), carries identity/enrichment onto the keeper, deletes the losers and
|
||||
backfills sources/aliases. Accumulates counters onto `result`. NEVER commits/rolls back — the
|
||||
caller owns the single transaction wrapping both passes.
|
||||
Builds a fresh loser→keeper mapping for this pass's cluster key, writes the MERGE JOURNAL
|
||||
(#2690), re-points every FK child (UNIQUE-collision-safe), carries identity/enrichment onto
|
||||
the keeper, deletes the losers and backfills sources/aliases. Accumulates counters onto
|
||||
`result`. NEVER commits/rolls back — the caller owns the single transaction wrapping both
|
||||
passes, which is also what makes the journal atomic with the merge: there is no ordering in
|
||||
which the rows vanish but the journal entry does not land (and dry_run rolls back both).
|
||||
"""
|
||||
# Fresh mapping for this pass. ON COMMIT DROP only fires at txn end, so drop the temp table
|
||||
# explicitly — the second pass must rebuild the same-named table within the one transaction.
|
||||
|
|
@ -616,8 +892,8 @@ def _run_merge_pass(
|
|||
|
||||
mapping = db.execute(
|
||||
text(
|
||||
"SELECT loser_id, keeper_id, norm_address FROM _1772_dup_mapping "
|
||||
"ORDER BY keeper_id, loser_id"
|
||||
"SELECT loser_id, keeper_id, norm_address, cluster_key, distance_m "
|
||||
"FROM _1772_dup_mapping ORDER BY keeper_id, loser_id"
|
||||
)
|
||||
).all()
|
||||
if not mapping:
|
||||
|
|
@ -627,32 +903,73 @@ def _run_merge_pass(
|
|||
result.losers_deleted += len(mapping)
|
||||
result.clusters_merged += len({row.keeper_id for row in mapping})
|
||||
|
||||
# Audit log: every loser→keeper move with its address, for traceability.
|
||||
# JOURNAL, phase 1 — snapshot loser + keeper BEFORE any statement mutates them.
|
||||
db.execute(
|
||||
_JOURNAL_INSERT_SQL,
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"run_id": run_id,
|
||||
"initiator": initiator,
|
||||
"merge_pass": pass_label,
|
||||
"geo_guard": geo_guard,
|
||||
},
|
||||
)
|
||||
|
||||
# Container logs rotate faster than a day (#2690), so this line is a convenience, not the
|
||||
# record — house_merge_log is. Distance is logged too: it is the one number that says
|
||||
# whether a merge would have survived the geo guard.
|
||||
for row in mapping:
|
||||
logger.info(
|
||||
"merge_duplicate_houses: pass=%s merge loser_id=%d → keeper_id=%d address=%r",
|
||||
"merge_duplicate_houses: pass=%s merge loser_id=%d → keeper_id=%d address=%r "
|
||||
"distance_m=%s batch=%s",
|
||||
pass_label,
|
||||
row.loser_id,
|
||||
row.keeper_id,
|
||||
row.norm_address,
|
||||
"n/a" if row.distance_m is None else f"{row.distance_m:.0f}",
|
||||
batch_id,
|
||||
)
|
||||
|
||||
# Per-loser child bookkeeping, collected from each step's RETURNING: survivors by id (the
|
||||
# rows are intact, only their FK moved), destroyed rows by full snapshot (nothing else is
|
||||
# left of them).
|
||||
repointed: dict[int, dict[str, list[int]]] = {}
|
||||
deleted: dict[int, dict[str, list[Any]]] = {}
|
||||
|
||||
for label, sql in _STEPS:
|
||||
res = db.execute(text(sql))
|
||||
rowcount = res.rowcount or 0
|
||||
if label == "listings":
|
||||
result.listings_repointed += rowcount
|
||||
elif label.endswith("(collision-delete)") or label.endswith("(dedup)"):
|
||||
rows = db.execute(text(sql)).all()
|
||||
rowcount = len(rows)
|
||||
table = label.split("(")[0]
|
||||
if label.endswith("(collision-delete)") or label.endswith("(dedup)"):
|
||||
result.children_deleted += rowcount
|
||||
elif label.endswith("(re-point)") or label in (
|
||||
"house_placement_history",
|
||||
"house_reviews",
|
||||
"house_reliability_checks",
|
||||
"external_valuations",
|
||||
):
|
||||
result.children_repointed += rowcount
|
||||
for r in rows:
|
||||
deleted.setdefault(r.loser_id, {}).setdefault(table, []).append(r.row_snapshot)
|
||||
else:
|
||||
key = f"{table}.{_FK_COLUMN.get(table, 'house_id')}"
|
||||
for r in rows:
|
||||
repointed.setdefault(r.loser_id, {}).setdefault(key, []).append(r.child_id)
|
||||
if label == "listings":
|
||||
result.listings_repointed += rowcount
|
||||
else:
|
||||
result.children_repointed += rowcount
|
||||
logger.debug("merge_duplicate_houses: pass=%s step=%s rows=%d", pass_label, label, rowcount)
|
||||
|
||||
# JOURNAL, phase 2 — attach the child bookkeeping to the rows written in phase 1.
|
||||
touched = sorted(set(repointed) | set(deleted))
|
||||
if touched:
|
||||
db.execute(
|
||||
_JOURNAL_CHILDREN_SQL,
|
||||
[
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"loser_id": loser_id,
|
||||
"children_repointed": json.dumps(repointed.get(loser_id, {})),
|
||||
"children_deleted": json.dumps(deleted.get(loser_id, {}), default=str),
|
||||
}
|
||||
for loser_id in touched
|
||||
],
|
||||
)
|
||||
|
||||
# Carry identity/enrichment onto the keeper BEFORE the losers vanish, then delete + backfill.
|
||||
db.execute(_CARRY_OVER_IDENTITY_SQL)
|
||||
db.execute(_DELETE_LOSERS_SQL)
|
||||
|
|
@ -660,7 +977,56 @@ def _run_merge_pass(
|
|||
db.execute(_BACKFILL_ALIASES_SQL)
|
||||
|
||||
|
||||
def merge_duplicate_houses(db: Session, *, dry_run: bool = False) -> dict[str, int]:
|
||||
def _measure_residual(db: Session, result: DedupMergeResult) -> None:
|
||||
"""Count the same-canon rows this run did NOT merge, bucketed by the reason (#2690).
|
||||
|
||||
Read-only; runs after both passes, so it describes the table as the run leaves it (under
|
||||
dry_run it sees the not-yet-rolled-back state, which is the correct preview). Kept out of
|
||||
`_run_merge_pass` because the census is about the CANON key only and must be taken once per
|
||||
call, not once per pass.
|
||||
|
||||
Never fails the merge: the merge itself is the product, the census is instrumentation, and a
|
||||
census that can abort a committed-by-now transaction would be worse than a missing number.
|
||||
"""
|
||||
try:
|
||||
rows = db.execute(_RESIDUAL_SQL).all()
|
||||
except Exception:
|
||||
logger.exception("merge_duplicate_houses: residual census failed — counters left at 0")
|
||||
return
|
||||
if not rows:
|
||||
return
|
||||
r = rows[0]
|
||||
result.residual_rows = int(r.residual_rows or 0)
|
||||
result.residual_listings = int(r.residual_listings or 0)
|
||||
result.residual_no_geom = int(r.residual_no_geom or 0)
|
||||
result.residual_far = int(r.residual_far or 0)
|
||||
result.residual_cross_fias = int(r.residual_cross_fias or 0)
|
||||
result.residual_mergeable = int(r.residual_mergeable or 0)
|
||||
logger.info(
|
||||
"merge_duplicate_houses: residual rows=%d listings=%d "
|
||||
"(страж молчит=%d · страж отверг >250м=%d · cross-fias=%d · сливаемых=%d)",
|
||||
result.residual_rows,
|
||||
result.residual_listings,
|
||||
result.residual_no_geom,
|
||||
result.residual_far,
|
||||
result.residual_cross_fias,
|
||||
result.residual_mergeable,
|
||||
)
|
||||
if result.residual_mergeable:
|
||||
logger.warning(
|
||||
"merge_duplicate_houses: %d rows pass every guard yet were NOT merged — the pass "
|
||||
"left work on the table (expected 0)",
|
||||
result.residual_mergeable,
|
||||
)
|
||||
|
||||
|
||||
def merge_duplicate_houses(
|
||||
db: Session,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
run_id: int | None = None,
|
||||
initiator: str = "manual",
|
||||
) -> dict[str, int]:
|
||||
"""Cluster houses by fias UUID, then by canonical address, merging dups onto one keeper.
|
||||
|
||||
Re-implements migration 108's proven collision-safe pipeline as a RECURRING TWO-PASS job:
|
||||
|
|
@ -673,16 +1039,45 @@ def merge_duplicate_houses(db: Session, *, dry_run: bool = False) -> dict[str, i
|
|||
dry_run=True computes counts then ROLLS BACK (no writes). Idempotent: a clean table yields an
|
||||
empty mapping in each pass → every statement is a 0-row no-op.
|
||||
|
||||
Every deleted row is journaled to house_merge_log in the SAME transaction (#2690), so a
|
||||
merge is reversible via house_merge_undo(batch_id); the batch_id is returned in the log line
|
||||
and stored on every journal row of this call.
|
||||
|
||||
Returns the counter dict (DedupMergeResult.to_counters()).
|
||||
"""
|
||||
start = time.monotonic()
|
||||
result = DedupMergeResult(dry_run=dry_run)
|
||||
batch_id = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
# Refuse to merge at all if some FK child would be CASCADE-destroyed unjournaled.
|
||||
_assert_all_fk_children_handled(db)
|
||||
# Pass 1: cluster by the ФИАС building UUID (runs first — most precise building identity).
|
||||
_run_merge_pass(db, build_sql=_BUILD_MAPPING_SQL_FIAS, pass_label="fias", result=result)
|
||||
_run_merge_pass(
|
||||
db,
|
||||
build_sql=_BUILD_MAPPING_SQL_FIAS,
|
||||
pass_label="fias",
|
||||
geo_guard=False,
|
||||
batch_id=batch_id,
|
||||
run_id=run_id,
|
||||
initiator=initiator,
|
||||
result=result,
|
||||
)
|
||||
# Pass 2: cluster by canonical address, with the cross-fias anti-over-merge guard.
|
||||
_run_merge_pass(db, build_sql=_BUILD_MAPPING_SQL, pass_label="canon", result=result)
|
||||
_run_merge_pass(
|
||||
db,
|
||||
build_sql=_BUILD_MAPPING_SQL,
|
||||
pass_label="canon",
|
||||
geo_guard=True,
|
||||
batch_id=batch_id,
|
||||
run_id=run_id,
|
||||
initiator=initiator,
|
||||
result=result,
|
||||
)
|
||||
|
||||
# Census of what is LEFT (read-only). Runs before the no-op early return on purpose:
|
||||
# a run that merged nothing is exactly the run whose remainder needs a number.
|
||||
_measure_residual(db, result)
|
||||
|
||||
if result.losers_deleted == 0:
|
||||
# Clean table — both passes empty. Roll back (we only opened temp tables).
|
||||
|
|
@ -709,12 +1104,15 @@ def merge_duplicate_houses(db: Session, *, dry_run: bool = False) -> dict[str, i
|
|||
db.commit()
|
||||
logger.info(
|
||||
"merge_duplicate_houses: COMMITTED clusters=%d losers=%d "
|
||||
"listings_repointed=%d children_deleted=%d children_repointed=%d",
|
||||
"listings_repointed=%d children_deleted=%d children_repointed=%d "
|
||||
"batch_id=%s (undo: SELECT * FROM house_merge_undo('%s'))",
|
||||
result.clusters_merged,
|
||||
result.losers_deleted,
|
||||
result.listings_repointed,
|
||||
result.children_deleted,
|
||||
result.children_repointed,
|
||||
batch_id,
|
||||
batch_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("merge_duplicate_houses: FAILED — rolling back")
|
||||
|
|
@ -754,7 +1152,7 @@ def run_house_dedup_merge(db: Session, *, run_id: int, params: dict) -> dict[str
|
|||
}
|
||||
try:
|
||||
runs_mod.update_heartbeat(db, run_id, counters)
|
||||
counters = merge_duplicate_houses(db, dry_run=dry_run)
|
||||
counters = merge_duplicate_houses(db, dry_run=dry_run, run_id=run_id, initiator="schedule")
|
||||
runs_mod.mark_done(db, run_id, counters)
|
||||
logger.info(
|
||||
"run_house_dedup_merge: run_id=%d DONE clusters=%d losers=%d dry_run=%s",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from dataclasses import dataclass, field
|
|||
from typing import Literal
|
||||
|
||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||
from scraper_kit.house_type_normalizer import normalize_house_type
|
||||
|
||||
# #2337 (Group E4, эпик #2277): переключено на scraper_kit — тот же периметр риска,
|
||||
# что и estimator.py (обе точки читают/пишут house_imv_evaluations, #651 IMV/Yandex
|
||||
|
|
@ -65,22 +66,76 @@ _HEARTBEAT_EVERY_N_HOUSES = 5
|
|||
|
||||
# ── house_type normalisation ─────────────────────────────────────────────────
|
||||
|
||||
# Ключи — КАНОНИЧНЫЕ значения listings.house_type (после normalize_house_type),
|
||||
# значения — вокабуляр Avito IMV.
|
||||
_HOUSE_TYPE_TO_IMV: dict[str, str] = {
|
||||
"panel": "panel",
|
||||
"brick": "brick",
|
||||
"monolith": "monolithic",
|
||||
"monolithic": "monolithic",
|
||||
"monolith_brick": "monolithic", # Avito API не принимает гибриды
|
||||
"block": "block",
|
||||
"wood": "wood",
|
||||
}
|
||||
_HOUSE_TYPE_DEFAULT = "panel" # самый распространённый в ЕКБ
|
||||
|
||||
|
||||
def _map_house_type(raw: str | None) -> str:
|
||||
if not raw:
|
||||
return _HOUSE_TYPE_DEFAULT
|
||||
return _HOUSE_TYPE_TO_IMV.get(raw.lower().strip(), _HOUSE_TYPE_DEFAULT)
|
||||
def _map_house_type(raw: str | None) -> str | None:
|
||||
"""Наш house_type → вокабуляр Avito IMV. None = тип неизвестен, запрос не шлём.
|
||||
|
||||
Сырое значение сначала прогоняем через общий normalize_house_type (#2007): он
|
||||
знает camelCase-вокабуляр Циана (monolithBrick / gasSilicateBlock /
|
||||
aerocreteBlock / stalin / ...) и SCREAMING-вокабуляр Яндекса, а нераспознанное
|
||||
('other', 'wireframe', пустое) схлопывает в None. Приведения к нижнему регистру
|
||||
тут мало: ключ канона пишется через подчёркивание (monolith_brick), поэтому
|
||||
'monolithbrick' в словарь не попадал.
|
||||
|
||||
#2674: раньше здесь стоял дефолт 'panel' — и когда типа нет вовсе, и когда он
|
||||
есть, но не распознан. Панель — почти самый дешёвый класс (медиана по нашим же
|
||||
2685 оценкам: block 122.6k < panel 128.8k < brick 131.1k < monolithic 145.9k
|
||||
₽/м²), то есть дефолт систематически ЗАНИЖАЛ оценку: на проде 363 дома совсем
|
||||
без типа + 75 домов с camelCase-типом (56 из них monolithBrick, −11.7% к
|
||||
monolithic) уехали как панель. Теперь неизвестный тип → None → дом помечается
|
||||
и запрос к площадке не тратится (см. _process_one_house).
|
||||
"""
|
||||
canon = normalize_house_type(raw)
|
||||
if canon is None:
|
||||
return None
|
||||
return _HOUSE_TYPE_TO_IMV.get(canon)
|
||||
|
||||
|
||||
def _map_renovation_type(repair_state: str | None) -> str:
|
||||
"""listings.repair_state → renovation_type вокабуляра Avito IMV.
|
||||
|
||||
Переиспользуем _IMV_REPAIR_MAP эстиматора — единственный источник правды для
|
||||
этого соответствия (needs_repair→required / standard→cosmetic / good→euro /
|
||||
excellent→designer). Импорт ленивый: estimator тянет scraper_adapters, а тот
|
||||
импортирует этот модуль (circular — см. блок импортов выше).
|
||||
|
||||
#2674: раньше здесь стоял литерал 'cosmetic' — все 2685 запросов ушли как
|
||||
«косметический ремонт», хотя мода по объявлениям этих же домов совсем другая
|
||||
(standard 4564 / good 4118 / needs_repair 2279 / excellent 1631 — косметика
|
||||
лишь 36%).
|
||||
|
||||
Неизвестный ремонт (498 домов из 2685 — ни одного объявления с repair_state)
|
||||
ОСТАЁТСЯ 'cosmetic', в отличие от неизвестного типа дома: 'cosmetic'
|
||||
(=standard) — это одновременно МОДА и МЕДИАННАЯ категория популяции
|
||||
(standard 7984 / good 7116 / needs_repair 4738 / excellent 2562; кумулятивно
|
||||
needs_repair 21.2%, +standard 56.8%), то есть наилучшая одиночная догадка.
|
||||
У типа дома такой догадки нет: 'panel' — почти край шкалы, а не её середина.
|
||||
|
||||
Асимметрия осознанная, а не недосмотр: поштучный путь эстиматора при
|
||||
неизвестном ремонте IMV вообще не зовёт (estimator.py, `imv_renovation is not
|
||||
None`), а домовой дефолтит — иначе теряем ещё ~32% домов очереди поверх тех,
|
||||
что уже отсекает неизвестный тип дома.
|
||||
"""
|
||||
from app.services.estimator import _IMV_REPAIR_MAP # lazy — см. import-блок
|
||||
|
||||
mapped = _IMV_REPAIR_MAP.get(repair_state)
|
||||
if mapped is None and repair_state:
|
||||
# Непустое, но незнакомое значение — признак дрейфа вокабуляра на ингесте
|
||||
# (сырых repair-значений в listings больше, чем нормализованных). Паритет
|
||||
# с house_type_normalizer, который такой случай уже логирует.
|
||||
logger.debug("house_imv: unmapped repair_state %r — падаем в 'cosmetic'", repair_state)
|
||||
return mapped or "cosmetic"
|
||||
|
||||
|
||||
# ── Region bbox prefix для Avito geocoder ────────────────────────────────────
|
||||
|
|
@ -135,7 +190,8 @@ def pick_lot_params(db: Session, house_id: int) -> dict:
|
|||
AS integer) AS floor,
|
||||
CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY total_floors)
|
||||
AS integer) AS total_floors,
|
||||
mode() WITHIN GROUP (ORDER BY house_type) AS house_type
|
||||
mode() WITHIN GROUP (ORDER BY house_type) AS house_type,
|
||||
mode() WITHIN GROUP (ORDER BY repair_state) AS repair_state
|
||||
FROM listings
|
||||
WHERE house_id_fk = :hid
|
||||
AND rooms IS NOT NULL
|
||||
|
|
@ -173,7 +229,12 @@ def pick_lot_params(db: Session, house_id: int) -> dict:
|
|||
"floor": floor,
|
||||
"floor_at_home": floor_at_home,
|
||||
"house_type": _map_house_type(row["house_type"] or (house and house["house_type"])),
|
||||
"renovation_type": "cosmetic",
|
||||
"renovation_type": _map_renovation_type(row["repair_state"]),
|
||||
# has_balcony/has_loggia остаются константами намеренно (#2674): покрытие
|
||||
# listings.has_balcony 13.8%, listings.balcony_loggia 9.4%, и две колонки
|
||||
# противоречат друг другу (по has_balcony «есть» у 62%, а по
|
||||
# balcony_loggia самый частый случай — loggia 5650 против balcony 2794).
|
||||
# Мода по одному-двум объявлениям на таком покрытии — шум, а не данные.
|
||||
"has_balcony": True,
|
||||
"has_loggia": False,
|
||||
}
|
||||
|
|
@ -284,26 +345,37 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat
|
|||
)
|
||||
|
||||
# 3. Suggestions
|
||||
# #2674: до этого фикса в INSERT не входили image_link + area_m2/rooms/floor/
|
||||
# total_floors — колонки есть с миграции 064, но писатель их не заполнял
|
||||
# (25 055 строк на проде с NULL во всех пяти). Ссылка на фото приходит в
|
||||
# suggestions.items[].imageLink, метрики квартиры парсятся из title.
|
||||
for sug in result.suggestions:
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO house_suggestions (
|
||||
house_id, ext_item_id, title, address, price_rub,
|
||||
area_m2, rooms, floor, total_floors,
|
||||
exposure_days, publish_date,
|
||||
item_link, metro_name, metro_distance,
|
||||
item_link, image_link, metro_name, metro_distance,
|
||||
has_good_price_badge, raw_payload, fetched_at
|
||||
) VALUES (
|
||||
:hid, :ext, :title, :addr, :price,
|
||||
CAST(:area AS numeric), :rooms, :floor, :total_floors,
|
||||
:exp, :pdate,
|
||||
:link, :mname, :mdist,
|
||||
:link, :img, :mname, :mdist,
|
||||
:gpb, CAST(:raw AS jsonb), NOW()
|
||||
)
|
||||
ON CONFLICT (house_id, ext_item_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
price_rub = EXCLUDED.price_rub,
|
||||
area_m2 = EXCLUDED.area_m2,
|
||||
rooms = EXCLUDED.rooms,
|
||||
floor = EXCLUDED.floor,
|
||||
total_floors = EXCLUDED.total_floors,
|
||||
exposure_days = EXCLUDED.exposure_days,
|
||||
publish_date = EXCLUDED.publish_date,
|
||||
item_link = EXCLUDED.item_link,
|
||||
image_link = EXCLUDED.image_link,
|
||||
metro_name = EXCLUDED.metro_name,
|
||||
metro_distance = EXCLUDED.metro_distance,
|
||||
has_good_price_badge = EXCLUDED.has_good_price_badge,
|
||||
|
|
@ -316,9 +388,14 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat
|
|||
"title": sug.title,
|
||||
"addr": sug.address,
|
||||
"price": sug.price_rub,
|
||||
"area": sug.area_m2,
|
||||
"rooms": sug.rooms,
|
||||
"floor": sug.floor,
|
||||
"total_floors": sug.total_floors,
|
||||
"exp": sug.exposure_days,
|
||||
"pdate": sug.publish_date,
|
||||
"link": sug.item_url,
|
||||
"img": sug.image_link,
|
||||
"mname": sug.metro_name,
|
||||
"mdist": sug.metro_distance,
|
||||
"gpb": sug.has_good_price_badge,
|
||||
|
|
@ -500,8 +577,33 @@ async def backfill_house_imv(
|
|||
# + прокси переиспользуются всеми домами; обходит datacenter-403, #562/#853).
|
||||
# Флаг OFF → _bf=None → evaluate_via_imv делает свою curl-сессию как раньше
|
||||
# (поведение байт-в-байт идентично доспринтовому).
|
||||
#
|
||||
# #2698: proxy_provider/use_pool/environment — обязательная часть проводки, а не
|
||||
# опция. Без них BrowserFetcher не кладёт "proxy" в тело POST /fetch-json, и сайдкар
|
||||
# берёт свой env-прокси SCRAPER_PROXY_URL — на проде это узел пула id=1
|
||||
# (asocks-residential-1, provider_affinity='domclick'), который proxy_pool.acquire
|
||||
# («affinity IN (provider,'any')» + защита последнего узла выделенной affinity от
|
||||
# fallback) для avito не выдал бы НИКОГДА. Результат: 03.07-05.08 все 35 из 35 попыток
|
||||
# каждого прогона падали на геокодере A (1240 домов — 503 «browser unavailable», затем
|
||||
# 500 «Page.goto: NS_ERROR_PROXY_BAD_GATEWAY» и 403 от самого Авито), пока
|
||||
# avito_city_sweep/avito_newbuilding_sweep в те же дни тянули сотни объявлений через
|
||||
# ТОТ ЖЕ сайдкар и тот же инстанс камуфокса — они пул подключают (pipeline.py). Хуже:
|
||||
# запрос без "proxy" в теле ещё и роняет сайдкару желаемый прокси на env → relaunch
|
||||
# камуфокса на каждый дом (server.py::_ensure_browser).
|
||||
if settings.avito_imv_use_browser_fetcher:
|
||||
async with BrowserFetcher(source="avito", endpoint=settings.browser_http_endpoint) as _bf:
|
||||
# lazy import — тот же цикл scraper_adapters↔этот модуль, что и у RealScraperConfig.
|
||||
from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
|
||||
|
||||
_cfg = RealScraperConfig()
|
||||
async with BrowserFetcher(
|
||||
source="avito",
|
||||
endpoint=settings.browser_http_endpoint,
|
||||
proxy_provider=RealProxyProvider(),
|
||||
use_pool=_cfg.use_proxy_pool_browser,
|
||||
# #2616 шаг 1: без environment прод-отказ «пул пуст» мёртв на этом пути —
|
||||
# фетчер молча ушёл бы на тот самый env-прокси (см. _acquire_lease).
|
||||
environment=_cfg.environment,
|
||||
) as _bf:
|
||||
await _run_loop(_bf)
|
||||
else:
|
||||
await _run_loop(None)
|
||||
|
|
@ -652,6 +754,14 @@ async def _process_one_house(
|
|||
_mark_status(db, hid, "no_params", "no listings with rooms+area")
|
||||
return "no_params"
|
||||
|
||||
# #2674: тип дома неизвестен (нет ни в объявлениях, ни в houses — либо
|
||||
# вокабуляр не распознан). Раньше такой дом молча уезжал как 'panel' и
|
||||
# занижал оценку. Лучше не тратить запрос и честно пометить дом — тот же
|
||||
# путь, что и при отсутствии комнат/площади.
|
||||
if params["house_type"] is None:
|
||||
_mark_status(db, hid, "no_params", "unknown house_type")
|
||||
return "no_params"
|
||||
|
||||
address = house.get("address") or house.get("full_address")
|
||||
if not address:
|
||||
_mark_status(db, hid, "no_address", "house.address is NULL")
|
||||
|
|
|
|||
|
|
@ -13,9 +13,13 @@
|
|||
POI-score его не улавливал (POI ranking ≠ цена).
|
||||
|
||||
НОВЫЙ ПОКАЗАТЕЛЬ (location index):
|
||||
location_index_pct = (медиана ₽/м² сопоставимых активных листингов в радиусе точки −
|
||||
location_index_pct = (медиана ₽/м² сопоставимых листингов в радиусе точки −
|
||||
медиана ₽/м² по всему ЕКБ) / медиана по ЕКБ * 100
|
||||
|
||||
«Сопоставимые» = ровно тот же пул, что берёт эстиматор (#2660): активные И свежие
|
||||
(scraped_at в пределах LISTINGS_FRESH_DAYS — `is_active` на проде не равно «живо») И
|
||||
только вторичка (гард #1186 — девелоперский прайс новостроек завышал обе медианы).
|
||||
|
||||
Самообновляем (те же `listings`, что уже скрейпятся под estimator), интерпретируем напрямую
|
||||
("район на N% дороже/дешевле среднего по городу"), устойчив к выбросам (percentile_cont(0.5) —
|
||||
медиана самой природой игнорирует единичные экстремумы, в отличие от mean/min/max), и НЕ зажат
|
||||
|
|
@ -45,6 +49,11 @@ from typing import Any
|
|||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
|
||||
# #2660: окно свежести берём ИЗ эстиматора — единственное определение в проекте.
|
||||
# Дублировать значение здесь нельзя: две константы разъедутся при первой же
|
||||
# перекалибровке, и витрина начнёт показывать другой пул, чем считает цена.
|
||||
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Гео-охват продукта: только Екатеринбург ──────────────────────────────────
|
||||
|
|
@ -159,6 +168,43 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
|
|||
# price_per_m2 BETWEEN sanity-границы — не бизнес-калибровка, а защита от битых строк
|
||||
# (см. _PRICE_PER_M2_SANITY_MIN/MAX выше).
|
||||
#
|
||||
# #2660 свежесть + сегмент — оба предиката ЗЕРКАЛЯТ _COMMON_WHERE эстиматора.
|
||||
# Вклад у них РАЗНЫЙ, и не тот, на который легко подумать. Прод-разложение
|
||||
# (2026-08-05, пул location_index — bbox ЕКБ + sanity ₽/м² + geo_precision):
|
||||
#
|
||||
# было (только is_active) 30 222 строк 172 984 ₽/м²
|
||||
# + только свежесть 11 453 строк 163 363 ₽/м²
|
||||
# + только сегмент 11 219 строк 147 632 ₽/м²
|
||||
# стало (оба) 7 715 строк 147 368 ₽/м²
|
||||
#
|
||||
# - listing_segment guard (#1186) — ЭТО и есть исправление смещения: из −14.8%
|
||||
# сдвига городской медианы он даёт −14.7 п.п. Девелоперский прайс новостроек
|
||||
# завышал и локальную, и городскую медиану. NULL = legacy вторичка до м.011.
|
||||
# Мертвецы, кстати, живут почти целиком тут же: из 18 769 протухших строк
|
||||
# пула 15 265 — новостройки, и гард выносит их заодно.
|
||||
# - scraped_at > NOW() - LISTINGS_FRESH_DAYS — даёт ПОВЕРХ сегмента всего
|
||||
# −0.18 п.п. Для ЭТОЙ метрики он не коррекция смещения, а СТРАХОВКА на
|
||||
# будущее (пул совпадает с пулом цены; если завтра протухнет вторичка —
|
||||
# виджет не соврёт), и страховка не бесплатная: выбрасывает 3 504 вторичных
|
||||
# строки, из которых 2 724 — живые объявления, отскрейпленные 15-30 дней
|
||||
# назад. Пул −31%, шум растёт: на центре ЕКБ (r=800) n падает 423 → 86, а
|
||||
# сам индекс гуляет по выбору окна на 12-14 п.п. (7д +75.7% / 14д +77.0% /
|
||||
# 21д +79.1% / 30д +64.7%) — при n=86 это в пределах шума выборки медианы.
|
||||
# Размен «меньше смещения ↔ больше дисперсии» сделан осознанно: старое число
|
||||
# было предвзятым, новое — шумным, но честным. Окно менять здесь НЕ надо,
|
||||
# LISTINGS_FRESH_DAYS живёт в estimator.py (см. импорт выше).
|
||||
#
|
||||
# НОВЫЙ РЕЖИМ ОТКАЗА (знать обязательно): свежесть связала витрину со здоровьем
|
||||
# СБОРА. Встанет скрейпинг на LISTINGS_FRESH_DAYS — городская выборка не наберёт
|
||||
# MIN_SAMPLE_SIZE, и "insufficient_data" прилетит ВСЕМ пользователям разом; до
|
||||
# этой правки виджет продолжал бы показывать устаревшее число. Учитывая, что
|
||||
# #2574 — ровно месяц молчаливой поломки сбора, сценарий не гипотетический.
|
||||
# Деградация честная (прочерк, а не выдуманное число), но она теперь массовая.
|
||||
#
|
||||
# Порог MIN_SAMPLE_SIZE после сужения пула набирается реже, но лестница радиусов
|
||||
# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок:
|
||||
# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246.
|
||||
#
|
||||
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если
|
||||
# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index
|
||||
# явно ограничил владелец продукта.
|
||||
|
|
@ -173,6 +219,8 @@ _MEDIAN_PPM2_LOCAL_SQL = text(
|
|||
AND price_per_m2 IS NOT NULL
|
||||
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
|
||||
AND (geo_precision IS DISTINCT FROM 'city')
|
||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||||
AND CAST(:bbox_north AS double precision)
|
||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||
|
|
@ -196,6 +244,8 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
|
|||
AND price_per_m2 IS NOT NULL
|
||||
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
|
||||
AND (geo_precision IS DISTINCT FROM 'city')
|
||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||||
AND CAST(:bbox_north AS double precision)
|
||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||
|
|
@ -235,6 +285,7 @@ def _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[
|
|||
"lat": lat,
|
||||
"lon": lon,
|
||||
"radius_m": radius_m,
|
||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||
"bbox_south": _EKB_BBOX_SOUTH,
|
||||
|
|
@ -257,6 +308,7 @@ def _citywide_median_ppm2(db: Any) -> tuple[float | None, int]:
|
|||
db.execute(
|
||||
_MEDIAN_PPM2_CITYWIDE_SQL,
|
||||
{
|
||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||
"bbox_south": _EKB_BBOX_SOUTH,
|
||||
|
|
|
|||
|
|
@ -75,7 +75,10 @@ HOUSE_FIELD_PRIORITY: dict[str, list[str] | str] = {
|
|||
"yandex_total_listings": ["yandex_valuation"], # "N объектов" в истории
|
||||
# Yandex Valuation enrichment (existing house attrs)
|
||||
"has_lift": ["cian_bti", "cian_detail", "yandex_valuation"],
|
||||
"ceiling_height": ["cian_detail", "yandex_valuation"],
|
||||
# #2699: запись "ceiling_height" удалена — колонки с таким именем в `houses`
|
||||
# никогда не было (проверено на проде: 0 колонок LIKE '%ceiling%'), правило не
|
||||
# могло сработать ни разу. Высота потолков — атрибут ОБЪЯВЛЕНИЯ
|
||||
# (listings.ceiling_height_m), см. LISTING_FIELD_PRIORITY ниже.
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -88,7 +91,10 @@ LISTING_FIELD_PRIORITY: dict[str, list[str] | str] = {
|
|||
"area_m2": ["cian_serp", "avito_detail"],
|
||||
"living_area_m2": ["cian_serp"],
|
||||
"kitchen_area_m2": ["cian_serp", "avito_detail"],
|
||||
"ceiling_height": ["cian_detail"],
|
||||
# #2699: ключ — имя РЕАЛЬНОЙ колонки. Был "ceiling_height" (019), которую
|
||||
# эстиматор не читает; канон — ceiling_height_m, и её пишут все три источника,
|
||||
# а не только cian_detail.
|
||||
"ceiling_height_m": ["cian_detail", "avito_detail", "yandex_detail"],
|
||||
"floor": ["cian_serp", "avito_detail"],
|
||||
"total_floors": ["cian_serp", "avito_detail"],
|
||||
"year_built": ["cian_serp"],
|
||||
|
|
|
|||
|
|
@ -1,11 +1,40 @@
|
|||
"""House cross-source matching — tiered algorithm.
|
||||
|
||||
Tier 0 (confidence 1.0): cadastral_number exact match on houses table.
|
||||
Tier 0.5 (confidence 0.95): house_fias_id (ГАР OBJECTGUID) exact match, case-insensitive.
|
||||
Tier 1 (confidence 1.0): ext_source + ext_id already in house_sources.
|
||||
Tier 2 (confidence 0.9): address_fingerprint match in house_address_aliases.
|
||||
Tier 3 (confidence 0.7): geo-proximity within 30 m (PostGIS ST_DWithin).
|
||||
New (confidence 1.0): INSERT new canonical house.
|
||||
`match_or_create_house` (путь скрейпинга, создаёт дома):
|
||||
Tier 0 (confidence 1.0): cadastral_number exact match on houses table.
|
||||
Tier 1 (confidence 1.0): ext_source + ext_id already in house_sources.
|
||||
Tier 2 (confidence 0.9): address_fingerprint match in house_address_aliases.
|
||||
Tier 3 (confidence 0.7): geo-proximity within 30 m (PostGIS ST_DWithin).
|
||||
New (confidence 1.0): INSERT new canonical house.
|
||||
|
||||
`match_house_readonly` (путь estimate-таргета, ничего не создаёт) дополнительно
|
||||
имеет Tier 0.5 fias_exact — у него ЕСТЬ источник ФИАС (DaData /suggest в
|
||||
`estimator.resolve_target_house`), см. docstring функции.
|
||||
|
||||
ЧЕСТНОСТЬ ТИРОВ (#2674, замер на проде 2026-08-05, 49 502 строки house_sources):
|
||||
fingerprint 58.97% · new 22.65% · geo_proximity 18.36% ·
|
||||
**cadastr_exact 0 · fias_exact 0** — верхние тиры не срабатывали НИ РАЗУ.
|
||||
|
||||
• Tier 0.5 fias_exact из `match_or_create_house` УДАЛЁН: параметра `house_fias_id`
|
||||
нет ни в Protocol `scraper_kit.contracts.HouseMatcher`, ни в
|
||||
`app.services.scraper_adapters.RealMatcherAdapter`, ни у двух прямых вызывающих
|
||||
(`estimator._save_yandex_history_items`, `scripts/backfill_listing_sources.py`) —
|
||||
передать его было НЕКОМУ. Регресс сторожит
|
||||
tests/test_matching_tier_reachability_2674.py.
|
||||
• Tier 0 cadastr_exact ОСТАВЛЕН: он достижим по построению (`ScrapedLot.
|
||||
building_cadastral_number` → адаптер → сюда), но площадки кадастр не отдают:
|
||||
`listings.cadastral_number` 0/93 408, а все 28 504 заполненных
|
||||
`listings.building_cadastral_number` — на 100% из локального гео-зеркала ЕГРН
|
||||
(`tasks/cadastral_geo_match.py`, KNN ≤50 м), т.е. появляются ПОСЛЕ матчинга и
|
||||
обратно в матчер не подаются. Подавать их сюда НЕЛЬЗЯ: как ключ здания KNN-кадастр
|
||||
не инъективен — 656 из 3 260 значений накрывают >1 ГАР-здание (20.1%), это был бы
|
||||
over-merge с confidence 1.0. Оставлен как рабочий приёмник на случай, если площадка
|
||||
начнёт отдавать настоящий кадастр — но приёмник СУЖЕН до кадастра ЗДАНИЯ: параметр
|
||||
`cadastral_number` (кадастр КВАРТИРЫ) убран из сигнатуры, Protocol и обоих вызывающих.
|
||||
Он был отложенной миной: у каждой квартиры свой номер, Tier 0 не сматчил бы никогда,
|
||||
падение в New-house INSERT записало бы номер квартиры в `houses.cadastral_number` и
|
||||
попутно сняло P1-страж «безномерный адрес без кадастра не создаём» — по дому на
|
||||
квартиру. В `listings` оба поля пишутся как раньше; из ключа дома ушло только ложное.
|
||||
|
||||
Algorithm reference: decisions/Cross_Source_Matching_Strategy.md sec 3
|
||||
"""
|
||||
|
|
@ -46,9 +75,8 @@ def match_or_create_house(
|
|||
*,
|
||||
year_built: int | None = None,
|
||||
building_cadastral_number: str | None = None,
|
||||
cadastral_number: str | None = None,
|
||||
house_fias_id: str | None = None,
|
||||
source_url: str | None = None,
|
||||
city: str | None = None,
|
||||
) -> tuple[int | None, float, str]:
|
||||
"""Match existing house or create new canonical record.
|
||||
|
||||
|
|
@ -58,21 +86,30 @@ def match_or_create_house(
|
|||
for an unknown address could both miss Tier 0-3 and each INSERT a duplicate
|
||||
house row. Closes finding #1 from 2026-05-24 audit.
|
||||
|
||||
NB: параметра `house_fias_id` здесь НЕТ намеренно (#2674) — см. шапку модуля.
|
||||
ФИАС-тир живёт только в `match_house_readonly`, у которого есть источник ФИАС.
|
||||
|
||||
Args:
|
||||
house_fias_id: ГАР OBJECTGUID (UUID) of the building, when known upstream
|
||||
(e.g. DaData /clean/address). Enables Tier 0.5 fias_exact — additive and
|
||||
optional, existing callers are unaffected.
|
||||
city: город-цель развёртки, собравшей эту карточку (`save_listings(city=…)`,
|
||||
он же `listings.city`) — НЕЗАВИСИМОЕ от строки адреса наблюдение города
|
||||
(#2777). Нужен ровно там, где адресный токен города бессилен: областной
|
||||
формат Avito SERP «ул. Кирова,4» города не называет, а бескоординатный
|
||||
ключ Tier-2a вырождается в один нормализованный адрес и становится
|
||||
глобально уникальным. Опционален: вызывающие без sweep-контекста
|
||||
(estimate-путь, ad-hoc скрипты) передают None → поведение прежнее.
|
||||
Про независимость: в #2690 доказано, что усиление ключа полем, выведенным
|
||||
из ТОЙ ЖЕ строки адреса (gar_house_guid), защиту отменяет, а не усиливает —
|
||||
здесь признак приходит другим каналом (какой город запрашивала развёртка).
|
||||
|
||||
Returns:
|
||||
(house_id, confidence ∈ [0.0, 1.0], method ∈ {
|
||||
'cadastr_exact', 'fias_exact', 'source_exact', 'fingerprint',
|
||||
'cadastr_exact', 'source_exact', 'fingerprint',
|
||||
'geo_proximity', 'new', 'no_house_number'
|
||||
})
|
||||
house_id is None only for the 'no_house_number' terminal case below.
|
||||
|
||||
Method values:
|
||||
'cadastr_exact' — matched by cadastral number (confidence 1.0)
|
||||
'fias_exact' — matched by house_fias_id (ГАР OBJECTGUID) (confidence 0.95)
|
||||
'source_exact' — already in house_sources for this source+ext_id (confidence 1.0)
|
||||
'fingerprint' — matched by address fingerprint (confidence 0.9)
|
||||
'geo_proximity' — matched by geo within 30 m (confidence 0.7)
|
||||
|
|
@ -87,7 +124,16 @@ def match_or_create_house(
|
|||
'р-н Чкаловский, мкр. Вторчермет' 480). A cadastral number is a precise building
|
||||
identity, so cad-carrying rows stay exempt (Tier 0 owns them).
|
||||
"""
|
||||
cad = building_cadastral_number or cadastral_number
|
||||
# ТОЛЬКО кадастр ЗДАНИЯ (#2674). Раньше было `building_cadastral_number or cadastral_number`,
|
||||
# где второе — кадастр КВАРТИРЫ (у каждой свой), и параметр `cadastral_number` тоже убран из
|
||||
# сигнатуры. Пока площадки не отдают ни того ни другого, фолбэк спал; но он и есть ловушка,
|
||||
# ради которой мы «оставили рабочий приёмник»: начни Циан отдавать `offer["cadastralNumber"]`
|
||||
# (парсер читает именно его), квартирный номер поехал бы в ключ ЗДАНИЯ. Tier 0 не сматчил бы
|
||||
# никогда (у каждой квартиры свой номер) → падение в New-house INSERT → номер КВАРТИРЫ
|
||||
# проштампован в houses.cadastral_number, плюс снят P1-страж ниже («безномерный адрес без
|
||||
# кадастра не создаём» — `cad` там же и разрешает создание). Две квартиры одного дома дали бы
|
||||
# два дома — то самое дробление, против которого Tier 0 и заведён.
|
||||
cad = building_cadastral_number
|
||||
|
||||
# Compute fingerprint early so we can acquire the advisory lock before any tier reads.
|
||||
fp = address_fingerprint(address, lat, lon)
|
||||
|
|
@ -136,34 +182,10 @@ def match_or_create_house(
|
|||
logger.info("house match cadastr_exact house_id=%s cad=%s", house_id, cad)
|
||||
return (house_id, 1.0, "cadastr_exact")
|
||||
|
||||
# Tier 0.5: house_fias_id (ГАР OBJECTGUID) exact match, case-insensitive.
|
||||
# Stable ORDER BY id so concurrent/duplicate rows resolve deterministically.
|
||||
if house_fias_id:
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"SELECT id FROM houses "
|
||||
"WHERE lower(house_fias_id) = lower(CAST(:fias AS text)) "
|
||||
"ORDER BY id ASC LIMIT 1"
|
||||
),
|
||||
{"fias": house_fias_id},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
if row:
|
||||
house_id = int(row["id"])
|
||||
_upsert_house_source(
|
||||
db,
|
||||
house_id=house_id,
|
||||
ext_source=ext_source,
|
||||
ext_id=ext_id,
|
||||
method="fias_exact",
|
||||
confidence=0.95,
|
||||
)
|
||||
_insert_alias(db, house_id=house_id, address=address, fp=fp, source=ext_source)
|
||||
logger.info("house match fias_exact house_id=%s fias=%s", house_id, house_fias_id)
|
||||
return (house_id, 0.95, "fias_exact")
|
||||
# Tier 0.5 fias_exact удалён (#2674): передать `house_fias_id` в этот путь было
|
||||
# некому — ни Protocol HouseMatcher, ни RealMatcherAdapter, ни оба прямых вызывающих
|
||||
# такого параметра не имели, поэтому за всю историю тир не сработал ни разу (0 из
|
||||
# 49 502 house_sources). Живой ФИАС-тир остался в match_house_readonly.
|
||||
|
||||
# Tier 1: source+ext_id already registered in house_sources
|
||||
row = (
|
||||
|
|
@ -203,18 +225,40 @@ def match_or_create_house(
|
|||
# SAME oblast building) still needs city-keyed aliases — a separate follow-up, out of
|
||||
# scope, only relevant once the oblast sweep is enabled.
|
||||
#
|
||||
# EKB happy-path is byte-identical: the guard fires ONLY when the address names a non-ЕКБ
|
||||
# city AND no coords disambiguate. ЕКБ cards (resolved city = екатеринбург) and the
|
||||
# dominant bare/city-less Avito coord-less cards (resolved city None) run Tier-2a/2b
|
||||
# exactly as before. NB: a BARE oblast card (no city token in the address — today's Avito
|
||||
# SERP format) carries no signal here and is deliberately left on the unchanged path; that
|
||||
# residual needs sweep-context and is out of this fix's scope.
|
||||
_resolved_city = resolve_city_token(norm_addr) if (lat is None and lon is None) else None
|
||||
# EKB happy-path is byte-identical: the guard fires ONLY when the card's city is known to
|
||||
# be non-ЕКБ AND no coords disambiguate. ЕКБ cards and cards with no city signal at all
|
||||
# (resolved city None) run Tier-2a/2b exactly as before.
|
||||
#
|
||||
# #2777: the residual the comment above used to describe as out of scope — a BARE oblast
|
||||
# card ('ул. Кирова,4', today's Avito SERP format) — is closed here by the `city` kwarg.
|
||||
# The sweep already knows which city it was crawling and stamps it on the listing row
|
||||
# (save_listings → listings.city); that observation just never reached this guard, so
|
||||
# 26 of 26 measured cross-city stitches went through Tier 2a on a coord-less key. Prod
|
||||
# 2026-08-10: 7303 of 21603 aliases are coord-less keys, 6047 of them carry no city token
|
||||
# at all — i.e. a globally unique 'street + number' that ANY city's card can hit.
|
||||
# The address token still wins when present (it describes THIS card; the sweep city
|
||||
# describes the batch).
|
||||
_resolved_city = None
|
||||
if lat is None and lon is None:
|
||||
_resolved_city = resolve_city_token(norm_addr) or (normalize_address(city) or None)
|
||||
_skip_oblast_alias = _resolved_city is not None and _resolved_city != EKB_CITY_TOKEN
|
||||
# Известный потолок правки, названный числом (прод 2026-08-10, 35 домов со
|
||||
# «сшитыми» городами по метке listings.city):
|
||||
# • 30 из 35 — приходящая карточка областная, алиас принадлежит дому другого
|
||||
# города → страж срабатывает;
|
||||
# • 5 из 35 — приходящая карточка ЕКБ, а алиас завёл областной дом. Тут страж
|
||||
# молчит: города владельца алиаса мы не знаем (в house_address_aliases его
|
||||
# нет). Апгрейд — city-ключ у алиаса, но это миграция + перекладка 7303
|
||||
# бескоординатных ключей, и до неё нужен журнал слияний (#2690 п.1).
|
||||
# • посёлки внутри ЕКБ-развёртки (Кедровка, Б. Седельниково, Решёты — 12-17 км
|
||||
# разброса) этим признаком НЕ ловятся вовсе: у них тот же город-цель
|
||||
# «Екатеринбург». Гранулярность независимого наблюдения — город, не населённый
|
||||
# пункт; это ограничение данных, а не недоделка стража.
|
||||
if _skip_oblast_alias:
|
||||
logger.info(
|
||||
"house tier2a/2b skip: coord-less non-ЕКБ city %r na=%r src=%s",
|
||||
"house tier2a/2b skip: coord-less non-ЕКБ city %r (sweep_city=%r) na=%r src=%s",
|
||||
_resolved_city,
|
||||
city,
|
||||
norm_addr,
|
||||
ext_source,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -246,7 +246,23 @@ def _upsert_listing_source(
|
|||
source_url: str | None,
|
||||
source_data: dict | None,
|
||||
) -> None:
|
||||
"""Insert or refresh listing_sources row for this source+ext_id."""
|
||||
"""Insert or refresh listing_sources row for this source+ext_id.
|
||||
|
||||
Отметки времени — statement_timestamp(), НЕ NOW() (#2731). Этот upsert вызывается
|
||||
ПОСТРОЧНО из save_listings (hook `_link_listing_to_house`), а транзакция batch'а
|
||||
коммитится один раз в конце, поэтому NOW() (== transaction_timestamp) давал одну
|
||||
метку на весь вызов: прод-замер 2026-08-06 — 219 строк на 1 метку в 11:00,
|
||||
235/1 в 10:00, 297/1 в 09:00, и так каждый час.
|
||||
|
||||
Чинится вместе с listings.scraped_at/last_seen_at, а не отдельно: сегодня
|
||||
listings.last_seen_at = listing_sources.last_seen_at у 100% пар (2407 из 2407 за
|
||||
сутки) именно потому, что обе колонки берут одну транзакционную метку. Почини
|
||||
только одну — вторая осталась бы замороженной на старте batch'а, и расхождение
|
||||
выросло бы с миллисекунд (честная разница двух записей) до длительности прогона.
|
||||
|
||||
Все три колонки пишутся ОДНИМ statement'ом, поэтому statement_timestamp() даёт им
|
||||
одинаковое значение; clock_timestamp() развёл бы их на микросекунды.
|
||||
"""
|
||||
raw = json.dumps(source_data) if source_data is not None else None
|
||||
db.execute(
|
||||
text("""
|
||||
|
|
@ -257,15 +273,15 @@ def _upsert_listing_source(
|
|||
price_rub, area_m2, floor, rooms_count, raw_payload
|
||||
) VALUES (
|
||||
CAST(:lid AS bigint), :s, :e,
|
||||
CAST(:c AS real), :m, NOW(), NOW(),
|
||||
:url, NOW(),
|
||||
CAST(:c AS real), :m, statement_timestamp(), statement_timestamp(),
|
||||
:url, statement_timestamp(),
|
||||
CAST(:p AS bigint), CAST(:a AS numeric), :fl, :rc,
|
||||
CAST(:raw AS jsonb)
|
||||
)
|
||||
ON CONFLICT (ext_source, ext_id) DO UPDATE SET
|
||||
confidence = GREATEST(EXCLUDED.confidence, listing_sources.confidence),
|
||||
last_seen_at = NOW(),
|
||||
last_scraped_at = NOW(),
|
||||
last_seen_at = statement_timestamp(),
|
||||
last_scraped_at = statement_timestamp(),
|
||||
price_rub = COALESCE(EXCLUDED.price_rub, listing_sources.price_rub),
|
||||
area_m2 = COALESCE(EXCLUDED.area_m2, listing_sources.area_m2),
|
||||
floor = COALESCE(EXCLUDED.floor, listing_sources.floor),
|
||||
|
|
|
|||
20
tradein-mvp/backend/app/services/payments/__init__.py
Normal file
20
tradein-mvp/backend/app/services/payments/__init__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""Т-Банк интернет-эквайринг — чистый интеграционный слой (PR-C).
|
||||
|
||||
Модули здесь НЕ импортируют `app.core.config` и не пишут в БД: все секреты
|
||||
(`terminal_key`, `password`, `base_url`) принимаются аргументами функций/
|
||||
конструктора. Причина — параллельный PR-B вводит эти поля в `config.py`,
|
||||
а проводку (роутер, `_PUBLIC_PATHS`, `payments`-таблицы, статус-машина)
|
||||
делает следующий PR-D. См. `mera-tbank-acquiring-recon.md` (корень репо)
|
||||
§3/§9 для полной схемы разбивки.
|
||||
|
||||
- `token.py` — подпись `Token` запросов + проверка подписи нотификаций
|
||||
(никогда не кидает исключение на враждебном входе).
|
||||
- `notification.py` — строгий типизированный разбор тела нотификации ПОСЛЕ
|
||||
проверки подписи (`parse_notification`) — сырой `dict` дальше не уходит.
|
||||
- `receipt.py` — сборка `Receipt` (54-ФЗ, ФФД 1.05) для услуги.
|
||||
- `tbank_client.py` — httpx-клиент `Init/GetState/CheckOrder/Confirm/Cancel`.
|
||||
|
||||
Docs: https://developer.tbank.ru/eacq/intro
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
116
tradein-mvp/backend/app/services/payments/notification.py
Normal file
116
tradein-mvp/backend/app/services/payments/notification.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""Строгий типизированный разбор нотификации Т-Банк — ПОСЛЕ проверки подписи.
|
||||
|
||||
Вызывать `parse_notification()` только когда `token.verify_notification_token(...)`
|
||||
уже вернул `True`. Разбор здесь НЕ проверяет подпись повторно — он только
|
||||
превращает уже доверенный (по подписи) `dict` в типизированный объект, чтобы
|
||||
сырой `dict` не утекал дальше в бизнес-логику (статус-машину платежа, запись
|
||||
в БД).
|
||||
|
||||
ПОЧЕМУ строгий разбор — самостоятельный слой защиты, а не формальность:
|
||||
алгоритм подписи Т-Банка конкатенирует значения полей БЕЗ разделителя между
|
||||
ними (см. `token.py`, docstring модуля, шаг 4). Из-за этого символы могут
|
||||
"перекладываться" между лексикографически соседними ключами так, что итоговая
|
||||
строка для SHA-256 не меняется, хотя значения полей — меняются. Проверено
|
||||
живым расчётом на официальном эталонном векторе: `Amount=1111, CardId="000000"`
|
||||
даёт тот же Token, что и `Amount=11, CardId="11000000"` (доп. `1` "перетекла"
|
||||
из `Amount` в начало `CardId`, потому что `Amount` < `CardId` лексикографически
|
||||
и обе стоят подряд в конкатенации). Значит подпись сама по себе НЕ гарантирует,
|
||||
что банк прислал именно ту сумму, которую записал у себя платёжный сервис —
|
||||
это СВОЙСТВО алгоритма банка, менять его нельзя (мы не управляем форматом
|
||||
Token, который реально пришлёт банк на проде).
|
||||
|
||||
КОНТРАКТ ДЛЯ PR-D (публичная ручка нотификации) — единственная реальная защита
|
||||
от описанного выше перекладывания: `amount_kopecks` из `parse_notification()`
|
||||
ОБЯЗАН быть сверен с уже сохранённым `payments.amount_kopecks` в БД (запись,
|
||||
созданная на `init_payment()`, найденная по `order_id`/`payment_id` из этой же
|
||||
нотификации) ДО того, как нотификация будет принята как валидное событие.
|
||||
Если сумма из нотификации не совпадает с суммой в БД — это либо подделанная
|
||||
нотификация (перекладывание символов дало другой `OrderId`/`Amount`-ключ и
|
||||
подпись всё равно сошлась), либо рассинхронизация, но НЕ штатный кейс — то и
|
||||
другое должно быть отказом, а не «примерно похоже, примем».
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
class NotificationParseError(ValueError):
|
||||
"""Поле нотификации не соответствует ожидаемому типу — отказ, не догадка."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TBankNotification:
|
||||
"""Типизированное тело нотификации Т-Банка ПОСЛЕ успешной проверки подписи.
|
||||
|
||||
`amount_kopecks` здесь — то, что ПРИСЛАЛ банк в текущем HTTP-запросе, а
|
||||
НЕ подтверждённый источник истины сам по себе. См. docstring модуля —
|
||||
сверка с `payments.amount_kopecks` в БД обязательна на вызывающей стороне.
|
||||
"""
|
||||
|
||||
success: bool
|
||||
status: str
|
||||
order_id: str
|
||||
payment_id: str
|
||||
terminal_key: str
|
||||
amount_kopecks: int
|
||||
|
||||
|
||||
def parse_notification(payload: dict[str, Any]) -> TBankNotification:
|
||||
"""Строгий разбор `payload` в `TBankNotification`.
|
||||
|
||||
Вызывать ТОЛЬКО после `token.verify_notification_token(payload, password)
|
||||
is True` — эта функция подпись не проверяет.
|
||||
|
||||
Правила (без исключений, без «примерно разберём»):
|
||||
- `Success` — только настоящий `bool` (не строка `"true"`, не `1`);
|
||||
- `Amount` — только `int`; `bool` — подкласс `int` в Python
|
||||
(`isinstance(True, int) is True`), поэтому проверяется и отсекается
|
||||
ДО проверки на `int`, иначе `Success`-подобное поле молча прошло бы
|
||||
как сумма;
|
||||
- `Status`, `OrderId`, `PaymentId`, `TerminalKey` — только непустой `str`.
|
||||
|
||||
Любое несоответствие — `NotificationParseError` с указанием поля,
|
||||
ожидаемого типа и того, что реально пришло.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
raise NotificationParseError(f"payload должен быть dict, получено {type(payload).__name__}")
|
||||
|
||||
return TBankNotification(
|
||||
success=_require_strict_bool(payload, "Success"),
|
||||
status=_require_nonempty_str(payload, "Status"),
|
||||
order_id=_require_nonempty_str(payload, "OrderId"),
|
||||
payment_id=_require_nonempty_str(payload, "PaymentId"),
|
||||
terminal_key=_require_nonempty_str(payload, "TerminalKey"),
|
||||
amount_kopecks=_require_strict_int(payload, "Amount"),
|
||||
)
|
||||
|
||||
|
||||
def _require_strict_bool(payload: dict[str, Any], key: str) -> bool:
|
||||
value = payload.get(key)
|
||||
if not isinstance(value, bool):
|
||||
raise NotificationParseError(
|
||||
f"{key} должен быть bool, получено {type(value).__name__}={value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _require_strict_int(payload: dict[str, Any], key: str) -> int:
|
||||
value = payload.get(key)
|
||||
# bool — подкласс int в Python: проверяем и отсекаем ДО isinstance(value, int),
|
||||
# иначе True/False молча прошли бы как Amount=1/Amount=0.
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise NotificationParseError(
|
||||
f"{key} должен быть int (не bool/str/float), получено {type(value).__name__}={value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _require_nonempty_str(payload: dict[str, Any], key: str) -> str:
|
||||
value = payload.get(key)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise NotificationParseError(
|
||||
f"{key} должен быть непустой str, получено {type(value).__name__}={value!r}"
|
||||
)
|
||||
return value
|
||||
143
tradein-mvp/backend/app/services/payments/receipt.py
Normal file
143
tradein-mvp/backend/app/services/payments/receipt.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""Сборка объекта `Receipt` (54-ФЗ, ФФД 1.05) для чека Т-Банк эквайринга.
|
||||
|
||||
Продукт продаёт УСЛУГУ (не товар) — везде фиксированы `PaymentObject="service"`
|
||||
и `PaymentMethod="full_payment"` (одномоментная оплата за уже готовую услугу,
|
||||
без предоплат/кредита/частичных расчётов).
|
||||
|
||||
Схема (`Receipt` в `Init`, ФФД 1.05) — источник, снят живым запросом
|
||||
2026-08-06: https://developer.tbank.ru/eacq/api/init
|
||||
|
||||
- `Email` ИЛИ `Phone` — обязательно хотя бы одно (перекрёстный required).
|
||||
- `Taxation` — обязателен: `osn|usn_income|usn_income_outcome|esn|patent`.
|
||||
- `Items[].Name` — <=128 символов, обязателен.
|
||||
- `Items[].Price`/`Quantity`/`Amount` — числа, В КОПЕЙКАХ; `Amount` — это
|
||||
произведение `Price * Quantity` (дословно из API-reference).
|
||||
- `Items[].Tax` — ставка НДС. Актуальный список 2026 (Init API reference):
|
||||
`none|vat0|vat5|vat7|vat10|vat22|vat105|vat107|vat110|vat122`.
|
||||
`vat20`/`vat120` В СПИСКЕ НЕТ — сняты, не использовать (см. recon §6/§11
|
||||
в `mera-tbank-acquiring-recon.md`, корень репо).
|
||||
|
||||
ВАЖНО: `Receipt` НЕ участвует в расчёте `Token` (`token.py` отсекает любые
|
||||
вложенные `dict`/`list` из подписи) — это архитектурно гарантировано самой
|
||||
функцией `token.sign`, а не соглашением здесь.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
TaxRate = Literal[
|
||||
"none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122"
|
||||
]
|
||||
|
||||
Taxation = Literal["osn", "usn_income", "usn_income_outcome", "esn", "patent"]
|
||||
|
||||
_ALLOWED_TAX_RATES: frozenset[str] = frozenset(
|
||||
{"none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122"}
|
||||
)
|
||||
_ALLOWED_TAXATION: frozenset[str] = frozenset(
|
||||
{"osn", "usn_income", "usn_income_outcome", "esn", "patent"}
|
||||
)
|
||||
|
||||
_MAX_ITEM_NAME_LEN = 128
|
||||
_MAX_ITEMS = 100 # "Количество товаров в чеке — не больше 100" (API reference)
|
||||
|
||||
|
||||
class ReceiptBuildError(ValueError):
|
||||
"""Невалидные данные для сборки Receipt — не пройдёт валидацию Т-Банка."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReceiptItem:
|
||||
"""Одна позиция чека — услуга. `price_kopecks`/`quantity` — целые копейки/штуки."""
|
||||
|
||||
name: str
|
||||
price_kopecks: int
|
||||
quantity: int = 1
|
||||
tax: TaxRate = "none"
|
||||
|
||||
@property
|
||||
def amount_kopecks(self) -> int:
|
||||
"""Items[].Amount = Price * Quantity (дословно из API reference)."""
|
||||
return self.price_kopecks * self.quantity
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
if not self.name or len(self.name) > _MAX_ITEM_NAME_LEN:
|
||||
raise ReceiptBuildError(
|
||||
f"Items[].Name должен быть 1..{_MAX_ITEM_NAME_LEN} символов, "
|
||||
f"получено {len(self.name)}"
|
||||
)
|
||||
if self.price_kopecks <= 0:
|
||||
raise ReceiptBuildError("Items[].Price должен быть > 0 (в копейках)")
|
||||
if self.quantity <= 0:
|
||||
raise ReceiptBuildError("Items[].Quantity должен быть > 0")
|
||||
if self.tax not in _ALLOWED_TAX_RATES:
|
||||
raise ReceiptBuildError(
|
||||
f"Items[].Tax={self.tax!r} не входит в актуальный список Т-Банка "
|
||||
f"({sorted(_ALLOWED_TAX_RATES)}) — vat20/vat120 сняты, не используются"
|
||||
)
|
||||
return {
|
||||
"Name": self.name,
|
||||
"Price": self.price_kopecks,
|
||||
"Quantity": self.quantity,
|
||||
"Amount": self.amount_kopecks,
|
||||
"Tax": self.tax,
|
||||
"PaymentMethod": "full_payment",
|
||||
"PaymentObject": "service",
|
||||
}
|
||||
|
||||
|
||||
def build_receipt(
|
||||
*,
|
||||
items: list[ReceiptItem],
|
||||
taxation: Taxation,
|
||||
email: str | None = None,
|
||||
phone: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Собирает `Receipt` (ФФД 1.05) для одного заказа (может быть >1 позиции).
|
||||
|
||||
Инвариант «сумма Items[].Amount == Init.Amount» здесь НЕ проверяется —
|
||||
`Receipt` строится независимо от `Init`-payload заказа. Сверка — на
|
||||
вызывающей стороне (`service.py`, следующий PR) через
|
||||
`receipt_total_kopecks(receipt) == init_amount_kopecks`. См. тест
|
||||
`test_receipt_total_matches_order_amount_invariant` в
|
||||
`tests/test_payments_receipt.py`, который проверяет именно эту сверку.
|
||||
"""
|
||||
if not items:
|
||||
raise ReceiptBuildError("Receipt.Items не может быть пустым")
|
||||
if len(items) > _MAX_ITEMS:
|
||||
raise ReceiptBuildError(f"Receipt.Items — не больше {_MAX_ITEMS} позиций")
|
||||
if taxation not in _ALLOWED_TAXATION:
|
||||
raise ReceiptBuildError(
|
||||
f"Taxation={taxation!r} не входит в допустимый список ({sorted(_ALLOWED_TAXATION)})"
|
||||
)
|
||||
|
||||
email_norm = (email or "").strip() or None
|
||||
phone_norm = (phone or "").strip() or None
|
||||
if not email_norm and not phone_norm:
|
||||
raise ReceiptBuildError("Нужно указать Email или Phone (хотя бы одно)")
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"Taxation": taxation,
|
||||
"Items": [item.to_payload() for item in items],
|
||||
}
|
||||
if email_norm:
|
||||
payload["Email"] = email_norm
|
||||
if phone_norm:
|
||||
payload["Phone"] = phone_norm
|
||||
return payload
|
||||
|
||||
|
||||
def receipt_total_kopecks(receipt: dict[str, Any]) -> int:
|
||||
"""Сумма `Items[].Amount` — для сверки вызывающей стороной с `Init.Amount`."""
|
||||
items = receipt.get("Items")
|
||||
if not isinstance(items, list):
|
||||
return 0
|
||||
total = 0
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
amount = item.get("Amount")
|
||||
if isinstance(amount, int):
|
||||
total += amount
|
||||
return total
|
||||
279
tradein-mvp/backend/app/services/payments/tbank_client.py
Normal file
279
tradein-mvp/backend/app/services/payments/tbank_client.py
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
"""httpx-клиент Т-Банк эквайринга (Init/GetState/CheckOrder/Confirm/Cancel).
|
||||
|
||||
Стиль и обработка ошибок — по образцу
|
||||
`app.services.tgbot.client.TelegramClient`: единственные нужные методы,
|
||||
не тянем отдельный SDK ради пяти HTTP-вызовов.
|
||||
|
||||
Модуль НЕ импортирует `app.core.config` — все параметры (`terminal_key`,
|
||||
`password`, `base_url`) передаются в конструктор явно аргументами.
|
||||
Архитектурное ограничение PR-C (см. `app/services/payments/__init__.py`):
|
||||
параллельный PR-B вводит эти поля в `config.py`, проводку делает PR-D.
|
||||
|
||||
Docs: https://developer.tbank.ru/eacq/api
|
||||
|
||||
Ретраи:
|
||||
- Сетевые ошибки (timeout/connect) и HTTP 5xx — экспоненциальный backoff,
|
||||
capped на `_MAX_BACKOFF_S`.
|
||||
- Любая 4xx — НЕ ретраится (запрос некорректен / права не те — повтор
|
||||
транспортного вызова не поможет), сразу `TBankApiError`.
|
||||
- Бизнес-отказ (HTTP 200, но `Success: false` в теле) — тоже НЕ
|
||||
ретраится: это содержательный ответ банка, а не сбой транспорта.
|
||||
|
||||
БЮДЖЕТ ВРЕМЕНИ (важно для PR-D): worst case одного вызова любого метода —
|
||||
около 74 с (4 попытки × `_DEFAULT_TIMEOUT_S`=15 с = 60 с, плюс backoff между
|
||||
попытками 2+4+8=14 с при `_DEFAULT_MAX_RETRIES`=3). Т-Банк даёт на ответ на
|
||||
нотификацию окно порядка 10 с — этот бюджет в 74 с в него заведомо не
|
||||
укладывается. Значит: исходящий HTTP-вызов к `TBankClient` (в т.ч.
|
||||
`get_state`/`confirm`/`cancel` для сверки/реконсиляции по нотификации)
|
||||
ВНУТРИ обработчика публичной ручки нотификации ЗАПРЕЩЁН — обработчик обязан
|
||||
только валидировать/сохранить событие и ответить `"OK"`, а любая сверка с
|
||||
банком (`GetState`/`CheckOrder`) — асинхронно, вне HTTP-цикла ответа банку.
|
||||
|
||||
БЕЗОПАСНОСТЬ: `password` и `Token` НИКОГДА не попадают в `logger.*` —
|
||||
логируем только имя метода, HTTP-статус, `ErrorCode`/`Message`/`Details`
|
||||
из ответа банка.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.services.payments.token import sign
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_TIMEOUT_S = 15.0
|
||||
_MAX_BACKOFF_S = 30.0
|
||||
_DEFAULT_MAX_RETRIES = 3
|
||||
|
||||
DEFAULT_BASE_URL = "https://securepay.tinkoff.ru"
|
||||
|
||||
|
||||
class TBankApiError(Exception):
|
||||
"""T-Bank Acquiring API ответил ошибкой (HTTP-ошибка или `Success: false`)."""
|
||||
|
||||
def __init__(self, method: str, error_code: str, message: str, details: str = "") -> None:
|
||||
self.method = method
|
||||
self.error_code = error_code
|
||||
self.message = message
|
||||
self.details = details
|
||||
text = f"T-Bank API {method} failed: [{error_code}] {message}"
|
||||
if details:
|
||||
text += f" — {details}"
|
||||
super().__init__(text)
|
||||
|
||||
|
||||
def _error_from_body(response: httpx.Response) -> tuple[str, str, str]:
|
||||
"""Парсит (ErrorCode, Message, Details) из тела ответа; fallback на HTTP-статус."""
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return str(response.status_code), (response.text or "")[:200], ""
|
||||
if not isinstance(data, dict):
|
||||
return str(response.status_code), str(data)[:200], ""
|
||||
error_code = str(data.get("ErrorCode", response.status_code))
|
||||
message = str(data.get("Message", ""))
|
||||
details = str(data.get("Details", ""))
|
||||
return error_code, message, details
|
||||
|
||||
|
||||
class TBankClient:
|
||||
"""Клиент Т-Банк эквайринга на `httpx.AsyncClient`.
|
||||
|
||||
Каждый вызов — отдельное короткоживущее соединение (без общего
|
||||
connection-pool между вызовами; частота вызовов в checkout-потоке
|
||||
низкая, держать долгоживущий клиент не нужно — тот же паттерн, что
|
||||
`TelegramClient`).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
terminal_key: str,
|
||||
password: str,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
timeout: float = _DEFAULT_TIMEOUT_S,
|
||||
) -> None:
|
||||
self._terminal_key = terminal_key
|
||||
self._password = password
|
||||
self._base = f"{base_url.rstrip('/')}/v2"
|
||||
self._timeout = timeout
|
||||
|
||||
def _signed_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Добавляет `TerminalKey` + `Token`. Сам `password` в тело не уходит."""
|
||||
body: dict[str, Any] = {"TerminalKey": self._terminal_key, **payload}
|
||||
body["Token"] = sign(body, self._password)
|
||||
return body
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
max_retries: int = _DEFAULT_MAX_RETRIES,
|
||||
) -> dict[str, Any]:
|
||||
"""POST `method` с подписанным JSON-телом. Ретраит network/5xx, иначе raise сразу."""
|
||||
body = self._signed_payload(payload)
|
||||
url = f"{self._base}/{method}"
|
||||
attempt = 0
|
||||
|
||||
while True:
|
||||
attempt += 1
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
response = await client.post(url, json=body)
|
||||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||||
if attempt > max_retries:
|
||||
logger.error(
|
||||
"tbank client: %s — network error после %d попыток: %s",
|
||||
method,
|
||||
attempt,
|
||||
exc,
|
||||
)
|
||||
raise TBankApiError(method, "network_error", str(exc)) from exc
|
||||
backoff = min(2.0**attempt, _MAX_BACKOFF_S)
|
||||
logger.warning(
|
||||
"tbank client: %s — network error (попытка %d/%d) — retry через %.0fs",
|
||||
method,
|
||||
attempt,
|
||||
max_retries,
|
||||
backoff,
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
|
||||
if response.status_code >= 500:
|
||||
if attempt > max_retries:
|
||||
error_code, message, details = _error_from_body(response)
|
||||
logger.error(
|
||||
"tbank client: %s — HTTP %d после %d попыток, сдаёмся",
|
||||
method,
|
||||
response.status_code,
|
||||
attempt,
|
||||
)
|
||||
raise TBankApiError(method, error_code, message, details)
|
||||
backoff = min(2.0**attempt, _MAX_BACKOFF_S)
|
||||
logger.warning(
|
||||
"tbank client: %s — HTTP %d (попытка %d/%d) — retry через %.0fs",
|
||||
method,
|
||||
response.status_code,
|
||||
attempt,
|
||||
max_retries,
|
||||
backoff,
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
|
||||
if response.status_code >= 400:
|
||||
# 4xx кроме сетевых сценариев выше — запрос некорректен, повтор не поможет.
|
||||
error_code, message, details = _error_from_body(response)
|
||||
raise TBankApiError(method, error_code, message, details)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
raise TBankApiError(method, "invalid_json", str(exc)) from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise TBankApiError(method, "invalid_response", "тело ответа — не JSON-объект")
|
||||
|
||||
if not data.get("Success"):
|
||||
error_code = str(data.get("ErrorCode", response.status_code))
|
||||
message = str(data.get("Message", ""))
|
||||
details = str(data.get("Details", ""))
|
||||
raise TBankApiError(method, error_code, message, details)
|
||||
|
||||
return data
|
||||
|
||||
async def init_payment(
|
||||
self,
|
||||
*,
|
||||
order_id: str,
|
||||
amount_kopecks: int,
|
||||
description: str = "",
|
||||
notification_url: str | None = None,
|
||||
success_url: str | None = None,
|
||||
fail_url: str | None = None,
|
||||
receipt: dict[str, Any] | None = None,
|
||||
pay_type: str | None = None,
|
||||
data: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""`POST /v2/Init` — инициирует платёж, возвращает `PaymentId` + `PaymentURL`.
|
||||
|
||||
КОНТРАКТ ДЛЯ PR-D (обработка сетевой ошибки вызывающей стороной):
|
||||
после `TBankApiError` от `Init` (в т.ч. `error_code == "network_error"` —
|
||||
таймаут/обрыв) НЕЛЬЗЯ слепо повторять `init_payment()` с тем же
|
||||
`order_id` — неизвестно, дошёл ли исходный запрос до банка до обрыва
|
||||
соединения. Слепой повтор может создать ВТОРОЙ холд на тот же
|
||||
`OrderId`. Разбираться нужно через `check_order(order_id=...)` —
|
||||
он возвращает уже существующие платежи по заказу — и только по его
|
||||
результату решать, нужен ли новый `Init`.
|
||||
"""
|
||||
payload: dict[str, Any] = {"OrderId": order_id, "Amount": amount_kopecks}
|
||||
if description:
|
||||
payload["Description"] = description
|
||||
if notification_url:
|
||||
payload["NotificationURL"] = notification_url
|
||||
if success_url:
|
||||
payload["SuccessURL"] = success_url
|
||||
if fail_url:
|
||||
payload["FailURL"] = fail_url
|
||||
if receipt:
|
||||
payload["Receipt"] = receipt
|
||||
if pay_type:
|
||||
payload["PayType"] = pay_type
|
||||
if data:
|
||||
payload["DATA"] = data
|
||||
return await self._request("Init", payload)
|
||||
|
||||
async def get_state(self, *, payment_id: str) -> dict[str, Any]:
|
||||
"""`POST /v2/GetState` — статус платежа по `PaymentId`."""
|
||||
return await self._request("GetState", {"PaymentId": payment_id})
|
||||
|
||||
async def check_order(self, *, order_id: str) -> dict[str, Any]:
|
||||
"""`POST /v2/CheckOrder` — список платежей по `OrderId` (для реконсиляции)."""
|
||||
return await self._request("CheckOrder", {"OrderId": order_id})
|
||||
|
||||
async def confirm(
|
||||
self,
|
||||
*,
|
||||
payment_id: str,
|
||||
amount_kopecks: int | None = None,
|
||||
receipt: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""`POST /v2/Confirm` — подтверждение холда (двухстадийная оплата, `PayType=T`).
|
||||
|
||||
КОНТРАКТ ДЛЯ PR-D (обработка ошибки вызывающей стороной): после
|
||||
`TBankApiError` от `Confirm` (в т.ч. сетевой таймаут) слепой вызов
|
||||
`cancel()` для того же `payment_id` ЗАПРЕЩЁН. Таймаут/обрыв мог
|
||||
прийти УЖЕ ПОСЛЕ того, как банк фактически подтвердил холд —
|
||||
`Confirm` состоялся на стороне банка, а ответ до клиента не дошёл.
|
||||
В этом случае `cancel()` вернёт клиенту уже захваченные деньги.
|
||||
Правильная последовательность: сначала `get_state(payment_id=...)`,
|
||||
и только по актуальному статусу решать, нужен ли `cancel()`.
|
||||
"""
|
||||
payload: dict[str, Any] = {"PaymentId": payment_id}
|
||||
if amount_kopecks is not None:
|
||||
payload["Amount"] = amount_kopecks
|
||||
if receipt:
|
||||
payload["Receipt"] = receipt
|
||||
return await self._request("Confirm", payload)
|
||||
|
||||
async def cancel(
|
||||
self,
|
||||
*,
|
||||
payment_id: str,
|
||||
amount_kopecks: int | None = None,
|
||||
receipt: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""`POST /v2/Cancel` — отмена/возврат (полный, если `amount_kopecks` не передан)."""
|
||||
payload: dict[str, Any] = {"PaymentId": payment_id}
|
||||
if amount_kopecks is not None:
|
||||
payload["Amount"] = amount_kopecks
|
||||
if receipt:
|
||||
payload["Receipt"] = receipt
|
||||
return await self._request("Cancel", payload)
|
||||
141
tradein-mvp/backend/app/services/payments/token.py
Normal file
141
tradein-mvp/backend/app/services/payments/token.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""Подпись `Token` запросов Т-Банк эквайринга и проверка подписи нотификаций.
|
||||
|
||||
Docs (проверено живым запросом к doc-порталу, 2026-08-06):
|
||||
- https://developer.tbank.ru/eacq/intro/developer/token — формирование Token.
|
||||
- https://developer.tbank.ru/eacq/intro/developer/notification
|
||||
(раздел «Проверить токен уведомлений») — тот же алгоритм для входящих
|
||||
нотификаций.
|
||||
|
||||
Алгоритм (идентичен для исходящего запроса и для проверки нотификации):
|
||||
|
||||
1. Берём ТОЛЬКО плоские поля payload: исключаем ключ `Token`, исключаем
|
||||
`None`, исключаем значения-`dict`/`list` (документация формулирует это
|
||||
как «кроме параметра Token и вложенных объектов (Data, Receipt)» —
|
||||
здесь обобщено до правила по ТИПУ значения, а не по имени ключа: любые
|
||||
вложенные объекты/массивы, будь то `Receipt`, `DATA`, `Data`, `Items`
|
||||
или `Shops`, отсекаются одинаково, потому что все они не примитивы).
|
||||
2. `bool` → `"true"`/`"false"` (нижний регистр); `int` → строка через `str()`;
|
||||
`str` — как есть. `float` НЕ поддерживается — падаем явной ошибкой (формат
|
||||
дробных чисел не задокументирован Т-Банком, см. `_stringify_value`).
|
||||
3. Добавляем пару `Password: <пароль_терминала>`.
|
||||
4. Сортируем пары по имени ключа (лексикографически по строке ключа),
|
||||
конкатенируем ТОЛЬКО значения (не ключи и не имена) в одну строку.
|
||||
5. SHA-256 (UTF-8) от строки, hex-digest в нижнем регистре.
|
||||
|
||||
Эталонные векторы (см. `tests/test_payments_token.py`) сняты дословно с
|
||||
doc-портала — оба подтверждены живым запросом, не выдуманы.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_EXCLUDED_KEYS = frozenset({"Token"})
|
||||
|
||||
|
||||
class TokenSigningError(ValueError):
|
||||
"""Поле не может быть однозначно сериализовано в подписываемую строку."""
|
||||
|
||||
|
||||
def _stringify_value(value: bool | int | float | str) -> str:
|
||||
"""Приводит плоское значение к строке по правилам Т-Банка.
|
||||
|
||||
`bool` проверяем ДО `int`: в Python `bool` — подкласс `int`
|
||||
(`isinstance(True, int) is True`), поэтому порядок веток важен —
|
||||
иначе `True` попал бы в ветку int и дал `"1"` вместо `"true"`.
|
||||
|
||||
`float` НЕ поддерживается — падаем с `TokenSigningError`, а не
|
||||
угадываем формат. Документация Т-Банка не описывает сериализацию
|
||||
дробных чисел в подписи; прежняя реализация (`format(value, "f")` +
|
||||
rstrip нулей) была неподтверждённой догадкой, и она расходится с тем,
|
||||
что реально уходит в JSON-теле запроса: `0.1 + 0.2` подписывался бы
|
||||
как `"0.3"`, а `json.dumps(0.1 + 0.2)` даёт `"0.30000000000000004"` —
|
||||
Token не соответствовал бы фактическому телу. Денежные суммы (`Amount`)
|
||||
в этом API всегда целые копейки (`int`); для денежного пути правильнее
|
||||
явно упасть на нецелом значении, чем подписать не то, что уйдёт в сеть.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
if isinstance(value, float):
|
||||
raise TokenSigningError(
|
||||
f"float в подписываемых полях не поддерживается (получено {value!r}) — "
|
||||
"формат дробных чисел не описан в документации Т-Банка, см. docstring "
|
||||
"_stringify_value"
|
||||
)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _flatten_signable_fields(payload: dict[str, Any]) -> dict[str, str]:
|
||||
"""Плоские поля payload, готовые к конкатенации: без Token/None/dict/list."""
|
||||
result: dict[str, str] = {}
|
||||
for key, value in payload.items():
|
||||
if key in _EXCLUDED_KEYS or value is None:
|
||||
continue
|
||||
if isinstance(value, dict | list):
|
||||
continue
|
||||
result[key] = _stringify_value(value)
|
||||
return result
|
||||
|
||||
|
||||
def sign(payload: dict[str, Any], password: str) -> str:
|
||||
"""Считает `Token` для исходящего запроса (Init/GetState/CheckOrder/...).
|
||||
|
||||
`payload` — тело запроса ДО добавления поля `Token` (поле `Password`
|
||||
самому передавать не нужно — функция добавляет его сама и удаляет
|
||||
участие любых вложенных объектов автоматически).
|
||||
"""
|
||||
fields = _flatten_signable_fields(payload)
|
||||
fields["Password"] = password
|
||||
raw = "".join(fields[key] for key in sorted(fields))
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def verify_notification_token(payload: dict[str, Any], password: str) -> bool:
|
||||
"""Проверяет `Token` входящей нотификации: пересчёт + `hmac.compare_digest`.
|
||||
|
||||
`payload` — полное тело нотификации, включая присланный `Token` (сам
|
||||
алгоритм сборки исключает ключ `Token` из подписи — см. `_EXCLUDED_KEYS`).
|
||||
|
||||
Возвращает `False`, если в payload нет строкового непустого `Token`
|
||||
(нечего сравнивать) — вызывающая сторона обязана трактовать это как
|
||||
отказ в обработке нотификации, а не как «пропустить проверку».
|
||||
|
||||
НИКОГДА не поднимает исключение — на любом враждебном/мусорном входе
|
||||
(не `dict`, не-ASCII `Token`, поля, которые ломают сериализацию внутри
|
||||
`sign()`) возвращает `False`. Это обязательное свойство для публичной
|
||||
ручки нотификации (PR-D): необработанное исключение здесь — это
|
||||
неаутентифицированный HTTP 500 в ответ банку, а любой ответ, отличный
|
||||
от `"OK"`, банк трактует как временный сбой и ретраит уведомление
|
||||
почасово в течение суток. Конкретные причины двух проверок ниже:
|
||||
- `payload` не `dict` (например список) → `.get()` кинул бы
|
||||
`AttributeError` без явной проверки типа;
|
||||
- `Token` с не-ASCII символами → `hmac.compare_digest` на строках
|
||||
требует ASCII и иначе кидает `TypeError` (документированное
|
||||
ограничение stdlib, не баг).
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
received_token = payload.get("Token")
|
||||
if not isinstance(received_token, str) or not received_token or not received_token.isascii():
|
||||
return False
|
||||
try:
|
||||
expected_token = sign(payload, password)
|
||||
except Exception:
|
||||
# Мусорное поле где-то ещё в payload (например float — см.
|
||||
# `_stringify_value`) не должно валить проверку подписи в исключение.
|
||||
logger.warning(
|
||||
"verify_notification_token: sign() упал на входящем payload — трактуем как отказ",
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
try:
|
||||
return hmac.compare_digest(expected_token, received_token)
|
||||
except TypeError:
|
||||
return False
|
||||
|
|
@ -21,8 +21,10 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from scraper_kit.orchestration import runs as kit_runs
|
||||
from scraper_kit.orchestration.scheduler import (
|
||||
Handler,
|
||||
reschedule_after_minutes,
|
||||
|
|
@ -39,39 +41,97 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
# ── cian_history_backfill — cookie-gated backfill ────────────────────────────
|
||||
# Машиночитаемые причины пропуска (#2658) — пишутся в scrape_runs.error строки
|
||||
# со status='skipped'. Отделены от kit-причин (already_running и т.п.): по слагу
|
||||
# видно, встал ли сбор из-за кук или из-за конкурентного прогона.
|
||||
SKIP_CIAN_COOKIES_MISSING = "cian_cookies_missing"
|
||||
SKIP_CIAN_COOKIES_EXPIRED = "cian_cookies_expired"
|
||||
SKIP_CIAN_COOKIES_INVALID = "cian_cookies_invalid"
|
||||
|
||||
|
||||
def _alert_cian_cookies(source: str, detail: str) -> None:
|
||||
"""Громкий алерт «сбор встал из-за кук» — logger.error, НЕ capture_message(warning).
|
||||
|
||||
В scraper-контейнере GlitchTip поднят с LoggingIntegration(event_level=ERROR)
|
||||
(scheduler_main.py) — ERROR-запись сама становится событием, а прежний
|
||||
`capture_message(..., level="warning")` до этого уровня не дотягивал (и стоял в
|
||||
недостижимой ветке, см. докстринг _cian_pre_claim). Заодно причина остаётся в
|
||||
docker-логах и в строке scrape_runs, которая переживает редеплой.
|
||||
"""
|
||||
logger.error(
|
||||
"scheduler: %s пропущен — %s. Перезалейте куки Циана через админку "
|
||||
"(до этого backfill истории стоит)",
|
||||
source,
|
||||
detail,
|
||||
)
|
||||
|
||||
|
||||
async def _cian_pre_claim(db: Session, schedule_row: dict[str, Any], ctx: SchedulerContext) -> bool:
|
||||
"""Pre-claim gate: проверить наличие/валидность cian-cookies ДО claim (#1522).
|
||||
|
||||
Cookies отсутствуют/протухли → defer next_run_at на следующее окно и skip
|
||||
(иначе get_due_schedules переотбирает schedule каждые 60с и verify_session
|
||||
долбит Cian круглосуточно). Дословно из боевого trigger_cian_backfill_run.
|
||||
"""
|
||||
import sentry_sdk
|
||||
Cookies отсутствуют/протухли → пишем строку прогона status='skipped' с причиной,
|
||||
двигаем next_run_at на следующее окно и skip (иначе get_due_schedules переотбирает
|
||||
schedule каждые 60с и verify_session долбит Cian круглосуточно).
|
||||
|
||||
from app.services.cian_session import load_session, verify_session
|
||||
#2658 — что было не так. Первая ветка (load_session вернул None) молчала: warning в
|
||||
docker-лог, сдвиг next_run_at, `return False`. Ни строки в scrape_runs, ни изменения
|
||||
last_run_at — снаружи 37 дней простоя выглядели как «всё по расписанию». Sentry-алерт
|
||||
стоял во ВТОРОЙ ветке (verify_session вернул None), до которой на протухших куках
|
||||
исполнение не доходит НИКОГДА: load_session сам фильтрует expires_at_estimate > NOW()
|
||||
и отдаёт None ещё в первой. Теперь громко в обеих + предупреждение ЗАРАНЕЕ, пока куки
|
||||
ещё валидны (COOKIE_EXPIRY_WARN_DAYS) — обновление кук ручное, ему нужен запас.
|
||||
"""
|
||||
from app.services.cian_session import (
|
||||
COOKIE_EXPIRY_WARN_DAYS,
|
||||
load_session,
|
||||
session_expires_at,
|
||||
verify_session,
|
||||
)
|
||||
|
||||
source: str = schedule_row["source"]
|
||||
now = datetime.now(tz=UTC)
|
||||
|
||||
cookies = load_session(db)
|
||||
if cookies is None:
|
||||
logger.warning("scheduler: cian_history_backfill skipped — no valid session cookies in DB")
|
||||
expires_at = session_expires_at(db)
|
||||
if expires_at is None:
|
||||
reason, detail = SKIP_CIAN_COOKIES_MISSING, "кук Циана нет в БД"
|
||||
elif expires_at <= now:
|
||||
reason = SKIP_CIAN_COOKIES_EXPIRED
|
||||
detail = (
|
||||
f"куки Циана протухли {expires_at:%Y-%m-%d} ({(now - expires_at).days} дн. назад)"
|
||||
)
|
||||
else:
|
||||
reason = SKIP_CIAN_COOKIES_INVALID
|
||||
detail = "куки Циана помечены невалидными (last_invalid_at)"
|
||||
_alert_cian_cookies(source, detail)
|
||||
kit_runs.mark_skipped(db, source=source, reason=reason, details=detail)
|
||||
kit_defer_next_run_at(db, schedule_row)
|
||||
return False
|
||||
|
||||
state = await verify_session(cookies)
|
||||
if state is None:
|
||||
logger.warning(
|
||||
"scheduler: cian_history_backfill — cookies expired or invalid, skipping run"
|
||||
)
|
||||
try:
|
||||
sentry_sdk.capture_message(
|
||||
"cian_history_backfill skipped: Cian session cookies expired — "
|
||||
"please re-upload via admin UI",
|
||||
level="warning",
|
||||
)
|
||||
except Exception:
|
||||
pass # sentry_sdk not initialised in dev
|
||||
# verify вернул именно None (401 / isAuthenticated=false) — куки числятся
|
||||
# валидными по сроку, но Циан их не принимает. Sentinel-ответы (бан / источник
|
||||
# недоступен / сменилась вёрстка) сюда НЕ попадают, они truthy — см. cian_session.
|
||||
detail = "Циан не принимает куки (разлогин)"
|
||||
_alert_cian_cookies(source, detail)
|
||||
kit_runs.mark_skipped(db, source=source, reason=SKIP_CIAN_COOKIES_INVALID, details=detail)
|
||||
kit_defer_next_run_at(db, schedule_row)
|
||||
return False
|
||||
|
||||
# Куки рабочие — предупреждаем, пока есть время их обновить без простоя сбора.
|
||||
# valid_only=True: срок ИМЕННО той записи, которую взял load_session (при нескольких
|
||||
# аккаунтах свежайшая-любая может быть чужой протухшей строкой).
|
||||
expires_at = session_expires_at(db, valid_only=True)
|
||||
if expires_at is not None and expires_at - now <= timedelta(days=COOKIE_EXPIRY_WARN_DAYS):
|
||||
logger.error(
|
||||
"scheduler: куки Циана протухнут %s (осталось %.1f дн.) — обновите заранее, "
|
||||
"иначе %s встанет молча",
|
||||
expires_at.date().isoformat(),
|
||||
(expires_at - now).total_seconds() / 86400,
|
||||
source,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
|
|
@ -156,12 +216,26 @@ async def _job_deactivate_stale(
|
|||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
) -> None:
|
||||
from app.core.config import settings as _settings
|
||||
from app.tasks.deactivate_stale_avito import deactivate_stale_listings
|
||||
from app.tasks.deactivate_stale_avito import (
|
||||
DEFAULT_MIN_CONFIRMATIONS,
|
||||
DEFAULT_REVISIT_FLOOR_QUANTILE,
|
||||
deactivate_stale_listings,
|
||||
)
|
||||
|
||||
listing_source: str = params.get("listing_source", "avito")
|
||||
ttl_days: int = params.get("ttl_days", _settings.avito_stale_ttl_days)
|
||||
segments: list[str] | None = params.get("segments")
|
||||
staleness_column: str = params.get("staleness_column", "last_seen_at")
|
||||
# Гейт по здоровью сбора (#2659) включён по умолчанию: незасеянное расписание
|
||||
# получает страховочный порог, а не «деактивируй вслепую». Посчитанные по
|
||||
# источнику пороги приходят из default_params (миграция 219).
|
||||
min_confirmations: int = params.get("min_confirmations", DEFAULT_MIN_CONFIRMATIONS)
|
||||
# Пол TTL по измеренному циклу переобхода (#2659) — тоже включён по умолчанию:
|
||||
# незасеянное расписание не должно снимать объявления по порогу ниже собственного
|
||||
# хвоста обхода. Снять ручку вручную: revisit_floor_quantile = 0.
|
||||
revisit_floor_quantile: float = params.get(
|
||||
"revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
|
|
@ -173,6 +247,8 @@ async def _job_deactivate_stale(
|
|||
ttl_days=ttl_days,
|
||||
segments=segments,
|
||||
staleness_column=staleness_column,
|
||||
min_confirmations=min_confirmations,
|
||||
revisit_floor_quantile=revisit_floor_quantile,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -282,6 +358,19 @@ async def _job_domclick_detail_backfill(
|
|||
await run_domclick_detail_backfill(db, run_id=run_id, params=params)
|
||||
|
||||
|
||||
# ── house_coords_from_listings — sync set-based UPDATE в executor (#2771) ─────
|
||||
async def _job_house_coords_from_listings(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
) -> None:
|
||||
from app.tasks.backfill_house_coords_from_listings import run_house_coords_from_listings
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: run_house_coords_from_listings(db, run_id=run_id, params=params),
|
||||
)
|
||||
|
||||
|
||||
# ── geoportal_coords_backfill — sync local exact match в executor (#1967) ─────
|
||||
async def _job_geoportal_coords_backfill(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
|
|
@ -341,17 +430,38 @@ async def _job_house_imv_backfill(
|
|||
only_status=only_status,
|
||||
heartbeat=_heartbeat,
|
||||
)
|
||||
ctx.runs.mark_done(
|
||||
db,
|
||||
run_id,
|
||||
{
|
||||
"checked": result.checked,
|
||||
"saved": result.saved,
|
||||
"skipped": result.skipped,
|
||||
"errors": result.errors,
|
||||
"duration_sec": int(result.duration_sec),
|
||||
},
|
||||
)
|
||||
counters = {
|
||||
"checked": result.checked,
|
||||
"saved": result.saved,
|
||||
"skipped": result.skipped,
|
||||
"errors": result.errors,
|
||||
"duration_sec": int(result.duration_sec),
|
||||
# #2674: _column_counts (scrape_runs.py) берёт выделенные колонки из
|
||||
# ключей total_seen|lots_fetched и new_count|lots_inserted — ни одного
|
||||
# из них тут не было, поэтому все 39 прогонов этого source лежат в БД
|
||||
# с total_seen=0. А mark_done по этой же колонке шлёт алерт «3 подряд
|
||||
# done с нулевым результатом» (#2625) — то есть даже идеальный прогон
|
||||
# с 50 сохранёнными считался бы нулевым и через три дня выстрелил бы
|
||||
# ложной тревогой про капчу.
|
||||
# Трейд-офф: на исчерпанной очереди checked=0 три дня подряд тоже даст
|
||||
# алерт — но пустая очередь при ежедневном расписании это и правда сигнал.
|
||||
"total_seen": result.checked,
|
||||
"new_count": result.saved,
|
||||
}
|
||||
# Честный статус (#2674, тот же класс, что #2670/#2657): успех — это
|
||||
# «сделали то, что собирались», а не «не поймали известное исключение».
|
||||
# На проде так ушли в done 31 прогон подряд: saved=0 при errors≈35 из 50.
|
||||
# Ноль сохранённых БЕЗ ошибок (всё отфильтровано в skipped) — честная
|
||||
# пустота, она по-прежнему done.
|
||||
if result.saved == 0 and result.errors > 0:
|
||||
ctx.runs.mark_failed(
|
||||
db,
|
||||
run_id,
|
||||
f"saved=0 при errors={result.errors} (checked={result.checked})",
|
||||
counters,
|
||||
)
|
||||
else:
|
||||
ctx.runs.mark_done(db, run_id, counters)
|
||||
except Exception as exc:
|
||||
logger.exception("scheduler: house_imv_backfill crashed run_id=%d", run_id)
|
||||
try:
|
||||
|
|
@ -360,6 +470,75 @@ async def _job_house_imv_backfill(
|
|||
logger.exception("scheduler: mark_failed crashed run_id=%d", run_id)
|
||||
|
||||
|
||||
# ── domrf_kapremont_load — sync загрузка open data ДОМ.РФ в executor ─────────
|
||||
# #2674: loader (services/domrf_kapremont_loader.py) и CLI (tasks/domrf_kapremont_load.py)
|
||||
# написаны и покрыты тестами с #2013, но Handler'а и строки расписания не было — источник
|
||||
# запускали руками ровно один раз, 12.07.2026 (29 978 строк, один и тот же loaded_at у всех).
|
||||
# Это не мёртвый код, а оборванная проводка: нечему было его вызвать.
|
||||
async def _job_domrf_kapremont_load(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
) -> None:
|
||||
"""Скачать КР1.1+КР1.2 ДОМ.РФ → staging → backfill houses → propagate listings.
|
||||
|
||||
Тело переиспользует те же три функции, что и CLI (дизайн-инвариант модуля: не
|
||||
дублируем логику). Lifecycle не свой — mark_done/mark_failed здесь, как у
|
||||
_job_yandex_newbuilding_sweep.
|
||||
|
||||
Счётчики кладём в total_seen/new_count: `scrape_runs._column_counts` берёт выделенные
|
||||
колонки именно из этих ключей, и по ним же mark_done ловит «три подряд нулевых
|
||||
прогона» (#2625) — без них идеальный прогон лежал бы в БД как нулевой (тот же
|
||||
промах, что чинили у house_imv_backfill).
|
||||
"""
|
||||
from app.services.domrf_kapremont_loader import (
|
||||
backfill_houses_from_domrf,
|
||||
load_domrf_kapremont,
|
||||
propagate_listings_year_from_houses,
|
||||
)
|
||||
|
||||
def _run() -> dict[str, int]:
|
||||
load_counts = load_domrf_kapremont(db)
|
||||
db.commit()
|
||||
houses_counts = backfill_houses_from_domrf(db)
|
||||
listings_counts = propagate_listings_year_from_houses(db)
|
||||
db.commit()
|
||||
return {
|
||||
"kr11_rows": load_counts["kr11_rows"],
|
||||
"upserted": load_counts["upserted"],
|
||||
"houses_updated": houses_counts["houses_updated"],
|
||||
"listings_updated": listings_counts["listings_updated"],
|
||||
# см. докстринг: выделенные колонки прогона + гейт «нулевой прогон».
|
||||
"total_seen": load_counts["kr11_rows"],
|
||||
"new_count": houses_counts["houses_updated"] + listings_counts["listings_updated"],
|
||||
}
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
counters = await loop.run_in_executor(None, _run)
|
||||
ctx.runs.mark_done(db, run_id, counters)
|
||||
except Exception as exc:
|
||||
logger.exception("scheduler: domrf_kapremont_load crashed run_id=%d", run_id)
|
||||
db.rollback()
|
||||
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
|
||||
|
||||
|
||||
# ── purge_expired_trade_in_data — ЭТАП 4 B2C retention (152-ФЗ) ───────────────
|
||||
async def _job_purge_expired_trade_in_data(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
) -> None:
|
||||
from app.tasks.purge_expired_trade_in_data import purge_expired_trade_in_data
|
||||
|
||||
batch_size = params.get("batch_size")
|
||||
max_batches = params.get("max_batches")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: purge_expired_trade_in_data(
|
||||
db, run_id, batch_size=batch_size, max_batches=max_batches
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ── house_dedup_merge — sync destructive merge в executor, owns lifecycle ─────
|
||||
async def _job_house_dedup_merge(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
|
|
@ -394,8 +573,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
|||
"""Реестр НЕ-sweep продуктовых source→Handler для kit build_registry.
|
||||
|
||||
Kit-native sweeps (avito/yandex/cian/domclick city/full-load/newbuilding) НЕ здесь —
|
||||
их даёт build_registry(_default_kit_handlers). Здесь — 19 именованных + 1 wildcard
|
||||
их даёт build_registry(_default_kit_handlers). Здесь — именованные + 1 wildcard
|
||||
(deactivate_stale_*), покрывающие каждый НЕ-sweep source боевого scheduler-dispatch.
|
||||
(Число намеренно не названо: прежнее «19» разошлось с реальностью на пять записей.)
|
||||
|
||||
`ctx` — принят для симметрии контракта; сами Handler-job'ы получают ctx во время
|
||||
dispatch (см. kit `_dispatch`), поэтому здесь он не замыкается.
|
||||
|
|
@ -427,6 +607,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
|||
"geoportal_coords_backfill": Handler(
|
||||
_job_geoportal_coords_backfill, "geoportal_coords_backfill"
|
||||
),
|
||||
"house_coords_from_listings": Handler(
|
||||
_job_house_coords_from_listings, "house_coords_from_listings"
|
||||
),
|
||||
"geocode_missing_listings": Handler(
|
||||
_job_geocode_missing_listings, "geocode_missing_listings"
|
||||
),
|
||||
|
|
@ -439,6 +622,10 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
|||
"osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"),
|
||||
"house_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"),
|
||||
"house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"),
|
||||
"domrf_kapremont_load": Handler(_job_domrf_kapremont_load, "domrf_kapremont_load"),
|
||||
"purge_expired_trade_in_data": Handler(
|
||||
_job_purge_expired_trade_in_data, "purge_expired_trade_in_data"
|
||||
),
|
||||
"proxy_healthcheck": Handler(
|
||||
_job_proxy_healthcheck,
|
||||
"proxy_healthcheck",
|
||||
|
|
|
|||
301
tradein-mvp/backend/app/services/proxy_egress.py
Normal file
301
tradein-mvp/backend/app/services/proxy_egress.py
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
"""Резолвер egress-прокси по источнику для ad-hoc сессий вне scrape_run (#2825).
|
||||
|
||||
ПРОБЛЕМА (доказана на проде 2026-08-10): `settings.scraper_proxy_url` (и его алиасы
|
||||
`cian_proxy_url`/`yandex_proxy_url`, все три — прямая проекция ENV `SCRAPER_PROXY_URL`,
|
||||
см. `app.core.config`) был ЕДИНСТВЕННЫМ egress для всех curl_cffi/httpx-сессий, которые
|
||||
строятся напрямую в `app/services/*` и `app/tasks/*` МИМО `app.services.proxy_pool` /
|
||||
`scraper_kit`-оркестрации. При этом `scrape_proxy_source_bans` (миграция 210, #2600 п.2)
|
||||
аккуратно вела учёт банов по паре «узел × источник» — но эти прямые сессии её никогда
|
||||
не читали и месяц ходили через узел, забаненный и Avito, и Cian.
|
||||
|
||||
ЧТО ЭТОТ МОДУЛЬ НЕ ДЕЛАЕТ: не берёт lease. `app.services.proxy_pool.acquire()` уже
|
||||
реализует pick-с-учётом-банов, но с полной lease-семантикой (leased_by/release/
|
||||
reap_stale_leases) — она рассчитана на долгоживущие `scrape_run`/`BrowserFetcher`-сессии
|
||||
(см. `RealProxyProvider` в `app.services.scraper_adapters`). Вызывающие здесь — короткие
|
||||
одноразовые fetch'и (проверка cookies, одна detail-страница) без run_id и без
|
||||
гарантированного `release` на каждом пути выхода; занимать под них lease значило бы
|
||||
дырявить пул фантомно занятыми узлами при малейшей утечке release. Резолвер ниже —
|
||||
ЧИСТО READ, той же таблицы `scrape_proxies` + `scrape_proxy_source_bans`, без блокировок
|
||||
и без мутаций.
|
||||
|
||||
ПРАВИЛО ВЫБОРА: enabled=true, consecutive_fails < proxy_pool.MAX_CONSECUTIVE_FAILS
|
||||
(тот же карантинный порог, что у acquire), нет активной строки в
|
||||
scrape_proxy_source_bans для ЭТОГО source. Среди кандидатов — меньший consecutive_fails,
|
||||
при равенстве — более свежий last_ok_at (NULLS LAST). Не изобретаем ротацию/балансировку:
|
||||
это резолвер «дай рабочий прокси прямо сейчас», не lease-менеджер.
|
||||
|
||||
FAIL-CLOSED ПРОТИВ ТИХОГО ОБХОДА ПУЛА (#2616, deep-review этой правки): пул и статичный
|
||||
`SCRAPER_PROXY_URL` — РАЗНЫЕ вещи, и путать их нельзя. Два разных исхода "кандидата нет":
|
||||
|
||||
1. Пул ПУСТ (в `scrape_proxies` вообще нет строк — dev/staging без БД-пула, легитимный
|
||||
сценарий). Тогда fallback на `settings.scraper_proxy_url` ЛЕГИТИМЕН — пула для этого
|
||||
окружения попросту не существует, идти больше некуда. `logger.warning`.
|
||||
2. Пул НЕ пуст, но НИ ОДИН узел не прошёл фильтр для source (все забанены ИМЕННО для
|
||||
этого источника / нездоровы / выключены). Здесь fallback на `SCRAPER_PROXY_URL`
|
||||
ЗАПРЕЩЁН: инцидент 2026-08-10 — это ровно случай (2), узел статичной переменной был
|
||||
тем же самым забаненным узлом, что и в пуле, «резервный» путь тихо возвращал систему
|
||||
к первопричине. `resolve_proxy_url` в этом случае бросает `ProxyPoolExhaustedError` —
|
||||
вызывающий обязан явно отказаться от запроса (`logger.error`), а не соскользнуть на
|
||||
env в обход учёта банов.
|
||||
|
||||
НАБЛЮДАЕМОСТЬ: при выборе из пула логируем label/host:port (БЕЗ credentials — url
|
||||
несёт логин/пароль, в лог никогда не идёт целиком) и id узла; при legit-fallback —
|
||||
warning с текстом «пуст» (сценарий 1); при exhaustion — error с разбивкой
|
||||
banned_for_source/unhealthy_or_disabled (сценарий 2) — тексты НАМЕРЕННО разные, чтобы
|
||||
их нельзя было спутать в логах/алертах.
|
||||
|
||||
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings as _settings
|
||||
from app.core.db import SessionLocal as _SessionLocal
|
||||
from app.services.proxy_pool import MAX_CONSECUTIVE_FAILS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["ProxyPoolExhaustedError", "resolve_proxy_url", "resolve_proxy_url_sync"]
|
||||
|
||||
|
||||
class ProxyPoolExhaustedError(RuntimeError):
|
||||
"""Пул `scrape_proxies` НЕ пуст, но ни один узел не прошёл фильтр для `source`
|
||||
(все забанены именно для этого источника / нездоровы / выключены).
|
||||
|
||||
Fail-closed (#2616): вызывающий обязан явно отказаться от запроса (пропустить run
|
||||
с понятным логом), а НЕ уйти в обход пула через статичный
|
||||
`settings.scraper_proxy_url` — тот самый узел мог быть источником текущего
|
||||
инцидента (см. module docstring, сценарий 2).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: str,
|
||||
*,
|
||||
pool_total: int,
|
||||
banned_for_source: int,
|
||||
unhealthy_or_disabled: int,
|
||||
) -> None:
|
||||
self.source = source
|
||||
self.pool_total = pool_total
|
||||
self.banned_for_source = banned_for_source
|
||||
self.unhealthy_or_disabled = unhealthy_or_disabled
|
||||
super().__init__(
|
||||
f"proxy pool exhausted for source={source!r}: pool_total={pool_total} "
|
||||
f"banned_for_source={banned_for_source} unhealthy_or_disabled={unhealthy_or_disabled}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Candidate:
|
||||
id: int
|
||||
url: str
|
||||
label: str | None
|
||||
|
||||
|
||||
def _safe_label(proxy_id: int, label: str | None, url: str) -> str:
|
||||
"""host:port для логов — НИКОГДА не credentials из url (userinfo)."""
|
||||
if label:
|
||||
return label
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
host = parts.hostname or "?"
|
||||
return f"{host}:{parts.port}" if parts.port else host
|
||||
except ValueError:
|
||||
return f"proxy#{proxy_id}"
|
||||
|
||||
|
||||
def _pick_candidate(db: Session, source: str) -> _Candidate | None:
|
||||
"""READ-ONLY выбор egress для source. Без FOR UPDATE — резолвер не арендует узел."""
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, url, label
|
||||
FROM scrape_proxies
|
||||
WHERE enabled
|
||||
AND consecutive_fails < CAST(:max_fails AS integer)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM scrape_proxy_source_bans b
|
||||
WHERE b.proxy_id = scrape_proxies.id
|
||||
AND b.source = CAST(:source AS text)
|
||||
AND b.banned_until > now()
|
||||
)
|
||||
ORDER BY consecutive_fails ASC, last_ok_at DESC NULLS LAST, id
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"max_fails": MAX_CONSECUTIVE_FAILS, "source": source},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
# Чистое чтение без блокировок — ничего не коммитим/не откатываем намеренно,
|
||||
# оставляем управление транзакцией вызывающему коду (тот же db может быть в
|
||||
# середине более широкой операции).
|
||||
if row is None:
|
||||
return None
|
||||
return _Candidate(id=int(row["id"]), url=str(row["url"]), label=row["label"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ExhaustionDiag:
|
||||
"""Разбивка причин "кандидата нет" — ТОЛЬКО когда пул реально не пуст (сценарий 2
|
||||
в докстринге модуля). Используется исключительно для diagnostic-лога/исключения."""
|
||||
|
||||
pool_total: int
|
||||
banned_for_source: int
|
||||
unhealthy_or_disabled: int
|
||||
|
||||
|
||||
def _diagnose_no_candidate(db: Session, source: str) -> _ExhaustionDiag:
|
||||
"""Отдельный запрос, вызывается ТОЛЬКО когда основной SELECT кандидата вернул
|
||||
пусто — не платим за агрегаты в happy-path (кандидат найден с первого запроса)."""
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
count(*) AS pool_total,
|
||||
count(*) FILTER (
|
||||
WHERE NOT enabled
|
||||
OR consecutive_fails >= CAST(:max_fails AS integer)
|
||||
) AS unhealthy_or_disabled,
|
||||
count(*) FILTER (
|
||||
WHERE enabled
|
||||
AND consecutive_fails < CAST(:max_fails AS integer)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM scrape_proxy_source_bans b
|
||||
WHERE b.proxy_id = scrape_proxies.id
|
||||
AND b.source = CAST(:source AS text)
|
||||
AND b.banned_until > now()
|
||||
)
|
||||
) AS banned_for_source
|
||||
FROM scrape_proxies
|
||||
"""
|
||||
),
|
||||
{"max_fails": MAX_CONSECUTIVE_FAILS, "source": source},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
if row is None: # pragma: no cover — count(*) всегда возвращает строку
|
||||
return _ExhaustionDiag(pool_total=0, banned_for_source=0, unhealthy_or_disabled=0)
|
||||
return _ExhaustionDiag(
|
||||
pool_total=int(row["pool_total"]),
|
||||
banned_for_source=int(row["banned_for_source"]),
|
||||
unhealthy_or_disabled=int(row["unhealthy_or_disabled"]),
|
||||
)
|
||||
|
||||
|
||||
def resolve_proxy_url(db: Session, source: str) -> str | None:
|
||||
"""Egress-URL для source (avito/cian/yandex/domclick) — пул с учётом банов пары
|
||||
«узел × источник». См. докстринг модуля за разбором двух РАЗНЫХ исходов
|
||||
"кандидата нет":
|
||||
|
||||
- пул пуст (0 строк в `scrape_proxies`) → fallback на
|
||||
`settings.scraper_proxy_url`, `logger.warning`, легитимный dev/staging-сценарий;
|
||||
- пул не пуст, все отсеяны (баны/health/disabled) → `ProxyPoolExhaustedError`
|
||||
(`logger.error`), fail-closed — БЕЗ прохода через статичный env.
|
||||
|
||||
БД пула недоступна (connection error и т.п., напр. dev-окружение без поднятой БД)
|
||||
— трактуем КАК пустой пул (не можем подтвердить exhaustion — небезопасно поднимать
|
||||
error/исключение по неполным данным), `logger.warning` + explicit (не silent
|
||||
failure). Отличается от сценария exhaustion: там мы ТОЧНО знаем, что узлы есть и
|
||||
все отсеяны; здесь мы вообще ничего не знаем о пуле.
|
||||
"""
|
||||
try:
|
||||
candidate = _pick_candidate(db, source)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"proxy_egress: source=%s -- пул scrape_proxies недоступен (ошибка БД), "
|
||||
"лечим как пустой пул (fallback на статичный SCRAPER_PROXY_URL)",
|
||||
source,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
# Ошибка на execute() оставляет сессию в aborted-транзакции (psycopg/PG:
|
||||
# "current transaction is aborted") — если db переживёт этот вызов
|
||||
# (долгоживущая caller-сессия, напр. avito_detail_backfill/
|
||||
# yandex_detail_backfill), последующие запросы на ней иначе все падали
|
||||
# бы с той же ошибкой, маскируя реальную причину.
|
||||
db.rollback()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"proxy_egress: source=%s -- rollback после сбоя пула тоже не удался",
|
||||
source,
|
||||
exc_info=True,
|
||||
)
|
||||
return _settings.scraper_proxy_url
|
||||
|
||||
if candidate is not None:
|
||||
logger.info(
|
||||
"proxy_egress: source=%s -> pool proxy id=%d (%s)",
|
||||
source,
|
||||
candidate.id,
|
||||
_safe_label(candidate.id, candidate.label, candidate.url),
|
||||
)
|
||||
return candidate.url
|
||||
|
||||
diag = _diagnose_no_candidate(db, source)
|
||||
|
||||
if diag.pool_total == 0:
|
||||
# Сценарий 1: пул для этого окружения попросту не сконфигурирован
|
||||
# (dev/staging без БД-пула) — легитимный fallback.
|
||||
fallback = _settings.scraper_proxy_url
|
||||
if fallback:
|
||||
logger.warning(
|
||||
"proxy_egress: source=%s -- пул scrape_proxies ПУСТ (0 записей), "
|
||||
"окружение без БД-пула -- идём через статичный SCRAPER_PROXY_URL "
|
||||
"(fallback)",
|
||||
source,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"proxy_egress: source=%s -- пул scrape_proxies пуст и SCRAPER_PROXY_URL "
|
||||
"не задан, идём прямым подключением без прокси",
|
||||
source,
|
||||
)
|
||||
return fallback
|
||||
|
||||
# Сценарий 2: пул РЕАЛЬНО не пуст, но для source не осталось ни одного
|
||||
# здорового/небаненного узла -- fail-closed (#2616), НЕ fallback на env.
|
||||
logger.error(
|
||||
"proxy_egress: source=%s -- пул scrape_proxies НЕ пуст (%d узлов), но НИ ОДИН "
|
||||
"не прошёл фильтр для этого источника (banned_for_source=%d, "
|
||||
"unhealthy_or_disabled=%d) -- FAIL-CLOSED (#2616): отказ, БЕЗ обхода через "
|
||||
"статичный SCRAPER_PROXY_URL (тот самый узел мог быть источником инцидента)",
|
||||
source,
|
||||
diag.pool_total,
|
||||
diag.banned_for_source,
|
||||
diag.unhealthy_or_disabled,
|
||||
)
|
||||
raise ProxyPoolExhaustedError(
|
||||
source,
|
||||
pool_total=diag.pool_total,
|
||||
banned_for_source=diag.banned_for_source,
|
||||
unhealthy_or_disabled=diag.unhealthy_or_disabled,
|
||||
)
|
||||
|
||||
|
||||
def resolve_proxy_url_sync(source: str) -> str | None:
|
||||
"""Как `resolve_proxy_url`, но сама открывает короткую `SessionLocal()` — для
|
||||
вызывающих без готового `db` в сигнатуре (напр. `cian_session.verify_session`).
|
||||
|
||||
`ProxyPoolExhaustedError` из `resolve_proxy_url` пробрасывается как есть (fail-closed) —
|
||||
вызывающий обязан явно её поймать и решить, как деградировать (см. call site'ы).
|
||||
"""
|
||||
db = _SessionLocal()
|
||||
try:
|
||||
return resolve_proxy_url(db, source)
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -77,6 +77,42 @@ Sticky session lease (browser-путь, живая регрессия 2026-08):
|
|||
на каждый /fetch, чтобы reap_stale_leases не отобрал прокси у многочасового
|
||||
прогона.
|
||||
|
||||
Два тракта — два диагноза (#2723):
|
||||
- ipify-проба (`_probe_proxy`) отвечает на «узел жив вообще» и владеет
|
||||
consecutive_fails / enabled / exit_ip. Такт — каждый прогон healthcheck (30 мин).
|
||||
- браузерная проба (`_run_browser_probe` → сайдкар → camoufox с ЭТИМ прокси →
|
||||
навигация) отвечает на «через узел работает браузерный тракт» и владеет
|
||||
browser_fail_streak / browser_unfit_since / browser_check_at (миграция 228).
|
||||
Такт свой, редкий (BROWSER_PROBE_MINUTES) — она стоит запуска camoufox.
|
||||
Пересечения нет: успешная ipify-проба НЕ обнуляет browser_fail_streak (иначе
|
||||
дешёвая проба каждые 30 минут стирает вердикт дорогого тракта — узел, мёртвый для
|
||||
браузера, вечно возвращается в выдачу), провал браузерной пробы НЕ выключает узел
|
||||
(он жив, просто не для этого тракта). Схлопнуть их в один флаг = повторить #2686.
|
||||
«Непригоден для браузера» — это НЕ исключение из пула: acquire() лишь отдаёт такой
|
||||
узел последним (ORDER BY), потому что при 4 узлах (#2638) голодание хуже.
|
||||
|
||||
Проба на ПАРУ «узел × источник» (#2800, продолжение #2723):
|
||||
- #2723 починил ТРАНСПОРТ пробы (ходить браузером, как работа). Ходила она при этом
|
||||
для всех узлов на один зашитый адрес — robots.txt Авито. Прокси-узел не «жив/мёртв»
|
||||
вообще: замер на проде 09.08.2026 — узел id=1 отдаёт 200 на Авито и Яндексе и 500
|
||||
NS_ERROR_PROXY_BAD_GATEWAY на рабочем хосте Домклика, имея browser_fail_streak=0 и
|
||||
свежую пробу. Зелёная проба означала «годен для Авито», а читалась как «годен».
|
||||
- Теперь каждый узел за такт опрашивается по КАЖДОМУ источнику, который ему может
|
||||
достаться (browser_fetcher.PROBE_SOURCES ∩ affinity), по РАБОЧЕМУ хосту площадки
|
||||
(apex-домен не годится: `domclick.ru` через узел id=1 отвечает 200, а
|
||||
`bff-search-web.domclick.ru`, куда ходит сбор, — 500).
|
||||
- Вердикт пары пишется В СУЩЕСТВУЮЩУЮ таблицу scrape_proxy_source_bans (новой
|
||||
сущности не заводим — эта ровно про пару и её уже читает acquire): подтверждённый
|
||||
отказ → строка бана с reason=_PROBE_BAN_REASON, успех → снятие СВОЕЙ строки.
|
||||
Чужие строки (бан, распознанный боевым сбором) проба не трогает — robots.txt
|
||||
площадка отдаёт и забаненному IP, так что дешёвый успех не имеет права стирать
|
||||
дорогой вердикт живого сбора (тот же принцип, что «ipify не стирает браузерный»).
|
||||
- Узловые поля (browser_fail_streak/browser_unfit_since) сохраняют своё значение
|
||||
«браузерный тракт через узел не работает ВООБЩЕ» и обновляются по итогу ВСЕГО
|
||||
креста: хоть одна зелёная площадка → ok; все красные транспортом → провал узла.
|
||||
Отказ одной площадки узел глобально не пятнает — иначе мы бы своими руками
|
||||
вернули то самое схлопывание диагнозов.
|
||||
|
||||
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
|
||||
"""
|
||||
|
||||
|
|
@ -90,9 +126,13 @@ import httpx
|
|||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings as _settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"BROWSER_PROBE_MINUTES",
|
||||
"BROWSER_UNFIT_THRESHOLD",
|
||||
"DISABLED_RECHECK_MINUTES",
|
||||
"DISABLE_THRESHOLD",
|
||||
"MAX_CONSECUTIVE_FAILS",
|
||||
|
|
@ -105,7 +145,9 @@ __all__ = [
|
|||
"acquire",
|
||||
"clear_source_bans",
|
||||
"mark_banned",
|
||||
"mark_browser_health",
|
||||
"mark_health",
|
||||
"mark_source_probe",
|
||||
"reap_stale_leases",
|
||||
"release",
|
||||
"run_proxy_healthcheck",
|
||||
|
|
@ -155,6 +197,47 @@ SOURCE_BAN_PURGE_DAYS = 7
|
|||
_HEALTH_PROBE_URL = "https://api.ipify.org"
|
||||
_HEALTH_PROBE_TIMEOUT_S = 10.0
|
||||
|
||||
# ── браузерная проба узла (#2723) ────────────────────────────────────────────
|
||||
# Такт браузерной пробы. Решено по замеру, не по ощущению (прод, 06.08.2026):
|
||||
# - одна браузерная проба = 8.3с и один запуск camoufox;
|
||||
# - боевая нагрузка сайдкара = ~42 /fetch и ~8 запусков camoufox в час
|
||||
# (≈1000 и ≈190 в сутки);
|
||||
# - такт ipify-пробы = 30 мин → 48 прогонов healthcheck в сутки.
|
||||
# Гнать браузерную пробу каждым прогоном по 4 узлам = +192 запуска camoufox в сутки,
|
||||
# то есть УДВОЕНИЕ самой дорогой операции сайдкара ради диагностики. 360 мин даёт
|
||||
# 4 пробы на узел в сутки: +16 запусков (+8% к запускам, +1.6% к запросам) — цена,
|
||||
# которую видно только в логе. Отказ, пойманный с задержкой до 6 часов, всё равно
|
||||
# ловится в разы раньше, чем сейчас (не ловится вовсе).
|
||||
BROWSER_PROBE_MINUTES = 360
|
||||
|
||||
# Столько подряд-провалов браузерной пробы (атрибутированных узлу) переводят узел в
|
||||
# browser_unfit. Не 1: запуск camoufox бывает флаки сам по себе, а пометка — операция
|
||||
# с последствиями при пуле из 4 узлов. Не 5 (как DISABLE_THRESHOLD): при редком такте
|
||||
# это были бы сутки. Второе подтверждение приходит на СЛЕДУЮЩЕМ прогоне healthcheck
|
||||
# (~30 мин), а не через полный такт — browser_check_at на неподтверждённом провале
|
||||
# намеренно не обновляется (см. mark_browser_health).
|
||||
BROWSER_UNFIT_THRESHOLD = 2
|
||||
|
||||
# ── проба на пару «узел × источник» (#2800) ──────────────────────────────────
|
||||
# ЦЕНА, посчитанная до правки (замер 09.08.2026, тот же тракт):
|
||||
# - было: 4 узла × 1 адрес / 360 мин = 16 навигаций в сутки, все на Авито;
|
||||
# - стало: 4 узла × 4 источника / 360 мин = 64 навигации в сутки, то есть
|
||||
# 16 robots.txt НА ПЛОЩАДКУ в сутки против ~1000 боевых /fetch;
|
||||
# - одна проба 9–18 с (замерено) → такт с крестом ~3 мин против ~50 с; прогонов
|
||||
# healthcheck с браузерной пробой по-прежнему 4 в сутки (гейт browser_check_at).
|
||||
# Запусков camoufox НЕ прибавляется пропорционально: сайдкар релончит браузер при
|
||||
# смене ЖЕЛАЕМОГО прокси, а крест идёт узел-за-узлом — 4 релонча за такт, как и было.
|
||||
# Разрежённая схема (по одному источнику за такт, round-robin) рассматривалась и
|
||||
# отвергнута: вердикт пары протухал бы до 24 ч при бане в 6 ч — окно, в котором
|
||||
# acquire снова выдаёт узел, не спросив.
|
||||
#
|
||||
# Причина в scrape_proxy_source_bans, которой владеет ИМЕННО проба. Отличает её
|
||||
# вердикт от бана, распознанного боевым сбором (mark_banned из report_ban): успешная
|
||||
# проба снимает ТОЛЬКО свои строки. Без этого дешёвый robots.txt, который площадка
|
||||
# отдаёт и забаненному IP, стирал бы дорогой вердикт живого сбора — ровно ошибка
|
||||
# #2723 («дешёвая проба стирает вердикт дорогого тракта»), только на паре.
|
||||
_PROBE_BAN_REASON = "probe:browser"
|
||||
|
||||
# deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для
|
||||
# mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что
|
||||
# в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не
|
||||
|
|
@ -188,14 +271,21 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
|||
чужая — только запасной вариант, чтобы источник не голодал при живых свободных узлах
|
||||
чужой affinity (#2600).
|
||||
|
||||
Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity — см.
|
||||
173_scrape_proxies_add_domclick_affinity.sql: у domclick ровно один узел (id=1),
|
||||
намеренно вырезанный из общего пула, потому что QRATOR банит все прокси кроме этого
|
||||
одного чистого residential-адреса. Если fallback заберёт его под avito/cian/yandex,
|
||||
domclick останется без прокси вообще — хуже, чем голодание исходного источника,
|
||||
которое фикс призван устранить. Кандидат участвует в fallback, только если его
|
||||
affinity='any' ИЛИ у этой affinity есть ДРУГОЙ enabled-узел (EXISTS-подзапрос) —
|
||||
т.е. выдача не обнулит доступность выделенной affinity целиком.
|
||||
Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity: если
|
||||
fallback заберёт его под чужой источник, «свой» останется без прокси вообще — хуже,
|
||||
чем голодание исходного источника, которое фикс призван устранить. Кандидат
|
||||
участвует в fallback, только если его affinity='any' ИЛИ у этой affinity есть ДРУГОЙ
|
||||
enabled-узел (EXISTS-подзапрос) — т.е. выдача не обнулит доступность выделенной
|
||||
affinity целиком.
|
||||
|
||||
Исторический повод для этой защиты (173_scrape_proxies_add_domclick_affinity.sql —
|
||||
единственный residential-узел id=1, закреплённый за domclick, потому что QRATOR
|
||||
банил остальные) снят миграцией 253 (#2800): живая проба показала, что как раз до
|
||||
рабочего хоста Домклика (bff-search-web.domclick.ru) этот узел НЕ доходит, а
|
||||
Авито/Яндекс через него работают — резервация держала узел за источником, которому
|
||||
он не годен, и прятала от тех, кому годен. Узлов с выделенной affinity на проде
|
||||
сейчас нет, но САМА защита остаётся: значение 'domclick' допустимо констрейнтом, и
|
||||
следующий выделенный узел должен получить её сразу, а не после повторного разбора.
|
||||
|
||||
ОБА запроса отсекают узлы с АКТИВНЫМ баном по ЭТОМУ provider'у
|
||||
(scrape_proxy_source_bans.banned_until > now(), #2600 п.2) — узел, забаненный Авито,
|
||||
|
|
@ -213,7 +303,7 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
|||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, url, kind, rotate_url
|
||||
SELECT id, url, kind, rotate_url, browser_unfit_since
|
||||
FROM scrape_proxies
|
||||
WHERE enabled
|
||||
AND consecutive_fails < CAST(:max_fails AS integer)
|
||||
|
|
@ -226,7 +316,9 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
|||
AND b.source = :provider
|
||||
AND b.banned_until > now()
|
||||
)
|
||||
ORDER BY last_ok_at NULLS LAST, id
|
||||
-- browser_unfit последним (#2723): узел, живой для HTTP, но не для
|
||||
-- браузера, из пула НЕ исключается — только уходит в конец очереди.
|
||||
ORDER BY (browser_unfit_since IS NOT NULL), last_ok_at NULLS LAST, id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
"""
|
||||
|
|
@ -247,7 +339,7 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
|||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT sp.id, sp.url, sp.kind, sp.rotate_url
|
||||
SELECT sp.id, sp.url, sp.kind, sp.rotate_url, sp.browser_unfit_since
|
||||
FROM scrape_proxies AS sp
|
||||
WHERE sp.enabled
|
||||
AND sp.consecutive_fails < CAST(:max_fails AS integer)
|
||||
|
|
@ -283,7 +375,8 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
|||
)
|
||||
)
|
||||
)
|
||||
ORDER BY sp.last_ok_at NULLS LAST, sp.id
|
||||
-- см. ORDER BY основного запроса (#2723)
|
||||
ORDER BY (sp.browser_unfit_since IS NOT NULL), sp.last_ok_at NULLS LAST, sp.id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
"""
|
||||
|
|
@ -323,6 +416,19 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
|||
logger.info(
|
||||
"proxy_pool: leased proxy id=%d provider=%s by=%s", proxy_id, provider, lease_marker
|
||||
)
|
||||
if row["browser_unfit_since"] is not None:
|
||||
# Узел помечен непригодным для браузера (#2723), но всё равно выдан — значит
|
||||
# пригодных свободных не осталось. Голодание хуже работы через плохой узел
|
||||
# (та же политика, что у защиты последнего узла в mark_banned), но молчать об
|
||||
# этом нельзя: для браузерного источника это заведомо обречённый прогон.
|
||||
logger.warning(
|
||||
"proxy_pool: leased proxy id=%d provider=%s — узел BROWSER-UNFIT с %s "
|
||||
"(жив для HTTP, браузерный тракт через него не работает). Выдан потому, "
|
||||
"что пригодных свободных узлов нет — пул надо пополнять (#2638).",
|
||||
proxy_id,
|
||||
provider,
|
||||
row["browser_unfit_since"],
|
||||
)
|
||||
return ProxyLease(
|
||||
id=proxy_id,
|
||||
url=str(row["url"]),
|
||||
|
|
@ -480,9 +586,182 @@ def mark_health(
|
|||
)
|
||||
|
||||
|
||||
def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
||||
def mark_browser_health(
|
||||
db: Session,
|
||||
proxy_id: int,
|
||||
ok: bool,
|
||||
*,
|
||||
fail_kind: str | None = None,
|
||||
detail: str = "",
|
||||
) -> str:
|
||||
"""Записать результат БРАУЗЕРНОЙ пробы узла (#2723). Returns исход для счётчиков.
|
||||
|
||||
ЧЕМ ОТЛИЧАЕТСЯ ОТ mark_health: тем же, чем «нас забанила площадка» отличается от
|
||||
«у нас упал сайдкар» (#2686/#2711) — это ДРУГОЙ диагноз, а не другое значение того
|
||||
же. mark_health отвечает на «узел жив вообще» и владеет
|
||||
consecutive_fails/enabled/exit_ip. Эта функция отвечает на «через узел работает
|
||||
браузерный тракт» и владеет browser_fail_streak/browser_unfit_since/
|
||||
browser_check_at. Пересечения нет НИ В ОДНУ сторону, и это главное:
|
||||
|
||||
- успешная ipify-проба НЕ обнуляет browser_fail_streak. До #2723 обнуляла бы
|
||||
(через consecutive_fails=0) — узел, мёртвый для браузера, выходил из карантина
|
||||
каждые ≤30 минут и снова забирал прогон;
|
||||
- провал браузерной пробы НЕ инкрементит consecutive_fails и НЕ выключает узел:
|
||||
он жив, просто не для этого тракта.
|
||||
|
||||
ЧТО СЧИТАЕТСЯ ПРОВАЛОМ УЗЛА: только fail_kind == "proxy" (см.
|
||||
scraper_kit.browser_fetcher.classify_browser_probe). "sidecar" (сайдкар лежит) и
|
||||
"page" (площадка отдала пустое) узлу не принадлежат — засчитывать их значило бы
|
||||
пометить непригодными ВСЕ узлы разом при одной упавшей общей зависимости, то есть
|
||||
повторить #2686 ещё раз и уже с последствиями для всего пула.
|
||||
|
||||
ТАКТ ПРИ ПРОВАЛЕ: browser_check_at обновляется только когда провал ПОДТВЕРЖДЁН
|
||||
(streak дошёл до BROWSER_UNFIT_THRESHOLD). На первом, ещё не подтверждённом
|
||||
провале поле остаётся старым → следующий же прогон healthcheck (~30 мин) повторит
|
||||
пробу и либо подтвердит отказ, либо снимет подозрение. Иначе подтверждения ждали бы
|
||||
полный BROWSER_PROBE_MINUTES.
|
||||
|
||||
Returns: "ok" | "refit" (узел был непригоден и починился) | "unfit" (только что
|
||||
помечен непригодным) | "fail" (провал засчитан, порог не достигнут) | "ignored"
|
||||
(провал не принадлежит узлу).
|
||||
"""
|
||||
if ok:
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE scrape_proxies AS sp
|
||||
SET browser_fail_streak = 0,
|
||||
browser_unfit_since = NULL,
|
||||
browser_check_at = now(),
|
||||
updated_at = now()
|
||||
-- prev — pre-image строки: RETURNING отдаёт УЖЕ обновлённые
|
||||
-- значения (browser_unfit_since там всегда NULL), а нам нужно
|
||||
-- знать, была ли это реанимация непригодного узла.
|
||||
FROM (
|
||||
SELECT id, browser_unfit_since
|
||||
FROM scrape_proxies
|
||||
WHERE id = CAST(:id AS bigint)
|
||||
) AS prev
|
||||
WHERE sp.id = prev.id
|
||||
RETURNING (prev.browser_unfit_since IS NOT NULL) AS was_unfit
|
||||
"""
|
||||
),
|
||||
{"id": proxy_id},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
db.commit()
|
||||
was_unfit = bool(row["was_unfit"]) if row is not None else False
|
||||
logger.info(
|
||||
"proxy_pool: browser probe OK id=%d (%s)%s",
|
||||
proxy_id,
|
||||
detail,
|
||||
" — узел снова пригоден для браузера" if was_unfit else "",
|
||||
)
|
||||
return "refit" if was_unfit else "ok"
|
||||
|
||||
if fail_kind != "proxy":
|
||||
logger.warning(
|
||||
"proxy_pool: browser probe FAILED id=%d, но отказ НЕ принадлежит узлу "
|
||||
"(fail_kind=%s): %s — browser_fail_streak не трогаем",
|
||||
proxy_id,
|
||||
fail_kind,
|
||||
detail,
|
||||
)
|
||||
return "ignored"
|
||||
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE scrape_proxies
|
||||
SET browser_fail_streak = browser_fail_streak + 1,
|
||||
browser_unfit_since = CASE
|
||||
WHEN browser_fail_streak + 1 >= CAST(:threshold AS integer)
|
||||
AND browser_unfit_since IS NULL
|
||||
THEN now() ELSE browser_unfit_since
|
||||
END,
|
||||
browser_check_at = CASE
|
||||
WHEN browser_fail_streak + 1 >= CAST(:threshold AS integer)
|
||||
THEN now() ELSE browser_check_at
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE id = CAST(:id AS bigint)
|
||||
RETURNING browser_fail_streak, browser_unfit_since
|
||||
"""
|
||||
),
|
||||
{"threshold": BROWSER_UNFIT_THRESHOLD, "id": proxy_id},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
db.commit()
|
||||
if row is None:
|
||||
logger.warning("proxy_pool: mark_browser_health id=%d not found — no-op", proxy_id)
|
||||
return "ignored"
|
||||
|
||||
streak = int(row["browser_fail_streak"])
|
||||
if streak >= BROWSER_UNFIT_THRESHOLD:
|
||||
logger.warning(
|
||||
"proxy_pool: proxy id=%d BROWSER-UNFIT (browser_fail_streak=%d) — жив для "
|
||||
"обычного HTTP, но браузерный тракт через него не работает: %s. Узел "
|
||||
"ОСТАЁТСЯ в пуле (enabled не тронут, curl-путь работает), но acquire() "
|
||||
"теперь отдаёт его последним (#2723).",
|
||||
proxy_id,
|
||||
streak,
|
||||
detail,
|
||||
)
|
||||
return "unfit"
|
||||
logger.warning(
|
||||
"proxy_pool: browser probe FAILED id=%d (browser_fail_streak=%d/%d, порог не "
|
||||
"достигнут — перепроверим на следующем прогоне): %s",
|
||||
proxy_id,
|
||||
streak,
|
||||
BROWSER_UNFIT_THRESHOLD,
|
||||
detail,
|
||||
)
|
||||
return "fail"
|
||||
|
||||
|
||||
def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = None) -> str:
|
||||
"""Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2.
|
||||
|
||||
Returns: "banned" (строка записана/продлена) | "deferred" (активная строка пары
|
||||
принадлежит другому вердикту, владельца не меняем) | "protected" (защита последнего
|
||||
узла) | "missing" (нет такого proxy_id).
|
||||
|
||||
`reason` попадает в одноимённую колонку и служит МЕТКОЙ ВЛАДЕЛЬЦА строки: по
|
||||
умолчанию 'banned:<source>' (бан распознан боевым сбором), у браузерной пробы —
|
||||
_PROBE_BAN_REASON (#2800). Снимать чужую строку никто не должен, поэтому
|
||||
clear_source_bans умеет фильтровать по ней (`only_reason`).
|
||||
|
||||
ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ (дефект #2803, реализовался на проде 09.08.2026:
|
||||
пара (1, cian) была `banned:cian, ban_count=1, до 00:21`, упавшая проба через
|
||||
ON CONFLICT переписала её в `probe:browser, ban_count=2, до 07:43`). Фильтр
|
||||
«снимаю только своё» защищает лишь до тех пор, пока чужую строку нельзя ПРИСВОИТЬ:
|
||||
присвоенная строка становится «своей», и следующая успешная проба снимает ею бан,
|
||||
который поставил боевой сбор по настоящему отказу площадки. Плюс теряется
|
||||
происхождение: 'banned:cian' («площадка нас отбила») и 'probe:browser' («наша проба
|
||||
не смогла») — разные факты с разными последствиями (ровно ловушка #2764), а ban_count
|
||||
начинает считать события РАЗНОГО рода одной эскалацией (на проде это удлинило отдых
|
||||
пары с 6 ч до 12 ч).
|
||||
|
||||
Правило в `WHERE` у DO UPDATE: строку берём, если она ИСТЕКЛА (живого владельца нет),
|
||||
ИЛИ она уже наша (та же метка — обычная эскалация), ИЛИ мы боевой сбор (`live_reason`).
|
||||
Иначе — ничего: ни reason, ни ban_count, ни срок. Продлевать чужой бан «безвредно»
|
||||
только на словах: срок пересчитывается от now() по НАШЕЙ эскалации и способен
|
||||
УКОРОТИТЬ уже эскалированный чужой бан. Бан и так стоит — делать нечего.
|
||||
|
||||
АСИММЕТРИЯ НАМЕРЕННАЯ: боевой сбор строку пробы перехватывает. Его вердикт сильнее
|
||||
(площадка реально отбила именно сейчас), пара остаётся забаненной, а метка становится
|
||||
ТОЧНЕЕ. Запретить ему это значило бы оставить строку за пробой — и её же зелёный
|
||||
robots.txt снёс бы настоящий бан площадки, то есть тот самый дефект, только зеркально
|
||||
и хуже. Цена перехвата — ban_count наследуется (отдых чуть длиннее заслуженного);
|
||||
обнулять его на смене владельца нельзя: тогда запись пробы стирала бы память об
|
||||
эскалации боевых банов пары.
|
||||
|
||||
Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и
|
||||
авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация —
|
||||
транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно
|
||||
|
|
@ -541,6 +820,10 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
|||
сюда попадают уже обёрнутыми в try/except, но сам mark_banned ошибки БД не глотает
|
||||
(падает как обычно) — caller решает, ловить или нет.
|
||||
"""
|
||||
# Метка боевого сбора: право перехватить АКТИВНУЮ строку пары есть только у неё
|
||||
# (см. докстринг "ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ").
|
||||
live_reason = f"banned:{source}"
|
||||
effective_reason = reason or live_reason
|
||||
# Сериализует check+insert ниже с другими конкурентными mark_banned (см. докстринг
|
||||
# "КОНКУРЕНТНОСТЬ"). Держится до db.commit()/rollback() этой транзакции.
|
||||
db.execute(
|
||||
|
|
@ -612,13 +895,20 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
|||
) AS integer)),
|
||||
reason = CAST(:reason AS text),
|
||||
updated_at = now()
|
||||
-- Владельца АКТИВНОЙ строки не меняем: берём истёкшую (владельца нет),
|
||||
-- свою же (обычная эскалация) или перебиваем боевым сбором — он сильнее
|
||||
-- пробы. Иначе 0 rows и ветка "deferred" ниже (дефект #2803).
|
||||
WHERE scrape_proxy_source_bans.banned_until <= now()
|
||||
OR scrape_proxy_source_bans.reason = CAST(:reason AS text)
|
||||
OR CAST(:reason AS text) = CAST(:live_reason AS text)
|
||||
RETURNING ban_count, banned_until
|
||||
"""
|
||||
),
|
||||
{
|
||||
"proxy_id": proxy_id,
|
||||
"source": source,
|
||||
"reason": f"banned:{source}",
|
||||
"reason": effective_reason,
|
||||
"live_reason": live_reason,
|
||||
"base_hours": SOURCE_BAN_BASE_HOURS,
|
||||
"max_hours": SOURCE_BAN_MAX_HOURS,
|
||||
"max_fails": MAX_CONSECUTIVE_FAILS,
|
||||
|
|
@ -638,15 +928,44 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
|||
row["banned_until"],
|
||||
row["ban_count"],
|
||||
)
|
||||
return
|
||||
return "banned"
|
||||
|
||||
# 0 rows — ТРИ разные причины, и путать их нельзя: чужой активный владелец, защита
|
||||
# последнего узла, отсутствующий узел. Читаем состояние ТОЛЬКО ради точного лога
|
||||
# (на решение уже не влияет), но диагноз должен называть то, что произошло.
|
||||
holder = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT reason, banned_until
|
||||
FROM scrape_proxy_source_bans
|
||||
WHERE proxy_id = CAST(:proxy_id AS bigint)
|
||||
AND source = CAST(:source AS text)
|
||||
AND banned_until > now()
|
||||
"""
|
||||
),
|
||||
{"proxy_id": proxy_id, "source": source},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
if holder is not None and holder["reason"] != effective_reason:
|
||||
logger.info(
|
||||
"proxy_pool: proxy id=%d source=%s — бан пары уже стоит от %r до %s; вердикт "
|
||||
"%r его НЕ перебивает (владельца активной строки меняет только боевой сбор, "
|
||||
"иначе проба присвоила бы чужой бан и потом сняла бы его как свой)",
|
||||
proxy_id,
|
||||
source,
|
||||
holder["reason"],
|
||||
holder["banned_until"],
|
||||
effective_reason,
|
||||
)
|
||||
return "deferred"
|
||||
|
||||
# 0 rows: либо узла нет, либо защита последнего узла отменила запись бана — читаем
|
||||
# текущее состояние ТОЛЬКО для точного лога (на решение уже не влияет).
|
||||
current = (
|
||||
db.execute(
|
||||
text(
|
||||
"SELECT enabled, disabled_reason FROM scrape_proxies "
|
||||
"WHERE id = CAST(:id AS bigint)"
|
||||
"SELECT enabled, disabled_reason FROM scrape_proxies WHERE id = CAST(:id AS bigint)"
|
||||
),
|
||||
{"id": proxy_id},
|
||||
)
|
||||
|
|
@ -655,17 +974,25 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
|||
)
|
||||
if current is None:
|
||||
logger.warning("proxy_pool: mark_banned id=%d not found — no-op", proxy_id)
|
||||
else:
|
||||
logger.warning(
|
||||
"proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для "
|
||||
"source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому "
|
||||
"источнику (голодание хуже, чем работа через забаненный узел).",
|
||||
proxy_id,
|
||||
source,
|
||||
)
|
||||
return "missing"
|
||||
logger.warning(
|
||||
"proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для "
|
||||
"source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому "
|
||||
"источнику (голодание хуже, чем работа через забаненный узел).",
|
||||
proxy_id,
|
||||
source,
|
||||
)
|
||||
return "protected"
|
||||
|
||||
|
||||
def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, reason: str) -> int:
|
||||
def clear_source_bans(
|
||||
db: Session,
|
||||
proxy_id: int,
|
||||
*,
|
||||
source: str | None = None,
|
||||
reason: str,
|
||||
only_reason: str | None = None,
|
||||
) -> int:
|
||||
"""Снять баны узла по источникам (#2600 п.2). Returns число снятых строк.
|
||||
|
||||
ЗАЧЕМ ОТДЕЛЬНАЯ РУЧКА: до п.2 ложный бан лечился оператором через
|
||||
|
|
@ -688,6 +1015,13 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
|
|||
SOURCE_BAN_BASE_HOURS.
|
||||
|
||||
`reason` идёт только в лог (человекочитаемый повод — «manual enable», «ip rotated»).
|
||||
|
||||
`only_reason` — ФИЛЬТР по колонке reason, т.е. «снимать только строки, которые
|
||||
написал я» (#2800). Нужен браузерной пробе: её успешный robots.txt — слабое
|
||||
свидетельство, площадка отдаёт его и забаненному IP, поэтому снимать им бан,
|
||||
распознанный боевым сбором по капче/QRATOR-заглушке, нельзя. Оператор и ротация
|
||||
IP этот фильтр НЕ ставят: там повод как раз объявить историю пары недействительной
|
||||
целиком. None — снимать всё, как и раньше.
|
||||
"""
|
||||
rows = db.execute(
|
||||
text(
|
||||
|
|
@ -695,10 +1029,11 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
|
|||
DELETE FROM scrape_proxy_source_bans
|
||||
WHERE proxy_id = CAST(:proxy_id AS bigint)
|
||||
AND (CAST(:source AS text) IS NULL OR source = CAST(:source AS text))
|
||||
AND (CAST(:only_reason AS text) IS NULL OR reason = CAST(:only_reason AS text))
|
||||
RETURNING source
|
||||
"""
|
||||
),
|
||||
{"proxy_id": proxy_id, "source": source},
|
||||
{"proxy_id": proxy_id, "source": source, "only_reason": only_reason},
|
||||
).fetchall()
|
||||
db.commit()
|
||||
if rows:
|
||||
|
|
@ -712,6 +1047,79 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
|
|||
return len(rows)
|
||||
|
||||
|
||||
def mark_source_probe(
|
||||
db: Session,
|
||||
proxy_id: int,
|
||||
*,
|
||||
source: str,
|
||||
ok: bool,
|
||||
fail_kind: str | None = None,
|
||||
detail: str = "",
|
||||
) -> str:
|
||||
"""Записать вердикт браузерной пробы по ПАРЕ «узел × источник» (#2800).
|
||||
|
||||
Пара — то, чего до сих пор не хватало: узел не «жив/мёртв» вообще, он годен или
|
||||
не годен КОНКРЕТНОЙ площадке. Хранилище для этого уже есть и его уже читает
|
||||
`acquire(source)` — `scrape_proxy_source_bans`; новой сущности не заводим.
|
||||
|
||||
КОМУ ПРИНАДЛЕЖИТ ОТКАЗ (шкала та же, что у `classify_browser_probe`, но граница
|
||||
другая — здесь судится ПАРА, а не узел):
|
||||
- "sidecar" — общая зависимость лежит, к паре отношения не имеет → "ignored".
|
||||
Иначе одна упавшая зависимость забанила бы разом все пары (#2686 в третий раз);
|
||||
- "proxy" — через этот узел до площадки не доходит транспорт
|
||||
(NS_ERROR_PROXY_*, camoufox не поднялся) → бан пары;
|
||||
- "page" — дошли, но площадка отдала ЭТОМУ exit-IP не ресурс, а заглушку
|
||||
(200 + «Ошибка — Циан» вместо robots.txt) → тоже бан пары.
|
||||
Для УЗЛА этот исход по-прежнему «не виноват» (см. mark_browser_health), для
|
||||
ПАРЫ — виноват ровно он: собирать через такой узел эту площадку нельзя.
|
||||
|
||||
Успех снимает ТОЛЬКО строку, написанную пробой (`only_reason`). Бан, распознанный
|
||||
боевым сбором, остаётся: robots.txt площадка отдаёт и забаненному IP, и разрешить
|
||||
дешёвой пробе гасить дорогой вердикт значило бы повторить #2723 на паре. Обратная
|
||||
половина того же правила живёт в `mark_banned`: чужую АКТИВНУЮ строку проба не
|
||||
присваивает (дефект #2803) — иначе фильтр `only_reason` перестаёт защищать, ведь
|
||||
присвоенная строка уже «своя».
|
||||
|
||||
Защита последнего узла и эскалация срока — целиком из `mark_banned`, здесь ничего
|
||||
своего: если после бана у `acquire(source)` не осталось бы кандидатов, бан не
|
||||
пишется (голодание хуже работы через плохой узел).
|
||||
|
||||
Returns: "ok" | "cleared" (сняли свой бан) | "ignored" | исход `mark_banned`
|
||||
("banned" | "deferred" | "protected" | "missing") — счётчик пар считает баном
|
||||
только реально записанный бан.
|
||||
"""
|
||||
if ok:
|
||||
cleared = clear_source_bans(
|
||||
db,
|
||||
proxy_id,
|
||||
source=source,
|
||||
reason=f"browser probe OK for source={source} ({detail})",
|
||||
only_reason=_PROBE_BAN_REASON,
|
||||
)
|
||||
return "cleared" if cleared else "ok"
|
||||
|
||||
if fail_kind not in ("proxy", "page"):
|
||||
logger.warning(
|
||||
"proxy_pool: pair probe FAILED id=%d source=%s, но отказ НЕ принадлежит паре "
|
||||
"(fail_kind=%s): %s — вердикт не пишем",
|
||||
proxy_id,
|
||||
source,
|
||||
fail_kind,
|
||||
detail,
|
||||
)
|
||||
return "ignored"
|
||||
|
||||
logger.warning(
|
||||
"proxy_pool: pair probe FAILED id=%d source=%s (fail_kind=%s): %s — пишем бан "
|
||||
"пары, узел остаётся первосортным для остальных площадок (#2800)",
|
||||
proxy_id,
|
||||
source,
|
||||
fail_kind,
|
||||
detail,
|
||||
)
|
||||
return mark_banned(db, proxy_id, source=source, reason=_PROBE_BAN_REASON)
|
||||
|
||||
|
||||
def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int:
|
||||
"""Освободить lease'ы старше older_than_minutes (упавший sweep не вызвал release).
|
||||
|
||||
|
|
@ -775,6 +1183,102 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No
|
|||
return False, None, None, "other"
|
||||
|
||||
|
||||
def _probe_sources_for(affinity: str) -> list[str]:
|
||||
"""Источники, которым узел с такой affinity МОЖЕТ достаться (#2800).
|
||||
|
||||
Ровно предикат основной выборки `acquire`: `provider_affinity IN (:source,'any')`.
|
||||
Спрашивать площадки, которым узел всё равно не выдадут, — платить за диагностику,
|
||||
которой никто не воспользуется.
|
||||
|
||||
ponytail: fallback-заход acquire умеет отдать узел и чужому источнику (когда своих
|
||||
свободных нет) — такая пара останется без вердикта и решится как раньше, по факту
|
||||
прогона. Полный крест по ВСЕМ источникам для каждого узла стоил бы столько же
|
||||
только на проде (там сейчас все узлы 'any'), а на пуле с выделенными affinity рос
|
||||
бы зря. Если fallback станет частым — снять условие, цена известна: N_узлов × 4.
|
||||
"""
|
||||
from scraper_kit.browser_fetcher import PROBE_SOURCES
|
||||
|
||||
return [s for s in PROBE_SOURCES if affinity in (s, "any")]
|
||||
|
||||
|
||||
async def _run_pair_probes(
|
||||
db: Session, proxy_id: int, url: str, kind: str, affinity: str
|
||||
) -> tuple[str, dict[str, int]]:
|
||||
"""Крест «этот узел × каждая его площадка» + запись вердиктов (#2800).
|
||||
|
||||
Возвращает (исход mark_browser_health для УЗЛА, счётчики по парам).
|
||||
|
||||
Два уровня вердикта, и они не пересекаются:
|
||||
- ПАРА (`mark_source_probe` → scrape_proxy_source_bans) — по каждой площадке
|
||||
отдельно, это то, что читает `acquire(source)`;
|
||||
- УЗЕЛ (`mark_browser_health` → browser_fail_streak/browser_unfit_since) — по
|
||||
итогу ВСЕГО креста: хоть одна площадка ответила → браузерный тракт через узел
|
||||
работает (ok); все отказали транспортом → отказ узла. Отказ ОДНОЙ площадки
|
||||
узел глобально не пятнает — иначе на месте вылеченного схлопывания диагнозов
|
||||
появилось бы новое.
|
||||
|
||||
Best-effort: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет
|
||||
healthcheck — ipify-часть уже отработала и её результат записан. Диагностика не
|
||||
имеет права ломать то, что диагностирует.
|
||||
"""
|
||||
from scraper_kit.browser_fetcher import probe_proxy_via_browser
|
||||
|
||||
counters = {"pair_checked": 0, "pair_banned": 0, "pair_cleared": 0}
|
||||
fail_kinds: list[str] = []
|
||||
any_ok = False
|
||||
last_detail = ""
|
||||
|
||||
for source in _probe_sources_for(affinity):
|
||||
try:
|
||||
ok, fail_kind, detail = await probe_proxy_via_browser(
|
||||
_settings.browser_http_endpoint, url, proxy_kind=kind, source=source
|
||||
)
|
||||
if not ok and fail_kind == "proxy":
|
||||
# Подтверждение НЕМЕДЛЕННО, а не через такт: запуск camoufox бывает
|
||||
# флаки сам по себе, а бан пары стоит источнику 6 часов узла. Повтор
|
||||
# идёт по уже поднятому браузеру с тем же прокси — секунды, и только
|
||||
# на отказах. Порог «2 подряд» у УЗЛОВОГО вердикта живёт своей жизнью
|
||||
# (BROWSER_UNFIT_THRESHOLD), здесь он был бы сутками ожидания.
|
||||
ok, fail_kind, detail = await probe_proxy_via_browser(
|
||||
_settings.browser_http_endpoint, url, proxy_kind=kind, source=source
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"proxy_pool: pair probe crashed id=%d source=%s — вердикт не записан",
|
||||
proxy_id,
|
||||
source,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
|
||||
counters["pair_checked"] += 1
|
||||
last_detail = detail
|
||||
if ok:
|
||||
any_ok = True
|
||||
else:
|
||||
fail_kinds.append(fail_kind or "other")
|
||||
outcome = mark_source_probe(
|
||||
db, proxy_id, source=source, ok=ok, fail_kind=fail_kind, detail=detail
|
||||
)
|
||||
if outcome == "banned":
|
||||
counters["pair_banned"] += 1
|
||||
elif outcome == "cleared":
|
||||
counters["pair_cleared"] += 1
|
||||
|
||||
if counters["pair_checked"] == 0:
|
||||
return "ignored", counters # крест не состоялся — узел не судим
|
||||
|
||||
if any_ok:
|
||||
return mark_browser_health(db, proxy_id, True, detail=last_detail), counters
|
||||
# Все площадки отказали. Узлу это принадлежит, только если КАЖДЫЙ отказ —
|
||||
# транспортный: смесь с "page"/"sidecar" значит «дело не (только) в узле».
|
||||
node_kind = "proxy" if all(k == "proxy" for k in fail_kinds) else fail_kinds[0]
|
||||
return (
|
||||
mark_browser_health(db, proxy_id, False, fail_kind=node_kind, detail=last_detail),
|
||||
counters,
|
||||
)
|
||||
|
||||
|
||||
def _mask(url: str) -> str:
|
||||
"""Скрыть пароль в proxy-url для логов (scheme://user:***@host)."""
|
||||
if "@" not in url or "//" not in url:
|
||||
|
|
@ -808,9 +1312,20 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
В конце — purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад
|
||||
(см. комментарий у самого DELETE: отложенность — это и есть сброс ban_count).
|
||||
|
||||
БРАУЗЕРНАЯ ПРОБА (#2723, на пару — #2800): узлам, прошедшим ipify и не
|
||||
проверявшимся браузером дольше BROWSER_PROBE_MINUTES, гоняется КРЕСТ проб ЧЕРЕЗ
|
||||
САЙДКАР — по одной навигации на каждую площадку, которую этот узел может
|
||||
обслуживать (тот же тракт, что у боевого сбора: camoufox стартует с этим прокси,
|
||||
потом навигация на robots.txt РАБОЧЕГО хоста площадки). Вердикт пары идёт в
|
||||
scrape_proxy_source_bans (его читает acquire(source)), вердикт узла — в отдельные
|
||||
browser_*-поля; ни один из них не смешивается с consecutive_fails/enabled. Гейт —
|
||||
settings.use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и
|
||||
проба измеряла бы то, чем никто не пользуется.
|
||||
|
||||
Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на
|
||||
один и тот же upstream-endpoint (ipify) не нужен. Returns counters
|
||||
{reaped, checked, ok, failed, revived, bans_purged}.
|
||||
{reaped, checked, ok, failed, revived, bans_purged, browser_checked, browser_ok,
|
||||
browser_unfit, browser_refit, pair_checked, pair_banned, pair_cleared}.
|
||||
"""
|
||||
reaped = reap_stale_leases(db)
|
||||
|
||||
|
|
@ -818,7 +1333,11 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, url, kind, enabled, disabled_reason
|
||||
SELECT id, url, kind, enabled, disabled_reason, provider_affinity,
|
||||
(browser_check_at IS NULL
|
||||
OR browser_check_at < now() - make_interval(
|
||||
mins => CAST(:browser_probe_minutes AS integer)
|
||||
)) AS browser_probe_due
|
||||
FROM scrape_proxies
|
||||
WHERE enabled
|
||||
OR last_check_at IS NULL
|
||||
|
|
@ -828,7 +1347,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
ORDER BY id
|
||||
"""
|
||||
),
|
||||
{"disabled_recheck_minutes": DISABLED_RECHECK_MINUTES},
|
||||
{
|
||||
"disabled_recheck_minutes": DISABLED_RECHECK_MINUTES,
|
||||
"browser_probe_minutes": BROWSER_PROBE_MINUTES,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
|
|
@ -838,6 +1360,13 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
ok_count = 0
|
||||
failed = 0
|
||||
revived = 0
|
||||
browser_checked = 0
|
||||
browser_ok = 0
|
||||
browser_unfit = 0
|
||||
browser_refit = 0
|
||||
pair_checked = 0
|
||||
pair_banned = 0
|
||||
pair_cleared = 0
|
||||
for row in proxies:
|
||||
proxy_id = int(row["id"])
|
||||
url = str(row["url"])
|
||||
|
|
@ -860,6 +1389,27 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
else:
|
||||
failed += 1
|
||||
|
||||
# Браузерная проба (#2723) — только если ipify прошла: провалившая ipify нода
|
||||
# мертва целиком, диагноз уже поставлен, а запуск camoufox через неё — чистая
|
||||
# трата 8 секунд. Гейт по use_proxy_pool_browser: при выключенном флаге браузер
|
||||
# ходит мимо пула (через env-прокси сайдкара), и вердикт об узлах пула был бы
|
||||
# вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет
|
||||
# не тот узел», из-за которого #2723 и появилась.
|
||||
if ok and row["browser_probe_due"] and _settings.use_proxy_pool_browser:
|
||||
outcome, pair_counters = await _run_pair_probes(
|
||||
db, proxy_id, url, str(row["kind"]), str(row["provider_affinity"])
|
||||
)
|
||||
browser_checked += 1
|
||||
pair_checked += pair_counters["pair_checked"]
|
||||
pair_banned += pair_counters["pair_banned"]
|
||||
pair_cleared += pair_counters["pair_cleared"]
|
||||
if outcome in ("ok", "refit"):
|
||||
browser_ok += 1
|
||||
if outcome == "refit":
|
||||
browser_refit += 1
|
||||
elif outcome == "unfit":
|
||||
browser_unfit += 1
|
||||
|
||||
# Purge ДАВНО истёкших бан-строк (#2600 п.2). Порог — banned_until + SOURCE_BAN_PURGE_DAYS,
|
||||
# НЕ просто `banned_until < now()`: строка после истечения бана ещё ничего не блокирует
|
||||
# (acquire фильтрует по banned_until > now()), но хранит ban_count — память об эскалации.
|
||||
|
|
@ -882,13 +1432,21 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
|
||||
logger.info(
|
||||
"proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d "
|
||||
"bans_purged=%d",
|
||||
"bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d "
|
||||
"pair_checked=%d pair_banned=%d pair_cleared=%d",
|
||||
reaped,
|
||||
checked,
|
||||
ok_count,
|
||||
failed,
|
||||
revived,
|
||||
purged,
|
||||
browser_checked,
|
||||
browser_ok,
|
||||
browser_unfit,
|
||||
browser_refit,
|
||||
pair_checked,
|
||||
pair_banned,
|
||||
pair_cleared,
|
||||
)
|
||||
return {
|
||||
"reaped": reaped,
|
||||
|
|
@ -897,4 +1455,16 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
"failed": failed,
|
||||
"revived": revived,
|
||||
"bans_purged": purged,
|
||||
# Счётчики браузерной пробы (#2723) — намеренно ОТДЕЛЬНЫЕ от checked/ok/failed:
|
||||
# схлопнув их в общие, мы бы своими руками сделали то, за что чиним этот модуль.
|
||||
"browser_checked": browser_checked,
|
||||
"browser_ok": browser_ok,
|
||||
"browser_unfit": browser_unfit,
|
||||
"browser_refit": browser_refit,
|
||||
# Вердикты по ПАРАМ (#2800). Тоже отдельно от узловых: browser_ok=1 и
|
||||
# pair_banned=2 одновременно — это не противоречие, а точный диагноз
|
||||
# «браузер через узел работает, но две площадки его не пускают».
|
||||
"pair_checked": pair_checked,
|
||||
"pair_banned": pair_banned,
|
||||
"pair_cleared": pair_cleared,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,9 +50,23 @@ sber_index.py для sberindex.ru (см. #922, тот же паттерн: пу
|
|||
отвечает HTTP 403 без браузерного User-Agent — шлём Chrome UA (тот же паттерн,
|
||||
что DEFAULT_UA в zhkh_flats_loader.py).
|
||||
|
||||
При сетевой ошибке / HTTP 5xx / таймауте — логируем warning, возвращаем
|
||||
available=False. Отсутствие папки/файла квартала → available=False (штатный
|
||||
случай до публикации квартала, до начала следующего месяца после конца квартала).
|
||||
УРОВНИ СИГНАЛОВ (#2674 — в контейнере скрапера событием GlitchTip становится только
|
||||
запись ERROR, см. scheduler_main.py LoggingIntegration(event_level=ERROR)):
|
||||
- Портал ответил не-200 на листинг каталога/папки → ERROR. Каталог — единственная
|
||||
опора поллера; портал УЖЕ один раз переехал (см. "ИСТОРИЯ"), и тогда поллер молча
|
||||
врал целыми кварталами. Такое обязано быть событием.
|
||||
- Файл датасета НАЙДЕН в листинге, но HEAD не отдал zip / размер ниже порога →
|
||||
ERROR. Тот же класс: это ровно поведение старой Bitrix-заглушки (200 + text/html).
|
||||
Ветка может сработать легитимно (файл выложили в листинг раньше, чем докачали),
|
||||
но цена асимметрична — ложное срабатывание стоит одного события в месяц (такт
|
||||
28 дней), пропуск стоит квартала молчания.
|
||||
- Таймаут / сетевая ошибка → WARNING, как раньше. Это транспортный блип раз в месяц
|
||||
(такт поллера), сам пройдёт; а «квартал так и не приехал» ловит отдельный
|
||||
deals_freshness_monitor ERROR-ом по max(deal_date).
|
||||
- Папки/файла квартала нет → INFO. Штатное состояние до публикации: квартал выходит
|
||||
4 раза в год, поллер ходит 12 — большинство прогонов ЗАКОННО пустые.
|
||||
- Квартал вышел → INFO + ЯВНОЕ событие capture_message(level="info"), см.
|
||||
poll_rosreestr_new_quarter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -63,6 +77,7 @@ from typing import Any
|
|||
from urllib.parse import quote, unquote, urljoin
|
||||
|
||||
import httpx
|
||||
import sentry_sdk
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -243,7 +258,8 @@ async def check_new_quarter_available(
|
|||
try:
|
||||
index_resp = await client.get(_DATA_SETS_BASE_URL, follow_redirects=True)
|
||||
if index_resp.status_code != 200:
|
||||
logger.warning(
|
||||
# ERROR (#2674): без каталога поллер слеп — см. "УРОВНИ СИГНАЛОВ".
|
||||
logger.error(
|
||||
"rosreestr_poll: unexpected HTTP %d listing %s — treating Q%d %d as unavailable",
|
||||
index_resp.status_code,
|
||||
_DATA_SETS_BASE_URL,
|
||||
|
|
@ -265,7 +281,9 @@ async def check_new_quarter_available(
|
|||
folder_url = urljoin(_DATA_SETS_BASE_URL, folder_href)
|
||||
folder_resp = await client.get(folder_url, follow_redirects=True)
|
||||
if folder_resp.status_code != 200:
|
||||
logger.warning(
|
||||
# ERROR (#2674): папка квартала НАЙДЕНА в каталоге, но не открывается —
|
||||
# это уже не «ещё не опубликовали», а поломка портала.
|
||||
logger.error(
|
||||
"rosreestr_poll: unexpected HTTP %d listing folder %s — "
|
||||
"treating Q%d %d as unavailable",
|
||||
folder_resp.status_code,
|
||||
|
|
@ -309,7 +327,13 @@ async def check_new_quarter_available(
|
|||
)
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
# ERROR (#2674, ревью PR #2681): файл ЕСТЬ в листинге, но HEAD отдал не zip
|
||||
# либо размер ниже порога — это буквально тот сбой, из-за которого поллер уже
|
||||
# врал (Bitrix-заглушка отвечала 200 с text/html вместо архива, см. "ИСТОРИЯ").
|
||||
# Ветка может сработать и легитимно — файл появился в листинге раньше, чем
|
||||
# докачался, — но цена асимметрична: такт 28 дней, значит ложное срабатывание
|
||||
# стоит максимум одного события в месяц, а пропуск стоит квартала молчания.
|
||||
logger.error(
|
||||
"rosreestr_poll: Q%d %d file found (%s) but failed availability check "
|
||||
"(HTTP %d, Content-Type=%r, Content-Length=%d) — soft-404 guard, "
|
||||
"treating as unavailable",
|
||||
|
|
@ -338,12 +362,14 @@ async def check_new_quarter_available(
|
|||
exc,
|
||||
)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"rosreestr_poll: unexpected error checking Q%d %d: %s — treating as unavailable",
|
||||
except Exception:
|
||||
# ERROR + traceback (#2674): сюда попадает НАШ баг (сменилась разметка, упал
|
||||
# парсер href'ов), а не сбой сети. Под WARNING он молча превращался в
|
||||
# «квартала нет» — ровно тот сценарий, из-за которого поллер врал кварталами.
|
||||
logger.exception(
|
||||
"rosreestr_poll: unexpected error checking Q%d %d — treating as unavailable",
|
||||
quarter,
|
||||
year,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
|
@ -409,6 +435,21 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
|
|||
rosreestr_dataset_url(next_year, next_quarter),
|
||||
_DATA_SETS_BASE_URL,
|
||||
)
|
||||
# #2674: это ХОРОШАЯ новость, но она требует ручного шага оператора (импорт
|
||||
# много-гигабайтного ZIP), а INFO-строка живёт только в docker-логах и
|
||||
# теряется на редеплое. Отсюда явный capture_message вместо logger.error:
|
||||
# событие в GlitchTip будет, а error-rate и стрик-алерты не соврут «сбой».
|
||||
# Шума не создаёт: такт поллера — раз в 28 дней, квартал выходит 4 раза в
|
||||
# год, а повтор до самого импорта — это и есть нужное напоминание (#2670).
|
||||
try:
|
||||
sentry_sdk.capture_message(
|
||||
f"Rosreestr: доступен новый квартал Q{next_quarter} {next_year} — "
|
||||
"нужен ручной импорт (02_load_all_quarters.sh + import-rosreestr.sh)",
|
||||
level="info",
|
||||
)
|
||||
except Exception:
|
||||
# Алертинг best-effort: падение отправки события не должно валить поллер.
|
||||
logger.warning("rosreestr_poll: capture_message failed", exc_info=True)
|
||||
|
||||
return {
|
||||
"available": available,
|
||||
|
|
|
|||
|
|
@ -464,8 +464,16 @@ async def pull_sber_indices(
|
|||
# path or its filter dims are stale (sber renames slugs / changes
|
||||
# dimension codes). Surface it loudly with the slug + filter so the
|
||||
# next breakage is diagnosable instead of a silent error-counter bump.
|
||||
#
|
||||
# #2674: "loudly" было сказано, но написано WARNING — тише, чем
|
||||
# соседние 5xx/сетевые ветки, и НЕ событие в скрапере
|
||||
# (LoggingIntegration event_level=ERROR). При этом 404 — самая
|
||||
# ПЕРМАНЕНТНАЯ из трёх: 5xx и сетевой сбой сами пройдут, а
|
||||
# переименованный slug будет 404-ить каждый месяц, пока человек не
|
||||
# перезахватит dataset-path. Ровно тот сбой, из-за которого бенчмарк
|
||||
# перестаёт обновляться.
|
||||
if exc.response.status_code == 404:
|
||||
logger.warning(
|
||||
logger.error(
|
||||
"sber_index: 404 for dashboard=%s ref_area=%s filter=%s — "
|
||||
"dataset-path invalid? slug renamed or filter dims stale "
|
||||
"(re-capture /dataset/v1/<slug> via dashboard route-interception)",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ scheduling-путь (`app/scheduler_main.py` безусловно запуска
|
|||
Что осталось в этом модуле — НЕ scheduler-loop, а функции с живыми потребителями вне
|
||||
удалённой machinery:
|
||||
- `compute_next_run_at` — читается admin.py (операторский предпросмотр "next run").
|
||||
С #2674 это re-export kit-версии, а не вторая копия формулы.
|
||||
- `has_running_run` — читается admin.py (UI-индикатор "уже бежит").
|
||||
- `import_rosreestr_dkp` — job-тело, вызываемое kit-handler'ом
|
||||
product_handlers._job_rosreestr_dkp (lazy import).
|
||||
|
|
@ -25,16 +26,24 @@ Zombie-reap, advisory-lock claim и tick-loop теперь целиком в
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from datetime import UTC, datetime, time, timedelta
|
||||
from typing import Any
|
||||
|
||||
# compute_next_run_at жил здесь ВТОРОЙ, побайтово одинаковой копией kit-версии (#2674).
|
||||
# Обе копии одинаково умели interval_days — но такт доезжал до next_run_at только через
|
||||
# kit (_claim_run/_defer_next_run_at читают default_params["interval_days"]); admin.py
|
||||
# звал эту копию БЕЗ аргумента, получал default=1 и сбивал любой источник на «завтра».
|
||||
# Копия удалена, а не подправлена: пока формула лежит в двух файлах, следующая правка
|
||||
# такта снова разъедется по одному из них. Re-export (а не правка импорта у вызывающих)
|
||||
# сохраняет `from app.services.scheduler import compute_next_run_at` в admin.py и тестах.
|
||||
from scraper_kit.orchestration.scheduler import compute_next_run_at
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.shutdown import shutdown_requested
|
||||
from app.services import scrape_runs as runs_mod
|
||||
|
||||
__all__ = ["compute_next_run_at", "has_running_run"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# import_rosreestr_dkp: доля per-row INSERT-ошибок (rows_errored / rows_fetched), выше
|
||||
|
|
@ -43,54 +52,6 @@ logger = logging.getLogger(__name__)
|
|||
DKP_IMPORT_ERROR_RATE_THRESHOLD = 0.05
|
||||
|
||||
|
||||
def compute_next_run_at(
|
||||
window_start_hour: int,
|
||||
window_end_hour: int,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
interval_days: int = 1,
|
||||
) -> datetime:
|
||||
"""Pick random datetime в window [start, end) UTC, через interval_days суток после now.
|
||||
|
||||
interval_days задаёт каденс источника: 1 (default) = daily (back-compat), 7 = weekly.
|
||||
Берётся из schedule.default_params["interval_days"] вызывающим кодом; отсутствие ключа
|
||||
→ 1 → прежнее ежедневное поведение.
|
||||
|
||||
Если window_end_hour <= window_start_hour → cross-midnight window
|
||||
(например 22→3 → окно 22:00-23:59 ИЛИ 00:00-02:59).
|
||||
"""
|
||||
now = now or datetime.now(tz=UTC)
|
||||
interval_days = max(1, int(interval_days))
|
||||
# Целевая дата = now + interval_days суток (interval_days=1 → завтра, как раньше).
|
||||
target = (now + timedelta(days=interval_days)).date()
|
||||
|
||||
if window_end_hour > window_start_hour:
|
||||
# Обычное окно (например 2..5 → 02:00-04:59)
|
||||
start_seconds = window_start_hour * 3600
|
||||
end_seconds = window_end_hour * 3600
|
||||
rand_seconds = random.randint(start_seconds, end_seconds - 1)
|
||||
return datetime.combine(target, time(0, 0), tzinfo=UTC) + timedelta(seconds=rand_seconds)
|
||||
else:
|
||||
# Cross-midnight (22..3 → 22:00-23:59 + 00:00-02:59)
|
||||
# Длина окна = (24-start) + end часов
|
||||
total_seconds = ((24 - window_start_hour) + window_end_hour) * 3600
|
||||
rand_seconds = random.randint(0, total_seconds - 1)
|
||||
# Если rand попадает в первую часть (start..24)
|
||||
first_half = (24 - window_start_hour) * 3600
|
||||
if rand_seconds < first_half:
|
||||
# interval_days=1: текущая дата (если окно ещё не наступило сегодня) или next day.
|
||||
# interval_days>1: всегда целевая дата (стаггер на N суток вперёд).
|
||||
today_ok = interval_days == 1 and now.hour < window_start_hour
|
||||
base_date = now.date() if today_ok else target
|
||||
return datetime.combine(base_date, time(0, 0), tzinfo=UTC) + timedelta(
|
||||
seconds=window_start_hour * 3600 + rand_seconds
|
||||
)
|
||||
else:
|
||||
# Во второй части (0..end), целевого дня
|
||||
offset = rand_seconds - first_half
|
||||
return datetime.combine(target, time(0, 0), tzinfo=UTC) + timedelta(seconds=offset)
|
||||
|
||||
|
||||
def has_running_run(db: Session, source: str) -> bool:
|
||||
"""Есть ли активный run для source (status='running')."""
|
||||
row = db.execute(
|
||||
|
|
@ -115,7 +76,16 @@ async def _execute_cian_backfill(
|
|||
"""Orchestrate Cian history backfill with heartbeat + checkpoint.
|
||||
|
||||
Wraps backfill_cian_history(), updating scrape_runs counters (via update_heartbeat)
|
||||
before and after the batch call for zombie-detection visibility.
|
||||
НА КАЖДОЙ сущности батча, а не только до и после него (#2725). Раньше сигнал
|
||||
живости слался ровно один раз — до батча, — а `reap_zombies` меряет именно
|
||||
heartbeat_at с порогом 6 ч, и добивал живые прогоны строго на 6-м часу: 6 прод-
|
||||
прогонов этого источника помечены 'zombie' со сдвигом heartbeat 16-32 мс, при том
|
||||
что у пятерых внутри окна писались строки offer_price_history (у прогона 304 — до
|
||||
5.4 ч после старта), а штатная длительность источника доходит до 5.06 ч (346).
|
||||
Цена ошибки не косметическая: mark_done апдейтит WHERE status='running', так что
|
||||
после ложной пометки собственный финал прогона становится no-op (отсюда нулевые
|
||||
counters у всех шести), а has_running_run перестаёт видеть прогон и следующий тик
|
||||
может запустить второй такой же батч поверх работающего.
|
||||
|
||||
Checkpoint/resume semantics: backfill_cian_history() queries rows WHERE history IS
|
||||
NULL via LEFT JOIN — so re-running after a partial completion naturally skips
|
||||
|
|
@ -124,9 +94,33 @@ async def _execute_cian_backfill(
|
|||
Params (from default_params jsonb):
|
||||
batch_size: int — rows per run (listings + houses counted separately).
|
||||
"""
|
||||
from app.tasks.cian_history_backfill import backfill_cian_history
|
||||
from app.tasks.cian_history_backfill import CianBackfillResult, backfill_cian_history
|
||||
|
||||
batch_size = int(params.get("batch_size", 100))
|
||||
|
||||
def _counters(result: CianBackfillResult) -> dict[str, int]:
|
||||
return {
|
||||
"listings_processed": result.listings_processed,
|
||||
"listings_succeeded": result.listings_succeeded,
|
||||
"listings_failed": result.listings_failed_fetch + result.listings_failed_save,
|
||||
"houses_processed": result.houses_processed,
|
||||
"houses_succeeded": result.houses_succeeded,
|
||||
"houses_failed": result.houses_failed_fetch + result.houses_failed_save,
|
||||
}
|
||||
|
||||
def _heartbeat(progress: CianBackfillResult) -> None:
|
||||
"""Сигнал живости из середины батча. Best-effort: сбой heartbeat не должен
|
||||
ронять уже идущую работу — прогон в худшем случае вернётся к прежнему
|
||||
поведению (пометка 'zombie' на 6-м часу)."""
|
||||
try:
|
||||
runs_mod.update_heartbeat(db, run_id, _counters(progress))
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"scheduler: cian_history_backfill run_id=%d heartbeat failed (ignored)",
|
||||
run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
counters: dict[str, int] = {
|
||||
"listings_processed": 0,
|
||||
"listings_succeeded": 0,
|
||||
|
|
@ -145,17 +139,10 @@ async def _execute_cian_backfill(
|
|||
do_listings=True,
|
||||
do_houses=True,
|
||||
do_valuations=False,
|
||||
on_progress=_heartbeat,
|
||||
)
|
||||
|
||||
counters = {
|
||||
"listings_processed": result.listings_processed,
|
||||
"listings_succeeded": result.listings_succeeded,
|
||||
"listings_failed": result.listings_failed_fetch + result.listings_failed_save,
|
||||
"houses_processed": result.houses_processed,
|
||||
"houses_succeeded": result.houses_succeeded,
|
||||
"houses_failed": result.houses_failed_fetch + result.houses_failed_save,
|
||||
"duration_sec": int(result.duration_sec),
|
||||
}
|
||||
counters = {**_counters(result), "duration_sec": int(result.duration_sec)}
|
||||
runs_mod.mark_done(db, run_id, counters)
|
||||
logger.info(
|
||||
"scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs",
|
||||
|
|
|
|||
|
|
@ -2,13 +2,39 @@
|
|||
|
||||
Таблица scrape_runs создана в 015_scrape_runs.sql.
|
||||
Расширена в 051_scrape_runs_extend.sql: params/counters/error/finished_at/cancelled.
|
||||
|
||||
ВРЕМЯ ПИШЕТСЯ clock_timestamp(), А НЕ now() (#2702). `now()` в PostgreSQL —
|
||||
синоним `transaction_timestamp()`: он замерзает на СТАРТЕ транзакции и не двигается,
|
||||
сколько бы та ни жила. Финализаторы (mark_done/mark_failed/mark_banned) выполняются
|
||||
ТОЙ ЖЕ сессией, что и работа задачи, — и если рабочая транзакция всё это время
|
||||
оставалась открытой (задача ничего не коммитила: нечего было сохранять, батч читающий,
|
||||
сохранение шло чужой сессией), их UPDATE попадал ВНУТРЬ неё, и `finished_at` получал
|
||||
время НАЧАЛА работы, а не её конца.
|
||||
|
||||
Замер на проде 2026-08-06 (487 прогонов, у которых есть и finished_at, и счётчик
|
||||
counters.duration_sec): у 153 заявленная длительность превышала собственное окно
|
||||
finished_at − started_at более чем в 1.5 раза, у 133 окно было меньше секунды при
|
||||
работе дольше 10 с. 126 из этих 133 окон лежат в диапазоне 9-64 мс — это не разброс,
|
||||
а подпись механизма: столько проходит от коммита claim'а до первого запроса рабочей
|
||||
транзакции. Крайний случай — прогон 346 (cian_history_backfill): 18230 с работы,
|
||||
окно 32 мс.
|
||||
|
||||
Дефект был не сплошной ровно потому, что зависел от того, коммитила ли задача перед
|
||||
финалом: cadastral_geo_match / house_imv_backfill / avito_detail_backfill коммитят
|
||||
поштучно, у них окно совпадало с работой; yandex_address_backfill (45 из 50 прогонов),
|
||||
newbuilding_enrich, cian_history_backfill — нет.
|
||||
|
||||
Побочно это чинит и `heartbeat_at`: он писался тем же `now()` и по той же причине
|
||||
отставал от реальности на возраст открытой транзакции, а на нём стоит поиск зависших
|
||||
прогонов (reap_zombies, порог 6 ч).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Collection, Mapping
|
||||
from functools import cache
|
||||
from typing import Any
|
||||
|
||||
import sentry_sdk
|
||||
|
|
@ -29,6 +55,232 @@ CONSECUTIVE_FAILURE_ALERT_THRESHOLD = 3
|
|||
# невидим для CONSECUTIVE_FAILURE_ALERT_THRESHOLD (тот считает только failed/banned).
|
||||
CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD = 3
|
||||
|
||||
# #2670: анти-спам «один раз на стрик» безопасен ТОЛЬКО там, где стрик прерывается
|
||||
# не только в принципе, но и на практике. Оба сторожа ниже слали алерт ровно на N-й
|
||||
# подряд неудаче и дальше молчали навсегда — а у постоянно сломанного источника
|
||||
# «дальше» длится месяцами. Прод 2026-08-06: у avito_full_load 31 неудача подряд,
|
||||
# последний успешный прогон 03.07 (34 дня без сбора), алерт был ровно один — на
|
||||
# третьей; у avito_full_load_exhaustive 5 подряд. Тишина при этом неотличима от
|
||||
# «всё хорошо» — ровно та ловушка, из-за которой #2574 месяц выглядела как норма.
|
||||
#
|
||||
# Вместо «ровно N» — разреженная лестница напоминаний: N, 2N, 4N, 8N…, а дальше не
|
||||
# реже, чем раз в STREAK_ALERT_MAX_PERIOD×N прогонов. Лестница по ПРОГОНАМ, а не
|
||||
# «раз в сутки», потому что источники идут разным тактом: domclick_city_sweep — раз
|
||||
# в день, proxy_healthcheck — раз в полчаса; календарное разрежение для одного из
|
||||
# них всегда будет либо спамом, либо молчанием.
|
||||
STREAK_ALERT_MAX_PERIOD = 16
|
||||
|
||||
# Потолок сканирования истории источника при подсчёте стрика. Достигнутый потолок
|
||||
# сам по себе повод для алерта (стрик заведомо огромен) — так «замолчать навсегда»
|
||||
# невозможно по построению, а не по счастливому совпадению чисел.
|
||||
STREAK_SCAN_LIMIT = 500
|
||||
|
||||
|
||||
def _streak_alert_due(streak: int, threshold: int) -> bool:
|
||||
"""Достиг ли стрик очередной вехи напоминания (#2670).
|
||||
|
||||
True на threshold, 2×, 4×, 8×… и дальше на каждом кратном
|
||||
STREAK_ALERT_MAX_PERIOD×threshold. Первый алерт приходит там же, где и раньше —
|
||||
на N-й подряд неудаче; меняется только то, что он не последний.
|
||||
"""
|
||||
if streak < threshold or streak % threshold:
|
||||
return False
|
||||
mult = streak // threshold
|
||||
if mult % STREAK_ALERT_MAX_PERIOD == 0:
|
||||
return True
|
||||
return mult & (mult - 1) == 0
|
||||
|
||||
|
||||
def _leading_streak(rows: list[Any], is_bad: Callable[[Any], bool]) -> int:
|
||||
"""Длина серии подряд идущих «плохих» строк с начала списка (свежие — первыми)."""
|
||||
streak = 0
|
||||
for row in rows:
|
||||
if not is_bad(row):
|
||||
break
|
||||
streak += 1
|
||||
return streak
|
||||
|
||||
|
||||
# #2686: диагноз оборванного прогона. Пишется в scrape_runs.ban_kind (миграция 218)
|
||||
# РЯДОМ со status='banned', а не ВМЕСТО него — сознательный выбор между «новый
|
||||
# статус» и «явное поле причины»:
|
||||
# 1. Побочная функция 'banned' — сохранение done_buckets-чекпоинта (mark_failed
|
||||
# его теряет) — нужна ОБОИМ исходам. Оставив статус, получаем её даром; расщепив
|
||||
# статус, пришлось бы дублировать её в каждом потребителе.
|
||||
# 2. Новое значение статуса пришлось бы доучить пяти местам, каждое из которых
|
||||
# молча даёт неверный ответ, если про него забыть: CHECK-констрейнт схемы,
|
||||
# IN-списки обоих сторожей (_alert_if_consecutive_failures / _zero_results),
|
||||
# Literal-фильтр admin API и хардкод-список статусов во фронте. Это ровно тот
|
||||
# класс оборванной проводки, из-за которого задача и появилась.
|
||||
# 3. Прогон в обоих случаях требует одного и того же обращения (оборвать, сохранить
|
||||
# частичное); различается только ДИАГНОЗ — то есть метаданное, не состояние.
|
||||
BAN_KIND_PLATFORM = "platform" # площадка показала firewall/403/captcha — внешнее
|
||||
BAN_KIND_INFRA = "infra" # наш сайдкар/прокси не отдал страницу — внутреннее
|
||||
# #2764: причина НЕ установлена. Дефолт mark_banned — именно он, а не 'platform':
|
||||
# на проде оба прогона, помеченных после миграции 218, получили 'platform' по
|
||||
# умолчанию (ни один их не передавал), то есть метка выглядела доказательством, не
|
||||
# будучи им. 'unknown' делает пробел измеримым (SELECT ban_kind, count(*)), а
|
||||
# 'platform'/'infra' начинают означать ровно то, что доказано типом исключения.
|
||||
BAN_KIND_UNKNOWN = "unknown"
|
||||
|
||||
|
||||
def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None:
|
||||
"""Первое присутствующее из ``keys`` как int; None — ни одного ключа нет."""
|
||||
for key in keys:
|
||||
val = counters.get(key)
|
||||
if val is not None:
|
||||
try:
|
||||
return int(val)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# #2703: ключи, которыми задача сообщает СВОЙ бизнес-результат. Список намеренно
|
||||
# короткий и состоит из синонимов ОДНОЙ величины — «сколько объявлений отдала выдача»:
|
||||
# total_seen — если задача посчитала сама;
|
||||
# lots_fetched — все city/newbuilding-sweep'ы (21 источник, 455 прогонов на проде);
|
||||
# unique_fetched — full-load'ы avito/cian/yandex (4 источника, 133 прогона) — раньше
|
||||
# сторож их не видел, хотя у cian_full_load 6 из 38 успешных прогонов
|
||||
# реально дали ноль.
|
||||
# Сводить сюда счётчики ОСТАЛЬНЫХ задач бессмысленно: на проде 28 источников (2650
|
||||
# прогонов) не имеют общего результатного ключа вовсе — у каждого свой словарь
|
||||
# (deactivated / rows_written / poi_loaded / snapshotted / upserted / listings_matched
|
||||
# …), а у refresh_search_matview counters пусты буквально ({} во всех 55 строках) и у
|
||||
# трёх мониторов результата нет по смыслу. Ноль у них — часто ЗДОРОВЫЙ ответ
|
||||
# (deactivate_stale_* без протухших объявлений). Поэтому сторож не угадывает их
|
||||
# словарь, а честно признаёт, что мерить нечем — см. _run_result_count.
|
||||
_RESULT_COUNTER_KEYS = ("total_seen", "lots_fetched", "unique_fetched")
|
||||
|
||||
|
||||
def _run_result_count(counters: Mapping[str, Any] | None) -> int | None:
|
||||
"""Бизнес-результат прогона; **None = прогон его не сообщил** (≠ ноль).
|
||||
|
||||
Ровно это различие и было потеряно: сторож читал колонку ``total_seen``, у
|
||||
которой DEFAULT 0, поэтому «не измерено» и «измерено, ноль» выглядели одинаково.
|
||||
"""
|
||||
return _pick_int(counters or {}, *_RESULT_COUNTER_KEYS)
|
||||
|
||||
|
||||
@cache
|
||||
def _warn_source_has_no_result_metric(source: str, keys: tuple[str, ...]) -> None:
|
||||
"""Один раз на процесс: у источника нет ключа, по которому сторож судит (#2703).
|
||||
|
||||
Не алерт — алертить не о чем, судить не о чем тоже. Это делает слепую зону
|
||||
ВИДИМОЙ: раньше её признаком был вечно молчащий сторож, выглядящий настроенным.
|
||||
"""
|
||||
logger.warning(
|
||||
"zero-result watchdog неприменим к source=%s: counters не содержат ни одного "
|
||||
"результатного ключа %s (есть: %s) — прогоны этого источника больше не считаются "
|
||||
"нулевыми по умолчанию (#2703)",
|
||||
source,
|
||||
_RESULT_COUNTER_KEYS,
|
||||
", ".join(keys) or "<пусто>",
|
||||
)
|
||||
|
||||
|
||||
def _sweep_run_did_nothing(counters: Mapping[str, Any]) -> str | None:
|
||||
"""Развёртка, у которой КАЖДЫЙ якорь кончился отказом и не принесла ничего (#2625).
|
||||
|
||||
Возвращает текст причины (для error) либо None, если прогон таким не является.
|
||||
|
||||
Третий исход, у которого не было терминального статуса. Развёртка различает:
|
||||
1. «площадка отбила» — попытки разбора были, структура не извлеклась ни разу →
|
||||
`mark_banned` в самих sweep'ах (#2642, cian/yandex);
|
||||
2. «площадка честно отдала пустоту» — валидный ответ, ноль предложений →
|
||||
`done` с нулём, это здоровый результат (в Серове реально 10 объявлений);
|
||||
3. «мы не дошли» — якорь упал по таймауту или исключению ДО того, как
|
||||
что-либо стало разбирать. Ровно этот случай в счётчики бана не попадает
|
||||
НАМЕРЕННО (#2600 п.1: transport_error не должен выглядеть баном площадки),
|
||||
и статуса ему никто не выдал — прогон уходил в `done`.
|
||||
|
||||
Признак — собственная бухгалтерия прогона, а не список известных антибот-маркеров:
|
||||
`errors_count >= anchors_total` при нулевом ИЗМЕРЕННОМ результате означает, что
|
||||
отказом кончился каждый якорь, который у прогона был, и собрано ноль. Это НЕ
|
||||
доказывает, КТО виноват (капча площадки / наш прокси / наш баг), поэтому статус
|
||||
'failed' без диагноза, а не 'banned' с 'platform' (#2764: диагноз не назначается
|
||||
по умолчанию).
|
||||
|
||||
Что признак НЕ ловит: прогон, где часть якорей отдала данные, а часть отказала —
|
||||
`errors_count < anchors_total`, статус остаётся 'done' (частичный сбор — сбор).
|
||||
|
||||
Замер на проде 2026-08-10 за 90 суток: под правило попадают 28 прогонов
|
||||
(yandex_city_sweep_nizhniy_tagil 16 подряд по 15-30.07 — каждый ровно 240 с,
|
||||
таймаут якоря, 0 лотов, 'done'; yandex_city_sweep 6; avito_city_sweep 5;
|
||||
yandex_city_sweep_pervouralsk 1 от 09.08 — 155 мс, исключение до первого запроса).
|
||||
НЕ затронуты: 132 прогона с отказами, но ненулевым сбором, и 37 прогонов честной
|
||||
пустоты (errors_count=0) — они остаются 'done'.
|
||||
"""
|
||||
anchors = _pick_int(counters, "anchors_total")
|
||||
errors = _pick_int(counters, "errors_count")
|
||||
if not anchors or anchors <= 0 or errors is None or errors < anchors:
|
||||
return None
|
||||
if _run_result_count(counters) != 0: # None (не измерено) сюда тоже НЕ попадает
|
||||
return None
|
||||
return (
|
||||
f"sweep-honest-status: отказом кончились все {anchors} якорей прогона "
|
||||
f"(errors_count={errors}), собрано 0 — работа не сделана. Причина НЕ "
|
||||
f"установлена: якорь мог упасть по таймауту, из-за нашего прокси или "
|
||||
f"блокировкой площадки — статус 'failed' без диагноза (#2625)"
|
||||
)
|
||||
|
||||
|
||||
# #2700: сколько попыток фазы должно быть, чтобы «отказали все» что-то значило.
|
||||
# 3 — не круглое число, а порог, на котором сам сбор уже сдаётся: столько подряд
|
||||
# неудачных detail'ов достаточно оркестратору, чтобы ротировать прокси и оборвать фазу
|
||||
# (_cian_detail_abort в orchestration/pipeline.py). Замер на проде 2026-08-10 за 90
|
||||
# суток: порог отсекает 2 прогона с ЕДИНСТВЕННОЙ попыткой (одиночный отказ — шум, не
|
||||
# диагноз) и оставляет 50 прогонов, где отказали 3-50 попыток подряд.
|
||||
_PHASE_MIN_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None:
|
||||
"""Фаза прогона, у которой отказала КАЖДАЯ попытка (#2700). Текст причины или None.
|
||||
|
||||
Прогон состоит из фаз, а статус у него один. `_sweep_run_did_nothing` (#2625) ловит
|
||||
случай, когда не сделано НИЧЕГО; этот — когда целое направление работы отказало на
|
||||
сто процентов, а соседнее сработало, и суммарный ненулевой сбор прячет отказ.
|
||||
|
||||
Живой повод (#2700): `cian_city_sweep` 15 суток подряд писал `detail_attempted=50,
|
||||
detail_failed=50, errors_count=0, status=done` — каждая detail-страница отдавала
|
||||
HTTP 403. Ноль обогащённых при 1 680 собранных лотах внешне неотличим от здорового
|
||||
прогона: результатный счётчик (lots_fetched) ненулевой, а до `errors_count` отказ
|
||||
подзадачи не доходил вовсе (403 гасился внутри провайдера в `return None`).
|
||||
|
||||
Признак — собственная бухгалтерия фазы: `<phase>_failed == <phase>_attempted` при
|
||||
`attempted >= _PHASE_MIN_ATTEMPTS`. Пары ищутся В САМИХ counters (любой ключ
|
||||
`X_attempted` со спутником `X_failed`), а не по зашитому списку фаз: список — это
|
||||
ровно то место, куда забывают дописать новую фазу, и тогда сторож молчит, выглядя
|
||||
настроенным. На проде за 90 суток таких пар четыре: detail/houses/address/imv.
|
||||
|
||||
Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) — поэтому
|
||||
'failed' без диагноза, как и в #2625/#2764, а не 'banned'/'platform'.
|
||||
|
||||
Замер на проде 2026-08-10 за 90 суток, ПРОГНАННЫЙ УЖЕ ДЕПЛОЙНУТОЙ функцией по
|
||||
боевым counters (3 574 прогона, из них 3 293 'done'): правило переводит в 'failed'
|
||||
42 прогона (1.3%) — 31 cian_city_sweep* и 11 avito_city_sweep*; про вторые никто не
|
||||
знал. Остальные 3 251 остаются 'done'. Первая версия этого абзаца называла 52 —
|
||||
это было число ПАР «прогон × фаза» из SQL-замера, а не прогонов: у 10 прогонов
|
||||
отказали обе фазы (detail и houses) сразу, и они посчитались дважды.
|
||||
"""
|
||||
for key in sorted(counters):
|
||||
if not key.endswith("_attempted"):
|
||||
continue
|
||||
phase = key[: -len("_attempted")]
|
||||
attempted = _pick_int(counters, key)
|
||||
failed = _pick_int(counters, f"{phase}_failed")
|
||||
if attempted is None or failed is None:
|
||||
continue
|
||||
if attempted >= _PHASE_MIN_ATTEMPTS and failed == attempted:
|
||||
return (
|
||||
f"phase-honest-status: фаза '{phase}' отказала полностью — "
|
||||
f"{failed} из {attempted} попыток неудачны, обогащено 0. Остальные фазы "
|
||||
f"прогона могли отработать, поэтому ненулевой сбор это НЕ опровергает. "
|
||||
f"Причина НЕ установлена: блок площадки, наш прокси или разбор — статус "
|
||||
f"'failed' без диагноза (#2700)"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
|
||||
"""Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.
|
||||
|
|
@ -40,41 +292,37 @@ def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
|
|||
показывала total_seen=0 при реально сохранённых строках (audit #1871/#1926).
|
||||
|
||||
Приоритет ключей:
|
||||
- total_seen ← 'total_seen' (если уже есть в counters) иначе 'lots_fetched'
|
||||
- new_count ← 'new_count' (если уже есть) иначе 'lots_inserted'
|
||||
- total_seen ← _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched)
|
||||
- new_count ← 'new_count' (если уже есть) иначе 'lots_inserted'
|
||||
|
||||
Возвращает (total_seen, new_count); None для ключа, которого нет в counters —
|
||||
тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE).
|
||||
"""
|
||||
|
||||
def _pick(*keys: str) -> int | None:
|
||||
for key in keys:
|
||||
val = counters.get(key)
|
||||
if val is not None:
|
||||
try:
|
||||
return int(val)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
return _pick("total_seen", "lots_fetched"), _pick("new_count", "lots_inserted")
|
||||
return _run_result_count(counters), _pick_int(counters, "new_count", "lots_inserted")
|
||||
|
||||
|
||||
def _alert_if_consecutive_failures(db: Session, source: str) -> None:
|
||||
"""Отправить Sentry alert если последние CONSECUTIVE_FAILURE_ALERT_THRESHOLD
|
||||
завершённых запусков для данного source имеют статус 'failed' или 'banned'.
|
||||
"""Sentry alert на серию из CONSECUTIVE_FAILURE_ALERT_THRESHOLD неудач подряд
|
||||
(статусы 'failed'/'banned') у данного source.
|
||||
|
||||
Anti-spam: алерт срабатывает ТОЛЬКО когда стрик РОВНО равен порогу — т.е. запрос
|
||||
возвращает ровно N последних (failed|banned) и (N+1)-й, если существует, НЕ является
|
||||
failed/banned. Это предотвращает повторный алерт на каждой ошибке сверх порога.
|
||||
Anti-spam: не на каждой неудаче, а по разреженной лестнице вех (см.
|
||||
_streak_alert_due). До #2670 алерт приходил РОВНО на N-й неудаче и дальше не
|
||||
повторялся никогда: серия, ставшая длиннее порога, замолкала навсегда. На проде
|
||||
это дало avito_full_load — 31 неудача подряд, 34 дня без единого успешного
|
||||
прогона, один алерт за всё время.
|
||||
|
||||
Стрик прерывается любым завершением, кроме failed/banned, — по данным прода это
|
||||
достижимо и достигается (у domclick_city_sweep текущий стрик равен 1 при 47
|
||||
завершённых прогонах), поэтому лестница не вырождается в постоянный алерт.
|
||||
|
||||
Best-effort: весь блок обёрнут в try/except — сбой запроса или неинициализированный
|
||||
Sentry НЕ должен нарушать вызывающий mark_* путь.
|
||||
"""
|
||||
if sentry_sdk is None:
|
||||
return
|
||||
n = CONSECUTIVE_FAILURE_ALERT_THRESHOLD
|
||||
try:
|
||||
# Берём последние N+1 завершённых (non-running) запусков по source.
|
||||
# Сортируем по finished_at DESC чтобы самые свежие шли первыми.
|
||||
# Завершённые (non-running) прогоны источника, самые свежие первыми.
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -85,31 +333,21 @@ def _alert_if_consecutive_failures(db: Session, source: str) -> None:
|
|||
LIMIT :limit
|
||||
"""
|
||||
),
|
||||
{"source": source, "limit": n + 1},
|
||||
{"source": source, "limit": STREAK_SCAN_LIMIT},
|
||||
).fetchall()
|
||||
|
||||
if len(rows) < n:
|
||||
# Ещё не набралось N завершённых запусков вообще — алерт не нужен.
|
||||
streak = _leading_streak(rows, lambda r: r.status in ("failed", "banned"))
|
||||
capped = streak >= STREAK_SCAN_LIMIT
|
||||
if not capped and not _streak_alert_due(streak, n):
|
||||
return
|
||||
|
||||
# Первые N должны быть все failed/banned.
|
||||
first_n = rows[:n]
|
||||
if not all(r.status in ("failed", "banned") for r in first_n):
|
||||
return
|
||||
|
||||
# (N+1)-й запуск, если есть, тоже должен НЕ быть failed/banned — иначе мы уже
|
||||
# должны были отправить алерт раньше и не стоит дублировать.
|
||||
if len(rows) > n and rows[n].status in ("failed", "banned"):
|
||||
return
|
||||
|
||||
# Стрик ровно достиг порога — отправляем алерт.
|
||||
sentry_sdk.capture_message(
|
||||
f"Scraper source '{source}' has {n} consecutive failed/banned runs — "
|
||||
f"Scraper source '{source}' has {streak} consecutive failed/banned runs — "
|
||||
"manual intervention may be required (expired cookies / ban / broken parser).",
|
||||
level="error",
|
||||
)
|
||||
logger.error(
|
||||
"sentry alert sent: source=%s has %d consecutive failed/banned runs", source, n
|
||||
"sentry alert sent: source=%s has %d consecutive failed/banned runs", source, streak
|
||||
)
|
||||
except Exception:
|
||||
pass # sentry_sdk not initialised in dev, or query failed — best-effort only
|
||||
|
|
@ -117,15 +355,23 @@ def _alert_if_consecutive_failures(db: Session, source: str) -> None:
|
|||
|
||||
def _alert_if_consecutive_zero_results(db: Session, source: str) -> None:
|
||||
"""Отправить Sentry alert если последние CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD
|
||||
завершённых 'done' запусков для source имеют total_seen=0 (#2625).
|
||||
завершённых 'done' запусков для source дали ИЗМЕРЕННЫЙ нулевой результат (#2625).
|
||||
|
||||
Отличается от _alert_if_consecutive_failures: статус здесь формально 'done'
|
||||
(errors_count=0) — деградация невидима существующему failed/banned алерту.
|
||||
Причина обычно капча/пустая выдача источника, у которого нет (или не сработал)
|
||||
детект блокировки (см. providers/cian/serp.py, providers/yandex/serp.py).
|
||||
|
||||
Anti-spam: тот же N-й-стрик паттерн, что у _alert_if_consecutive_failures —
|
||||
алерт срабатывает ровно когда стрик достигает порога, не на каждом запуске сверх.
|
||||
Anti-spam: та же разреженная лестница вех, что у _alert_if_consecutive_failures
|
||||
(#2670) — N, 2N, 4N…, а не «ровно N и дальше тишина».
|
||||
|
||||
#2703: анти-спам «один раз на стрик» безопасен ТОЛЬКО там, где стрик может
|
||||
прерваться. Сторож читал колонку total_seen (DEFAULT 0), которой у 28 из 53
|
||||
источников не заполняет ничто — значит у них он читал 0 ВСЕГДА, в том числе у
|
||||
полностью успешного прогона, стрик не прерывался никогда, и после первого
|
||||
события сторож замолкал навсегда, продолжая выглядеть настроенным. Теперь
|
||||
признак берётся из counters, а «не измерено» (None) стрик ПРЕРЫВАЕТ — ложный
|
||||
вечный стрик стал невозможен по построению, а слепая зона логируется явно.
|
||||
|
||||
Best-effort: весь блок обёрнут в try/except — сбой запроса или неинициализированный
|
||||
Sentry НЕ должен нарушать вызывающий mark_done путь.
|
||||
|
|
@ -134,35 +380,47 @@ def _alert_if_consecutive_zero_results(db: Session, source: str) -> None:
|
|||
try:
|
||||
# Те же non-running статусы, что у _alert_if_consecutive_failures — стрик
|
||||
# 'done'-с-нулём прерывается ЛЮБЫМ другим завершением (failed/banned/done-
|
||||
# с-результатом/cancelled), не только успешным сбором.
|
||||
# с-результатом/cancelled/прогон без результатной метрики), не только успешным
|
||||
# сбором. counters, а НЕ колонка total_seen: у колонки DEFAULT 0, по ней
|
||||
# «не измерено» неотличимо от «ноль» (#2703).
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT status, total_seen FROM scrape_runs
|
||||
SELECT status, counters FROM scrape_runs
|
||||
WHERE source = :source
|
||||
AND status IN ('failed', 'banned', 'done', 'cancelled')
|
||||
ORDER BY finished_at DESC NULLS LAST
|
||||
LIMIT :limit
|
||||
"""
|
||||
),
|
||||
{"source": source, "limit": n + 1},
|
||||
{"source": source, "limit": STREAK_SCAN_LIMIT},
|
||||
).fetchall()
|
||||
|
||||
if len(rows) < n:
|
||||
if not rows:
|
||||
return
|
||||
|
||||
def _is_zero_done(r: Any) -> bool:
|
||||
return r.status == "done" and (r.total_seen or 0) == 0
|
||||
"""Только ИЗМЕРЕННЫЙ ноль. Прогон без результатной метрики стрик ПРЕРЫВАЕТ.
|
||||
|
||||
first_n = rows[:n]
|
||||
if not all(_is_zero_done(r) for r in first_n):
|
||||
Так недостижимое условие прерывания невозможно по построению: источник,
|
||||
чей словарь счётчиков сторожу неизвестен, не копит ложный стрик и не
|
||||
запирает анти-спам «один раз на стрик» в «один раз навсегда».
|
||||
"""
|
||||
return r.status == "done" and _run_result_count(r.counters) == 0
|
||||
|
||||
if _run_result_count(rows[0].counters) is None:
|
||||
# Свежайший завершённый прогон не сообщил результата — судить нечем.
|
||||
# Логируем (один раз на источник за процесс) вместо молчаливого нуля.
|
||||
_warn_source_has_no_result_metric(source, tuple(sorted(rows[0].counters or {})))
|
||||
return
|
||||
|
||||
if len(rows) > n and _is_zero_done(rows[n]):
|
||||
streak = _leading_streak(rows, _is_zero_done)
|
||||
capped = streak >= STREAK_SCAN_LIMIT
|
||||
if not capped and not _streak_alert_due(streak, n):
|
||||
return
|
||||
|
||||
sentry_sdk.capture_message(
|
||||
f"Scraper source '{source}' has {n} consecutive 'done' runs with zero "
|
||||
f"Scraper source '{source}' has {streak} consecutive 'done' runs with zero "
|
||||
"lots fetched — captcha/layout-change likely undetected "
|
||||
"(manual check recommended).",
|
||||
level="error",
|
||||
|
|
@ -170,7 +428,7 @@ def _alert_if_consecutive_zero_results(db: Session, source: str) -> None:
|
|||
logger.error(
|
||||
"sentry alert sent: source=%s has %d consecutive zero-result 'done' runs",
|
||||
source,
|
||||
n,
|
||||
streak,
|
||||
)
|
||||
except Exception:
|
||||
pass # sentry_sdk not initialised in dev, or query failed — best-effort only
|
||||
|
|
@ -199,16 +457,23 @@ def _alert_on_run_id(
|
|||
|
||||
|
||||
def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int:
|
||||
"""INSERT scrape_runs(source, status='running', params, started_at=NOW()).
|
||||
"""INSERT scrape_runs(source, status='running', params, started_at=clock_timestamp()).
|
||||
|
||||
run_type DEFAULT 'city_sweep' (из 051 миграции).
|
||||
started_at пишется СВОЕЙ транзакцией (db.commit() ниже) — откат рабочей
|
||||
транзакции задачи его уже не достаёт (#2702).
|
||||
|
||||
Вид прогона несёт сам `source` (avito_city_sweep / domclick_detail_backfill / …);
|
||||
отдельной колонки run_type больше нет — она 3244 прогона подряд молчала
|
||||
дефолтом 'city_sweep' и подписывала им, например, proxy_healthcheck (#2674).
|
||||
Returns run_id (bigint).
|
||||
"""
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scrape_runs (source, status, params, started_at, heartbeat_at)
|
||||
VALUES (:source, 'running', CAST(:params AS jsonb), NOW(), NOW())
|
||||
VALUES (
|
||||
:source, 'running', CAST(:params AS jsonb), clock_timestamp(), clock_timestamp()
|
||||
)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
|
|
@ -220,7 +485,7 @@ def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int:
|
|||
|
||||
|
||||
def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None:
|
||||
"""UPDATE heartbeat_at=NOW(), counters=:counters + total_seen/new_count колонки.
|
||||
"""UPDATE heartbeat_at + counters=:counters + total_seen/new_count колонки.
|
||||
|
||||
total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и
|
||||
пишутся в выделенные колонки, чтобы observability не показывала 0 (audit #1926).
|
||||
|
|
@ -231,7 +496,7 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None
|
|||
text(
|
||||
"""
|
||||
UPDATE scrape_runs
|
||||
SET heartbeat_at = NOW(),
|
||||
SET heartbeat_at = clock_timestamp(),
|
||||
counters = CAST(:counters AS jsonb),
|
||||
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
||||
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
||||
|
|
@ -248,6 +513,27 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None
|
|||
db.commit()
|
||||
|
||||
|
||||
# Источники, чей джоб РЕАЛЬНО опрашивает status='cancelled' в своём цикле.
|
||||
# Всё остальное отменить нельзя: строка стала бы 'cancelled', а задача продолжила бы
|
||||
# работать — это, во-первых, ещё один врущий статус, во-вторых (хуже) обход guard'а
|
||||
# has_running_run: он перестанет видеть прогон как running и пустит второй свип на том
|
||||
# же прокси-IP → бан (инцидент 2026-05-31, runs #26+#27).
|
||||
# Состав проверен по call-site'ам runs.is_cancelled: kit pipeline (city-sweep'ы всех
|
||||
# площадок и городов, full-load'ы, avito_newbuilding_sweep) + rosreestr_dkp_import
|
||||
# (scheduler.py). yandex_newbuilding_sweep отмену НЕ опрашивает — поэтому правило не
|
||||
# «любой *_sweep». Актуально с #2674: до починки фильтра таблица прогонов была пуста
|
||||
# на всех вкладках, кнопка отмены не рендерилась ни разу и дыра не проявлялась.
|
||||
_CANCEL_HONORING_EXACT = frozenset({"avito_newbuilding_sweep", "rosreestr_dkp_import"})
|
||||
_CANCEL_HONORING_SUBSTRINGS = ("city_sweep", "full_load")
|
||||
|
||||
|
||||
def honors_cancel(source: str) -> bool:
|
||||
"""True, если джоб этого source опрашивает отмену и реально остановится."""
|
||||
return source in _CANCEL_HONORING_EXACT or any(
|
||||
key in source for key in _CANCEL_HONORING_SUBSTRINGS
|
||||
)
|
||||
|
||||
|
||||
def is_cancelled(db: Session, run_id: int) -> bool:
|
||||
"""Проверить status='cancelled' (cooperative cancel в long-running pipeline)."""
|
||||
row = db.execute(
|
||||
|
|
@ -258,17 +544,38 @@ def is_cancelled(db: Session, run_id: int) -> bool:
|
|||
|
||||
|
||||
def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
|
||||
"""Финализация run: status='done', finished_at=NOW(), counters + total_seen/new_count.
|
||||
"""Финализация run: status='done', finished_at + counters + total_seen/new_count.
|
||||
|
||||
total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся
|
||||
в выделенные колонки — иначе admin/observability показывает 0 (audit #1926).
|
||||
|
||||
#2625: сюда же сведён отказ называть успехом прогон, у которого отказом кончился
|
||||
каждый якорь и собрано ноль — см. _sweep_run_did_nothing. Проверка стоит здесь, а
|
||||
не в каждом sweep'е, ровно потому, что вызывающих у mark_done четыре десятка:
|
||||
страж, который надо не забыть позвать, — это тот же дефект оборванной проводки,
|
||||
из-за которого задача и появилась.
|
||||
|
||||
#2700: там же — отказ называть успехом прогон, у которого отказала КАЖДАЯ попытка
|
||||
целой фазы (см. _phase_totally_failed). Отличие от #2625: тот случай про «не сделано
|
||||
ничего», этот — про «одно направление работы мертво, а суммарный сбор это прячет».
|
||||
"""
|
||||
did_nothing = _sweep_run_did_nothing(counters)
|
||||
if did_nothing is not None:
|
||||
logger.error("%s run_id=%d", did_nothing, run_id)
|
||||
mark_failed(db, run_id, did_nothing, counters)
|
||||
return
|
||||
phase_dead = _phase_totally_failed(counters)
|
||||
if phase_dead is not None:
|
||||
logger.error("%s run_id=%d", phase_dead, run_id)
|
||||
mark_failed(db, run_id, phase_dead, counters)
|
||||
return
|
||||
total_seen, new_count = _column_counts(counters)
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE scrape_runs
|
||||
SET status = 'done', finished_at = NOW(), heartbeat_at = NOW(),
|
||||
SET status = 'done',
|
||||
finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(),
|
||||
counters = CAST(:counters AS jsonb),
|
||||
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
||||
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
||||
|
|
@ -307,7 +614,8 @@ def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int])
|
|||
text(
|
||||
"""
|
||||
UPDATE scrape_runs
|
||||
SET status = 'failed', finished_at = NOW(), heartbeat_at = NOW(),
|
||||
SET status = 'failed',
|
||||
finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(),
|
||||
error = :error, counters = CAST(:counters AS jsonb),
|
||||
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
||||
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
||||
|
|
@ -330,11 +638,30 @@ def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int])
|
|||
_alert_on_run_id(db, run_id)
|
||||
|
||||
|
||||
def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None:
|
||||
"""Финализация run: status='banned' (IP заблокирован Avito — 403/captcha).
|
||||
def mark_banned(
|
||||
db: Session,
|
||||
run_id: int,
|
||||
error: str,
|
||||
counters: dict[str, int],
|
||||
*,
|
||||
ban_kind: str = BAN_KIND_UNKNOWN,
|
||||
) -> None:
|
||||
"""Финализация run: status='banned' + диагноз ban_kind (#2686, дефолт — #2764).
|
||||
|
||||
Per migration 015 — 'banned' задокументирован как 'Avito вернул 403/captcha'.
|
||||
Отличается от 'failed': это external constraint, не наш bug. Cooldown 2-4 часа.
|
||||
Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим
|
||||
багом, и — важно — СОХРАНЯЕТ done_buckets-чекпоинт в counters (mark_failed его
|
||||
теряет). Cooldown 2-4 часа.
|
||||
|
||||
`ban_kind` разводит два исхода, которые раньше схлопывались в один статус:
|
||||
- BAN_KIND_PLATFORM — площадка нас заблокировала (firewall/403/captcha);
|
||||
- BAN_KIND_INFRA — упала НАША инфраструктура (браузерный сайдкар/прокси);
|
||||
- BAN_KIND_UNKNOWN (дефолт) — причина не установлена.
|
||||
Значение приходит от места ПОРОЖДЕНИЯ отказа (тип исключения), а не из разбора
|
||||
текста ошибки. Дефолт 'unknown', а НЕ 'platform' (#2764): вызывающий, которому
|
||||
разводить нечего, ничего и не знает — а не «знает, что виновата площадка».
|
||||
|
||||
Оба исхода одинаково сохраняют чекпоинт — они отличаются только диагнозом.
|
||||
|
||||
Defensive rollback: если до этого вызова в той же транзакции был ошибочный UPDATE,
|
||||
он мог оставить сессию в error state — rollback сбрасывает состояние.
|
||||
|
|
@ -348,8 +675,10 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int])
|
|||
text(
|
||||
"""
|
||||
UPDATE scrape_runs
|
||||
SET status = 'banned', finished_at = NOW(), heartbeat_at = NOW(),
|
||||
SET status = 'banned',
|
||||
finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(),
|
||||
error = :error, counters = CAST(:counters AS jsonb),
|
||||
ban_kind = :ban_kind,
|
||||
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
||||
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
||||
WHERE id = :run_id AND status = 'running'
|
||||
|
|
@ -360,6 +689,7 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int])
|
|||
"run_id": run_id,
|
||||
"error": error[:1000],
|
||||
"counters": json.dumps(counters),
|
||||
"ban_kind": ban_kind,
|
||||
"total_seen": total_seen,
|
||||
"new_count": new_count,
|
||||
},
|
||||
|
|
@ -371,13 +701,117 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int])
|
|||
_alert_on_run_id(db, run_id)
|
||||
|
||||
|
||||
def mark_backfill_finished(
|
||||
db: Session,
|
||||
run_id: int,
|
||||
counters: dict[str, int],
|
||||
*,
|
||||
source: str,
|
||||
aborted_by_blocks: bool = False,
|
||||
fail_hint: str | None = None,
|
||||
ban_kinds: Collection[str] = (),
|
||||
) -> None:
|
||||
"""Честный финал detail-backfill'а (#2674): нулевой прогон ≠ 'done'.
|
||||
|
||||
Все три detail-backfill'а (avito/yandex/domclick) финализировались ОДНИМ
|
||||
mark_done: прогон, который сделал N попыток и не обогатил НИ ОДНОГО объявления,
|
||||
отчитывался успехом. На проде (2026-08-06) это 78 прогонов из 158 —
|
||||
avito 23/76 (в т.ч. 5 прогонов по 1500-1600 попыток с нулём обогащений),
|
||||
yandex 31/52 (все attempted=5 failed=5), domclick 24/30 (494 попытки → 0).
|
||||
|
||||
Существующие алерты этот класс не ловили: _alert_if_consecutive_failures
|
||||
считает только failed/banned, а _alert_if_consecutive_zero_results смотрит
|
||||
total_seen, которого в counters backfill'ов нет вовсе (всегда 0 → стрик не
|
||||
прерывается никогда → анти-спам молчит после первого раза).
|
||||
|
||||
Правила (порядок важен), по образцу #2657 для domclick_city_sweep:
|
||||
- попыток не было (attempted=0) → 'done', честная пустота: кандидатов нет;
|
||||
- есть блоки источника И (прогон оборван брейкером ИЛИ ноль результата)
|
||||
→ 'banned': external constraint, не наш баг (и триггер ротации IP #2611);
|
||||
- ноль результата без блоков → 'failed': это наша поломка (парсер/сеть/БД);
|
||||
- иначе (обогатили хоть что-то) → 'done', в т.ч. частичный прогон.
|
||||
|
||||
`gone` (404 у avito) считается результатом наравне с `enriched`: прогон,
|
||||
который подтвердил снятие объявлений, работу сделал.
|
||||
|
||||
`fail_hint` — самая частая причина отказа этого прогона (задача считает её сама,
|
||||
см. avito_detail_backfill._failure_signature). Дописывается в текст статуса,
|
||||
потому что «blocked=5, обогащено 0» не отвечает на единственный вопрос, ради
|
||||
которого статус и читают: отказала площадка или наш тракт (#2686, #2698). Логи
|
||||
контейнера на этот вопрос отвечать не могут — они исчезают при пересоздании
|
||||
контейнера, то есть на первом же деплое после ночного прогона.
|
||||
|
||||
`ban_kinds` — диагнозы (ban_kind_of_exception) ВСЕХ блоков, которые задача
|
||||
поймала за прогон; пустой (дефолт) = задача типы не различает. Схлопываем сами,
|
||||
в одном месте на все три backfill'а: все блоки сошлись в одном диагнозе → он и
|
||||
пишется; разошлись (или их типы ничего не доказывают) → 'unknown'. Смешанный
|
||||
прогон честнее пометить неизвестным, чем выбрать из двух причин ту, что
|
||||
попалась последней — какая из них оборвала прогон, мы не знаем (#2764).
|
||||
"""
|
||||
attempted = int(counters.get("attempted") or 0)
|
||||
enriched = int(counters.get("enriched") or 0)
|
||||
blocked = int(counters.get("blocked") or 0)
|
||||
produced = enriched + int(counters.get("gone") or 0)
|
||||
hint = f"; причина: {fail_hint}" if fail_hint else ""
|
||||
|
||||
if attempted == 0:
|
||||
mark_done(db, run_id, counters)
|
||||
return
|
||||
|
||||
if blocked and (aborted_by_blocks or produced == 0):
|
||||
reason = (
|
||||
f"backfill-honest-status: {source} остановлен блоками источника — "
|
||||
f"blocked={blocked}, обогащено {enriched} из {attempted} попыток{hint} (#2674)"
|
||||
)
|
||||
logger.error("%s run_id=%d", reason, run_id)
|
||||
kinds = set(ban_kinds)
|
||||
mark_banned(
|
||||
db,
|
||||
run_id,
|
||||
reason,
|
||||
counters,
|
||||
ban_kind=kinds.pop() if len(kinds) == 1 else BAN_KIND_UNKNOWN,
|
||||
)
|
||||
return
|
||||
|
||||
if produced == 0:
|
||||
reason = (
|
||||
f"backfill-honest-status: {source} без результата — 0 обогащено из "
|
||||
f"{attempted} попыток (failed={counters.get('failed', 0)}, "
|
||||
f"blocked={blocked}){hint} (#2674)"
|
||||
)
|
||||
logger.error("%s run_id=%d", reason, run_id)
|
||||
mark_failed(db, run_id, reason, counters)
|
||||
return
|
||||
|
||||
mark_done(db, run_id, counters)
|
||||
|
||||
|
||||
def mark_cancelled(db: Session, run_id: int) -> bool:
|
||||
"""Set status='cancelled' если currently 'running'. Returns True если cancelled."""
|
||||
"""Set status='cancelled' если currently 'running'. Returns True если cancelled.
|
||||
|
||||
Отказ (False) для source'ов, чей джоб отмену не опрашивает — см. honors_cancel:
|
||||
там 'cancelled' был бы враньём в статусе и снял бы has_running_run-guard.
|
||||
Ручки отмены source не проверяют (любая из пяти принимает любой run_id), поэтому
|
||||
гейт стоит здесь — на общем узле всех пяти.
|
||||
"""
|
||||
row = db.execute(
|
||||
text("SELECT source FROM scrape_runs WHERE id = :run_id"),
|
||||
{"run_id": run_id},
|
||||
).fetchone()
|
||||
if row is not None and not honors_cancel(str(row.source)):
|
||||
logger.warning(
|
||||
"mark_cancelled отказ: run_id=%d source=%s не опрашивает отмену — "
|
||||
"задача продолжила бы работать под статусом 'cancelled'",
|
||||
run_id,
|
||||
row.source,
|
||||
)
|
||||
return False
|
||||
result = db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE scrape_runs
|
||||
SET status = 'cancelled', finished_at = NOW()
|
||||
SET status = 'cancelled', finished_at = clock_timestamp()
|
||||
WHERE id = :run_id AND status = 'running'
|
||||
RETURNING id
|
||||
"""
|
||||
|
|
@ -445,8 +879,8 @@ def list_all(
|
|||
db.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT id AS run_id, source, run_type, status, params, counters,
|
||||
total_seen, new_count, started_at, finished_at,
|
||||
SELECT id AS run_id, source, status, params, counters,
|
||||
ban_kind, total_seen, new_count, started_at, finished_at,
|
||||
heartbeat_at, error AS error_text
|
||||
FROM scrape_runs
|
||||
WHERE {where_sql}
|
||||
|
|
@ -460,3 +894,22 @@ def list_all(
|
|||
.all()
|
||||
)
|
||||
return total, [dict(r) for r in rows]
|
||||
|
||||
|
||||
def distinct_sources(db: Session) -> list[str]:
|
||||
"""Все значения source, которые РЕАЛЬНО есть в scrape_runs (по алфавиту).
|
||||
|
||||
#2674: фильтр источников в админке был захардкожен тремя площадками
|
||||
(avito/cian/yandex), а в таблице 53 разных source и ни одной строки с таким
|
||||
точным значением — все три пункта фильтра давали пустую выдачу, а 76%
|
||||
прогонов (включая всю площадку Домклик) отфильтровать было нечем.
|
||||
Список обязан приходить из данных: новый source появляется в фильтре сам,
|
||||
без правки кода.
|
||||
|
||||
Игнорирует фильтры /scrape/runs — иначе выбор источника вырезал бы из
|
||||
выпадающего списка все остальные.
|
||||
"""
|
||||
rows = db.execute(
|
||||
text("SELECT DISTINCT source FROM scrape_runs WHERE source IS NOT NULL ORDER BY source")
|
||||
).fetchall()
|
||||
return [str(r.source) for r in rows]
|
||||
|
|
|
|||
|
|
@ -66,8 +66,8 @@ class RealMatcherAdapter:
|
|||
*,
|
||||
year_built: int | None = None,
|
||||
building_cadastral_number: str | None = None,
|
||||
cadastral_number: str | None = None,
|
||||
source_url: str | None = None,
|
||||
city: str | None = None,
|
||||
) -> tuple[int | None, float, str]:
|
||||
# house_id is None when the matcher refuses a numberless address without a
|
||||
# cadastral number (method 'no_house_number', P1). Callers must tolerate None.
|
||||
|
|
@ -80,8 +80,8 @@ class RealMatcherAdapter:
|
|||
lon,
|
||||
year_built=year_built,
|
||||
building_cadastral_number=building_cadastral_number,
|
||||
cadastral_number=cadastral_number,
|
||||
source_url=source_url,
|
||||
city=city,
|
||||
)
|
||||
|
||||
def upsert_listing_source(
|
||||
|
|
|
|||
|
|
@ -95,8 +95,12 @@ def build_search_query(params: SearchParams) -> tuple[str, dict[str, object]]:
|
|||
where.append("total_floors <= CAST(:fl_total_max AS integer)")
|
||||
args["fl_total_max"] = params.floors_total_max
|
||||
|
||||
if params.has_kadastr:
|
||||
where.append("cadastral_number IS NOT NULL")
|
||||
# Фильтр has_kadastr удалён (#2674): `listings.cadastral_number` (кадастр КВАРТИРЫ)
|
||||
# пуст у всех 93 408 объявлений — площадки его не отдают (единственный писатель,
|
||||
# парсер Циана, читает offer["cadastralNumber"], которого в ответе нет). Предикат
|
||||
# `cadastral_number IS NOT NULL` мог вернуть только пустую выдачу, т.е. обещал
|
||||
# качество данных, которого нет. Колонка и её писатель оставлены: если площадка
|
||||
# начнёт отдавать кадастр, заполнение заработает само — тогда и вернём фильтр.
|
||||
|
||||
segment_clause = _SEGMENT_SQL[params.segment]
|
||||
if segment_clause is not None:
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ async def backfill_yandex_addresses(
|
|||
Returns:
|
||||
YandexAddressBackfillResult with checked/saved/skipped/errors counters.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url
|
||||
|
||||
result = YandexAddressBackfillResult()
|
||||
t0 = time.time()
|
||||
|
|
@ -130,7 +130,23 @@ async def backfill_yandex_addresses(
|
|||
request_delay_sec,
|
||||
)
|
||||
|
||||
_proxy_url = settings.scraper_proxy_url
|
||||
# Резолвер по источнику (#2825): пул scrape_proxies с учётом
|
||||
# scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только если пул
|
||||
# пуст (легитимный dev/staging-сценарий).
|
||||
try:
|
||||
_proxy_url = resolve_proxy_url(db, "yandex")
|
||||
except ProxyPoolExhaustedError as exc:
|
||||
# Fail-closed (#2616, #2825): пул не пуст, но все узлы забанены для yandex/
|
||||
# нездоровы — НЕ уходим на settings.scraper_proxy_url (см. proxy_egress module
|
||||
# docstring). Явный пропуск run'а вместо слепого прохода через egress, который
|
||||
# мог быть источником текущего инцидента.
|
||||
logger.error(
|
||||
"yandex_address_backfill: пул прокси исчерпан для yandex (%s) — run "
|
||||
"пропущен, ни один листинг не обработан",
|
||||
exc,
|
||||
)
|
||||
result.duration_sec = time.time() - t0
|
||||
return result
|
||||
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
|
||||
|
||||
async with AsyncSession(
|
||||
|
|
|
|||
|
|
@ -10,8 +10,22 @@ Legacy listings (older than 2h or outside radius) are never enriched.
|
|||
Solution: single snapshot SELECT at start (guarantees termination), same proxy
|
||||
session path as the detail-phase of `run_avito_city_sweep`
|
||||
(scraper_kit.orchestration.pipeline). Block handling mirrors that phase:
|
||||
rotate IP on every block, abort after max_consecutive_blocks (mark_done not
|
||||
mark_failed -- block is temporary, retry next night via NULL detail_enriched_at).
|
||||
rotate IP on every block, abort after max_consecutive_blocks. Статус оборванного
|
||||
блоками прогона — 'banned' (#2674, runs.mark_backfill_finished): работу он не
|
||||
доделал, остаток снапшота уедет в следующую ночь через NULL detail_enriched_at.
|
||||
|
||||
Отказы, не являющиеся блоками, до 2026-08-06 брейкера не имели вовсе: прогоны
|
||||
3-5 августа делали ~1600 попыток, получали 1600 отказов, ноль обогащений и
|
||||
выедали весь бюджет (9000 с) вместе с 1600 запросами через единственный прокси.
|
||||
Теперь такая серия обрывается по max_consecutive_failures, а самая частая причина
|
||||
отказа пишется в текст статуса прогона (_failure_signature) — иначе она живёт
|
||||
только в логах контейнера, а те исчезают на первом же деплое.
|
||||
|
||||
ДИАГНОЗ бана (#2764): каждый пойманный блок классифицируется по ТИПУ исключения
|
||||
(ban_kind_of_exception) и уходит в scrape_runs.ban_kind. До этой правки прогон
|
||||
3306 (blocked=5, 0 обогащено) получил 'platform' по УМОЛЧАНИЮ — финализатор
|
||||
диагноз не передавал, а browser-режим fetch_detail всё равно превращал отказ
|
||||
сайдкара в AvitoBlockedError, так что передавать было бы нечего.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -19,7 +33,9 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
|
@ -30,7 +46,7 @@ from scraper_kit.avito_exceptions import (
|
|||
AvitoRateLimitedError,
|
||||
)
|
||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||
from scraper_kit.orchestration.pipeline import CITY_LOCATIONS
|
||||
from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, ban_kind_of_exception
|
||||
|
||||
# #2397 slice B (эпик #2277 decommission scrape_pipeline.py, Part E): раньше
|
||||
# _CHROME_HEADERS/_avito_proxies() импортировались из app.services.scrape_pipeline.
|
||||
|
|
@ -47,12 +63,14 @@ from scraper_kit.providers.avito.detail import (
|
|||
save_detail_enrichment,
|
||||
)
|
||||
from scraper_kit.providers.avito.serp import AvitoScraper
|
||||
from scraper_kit.snapshot_writer import upsert_listing_snapshot
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.shutdown import shutdown_requested
|
||||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.proxy_egress import resolve_proxy_url
|
||||
from app.services.scraper_adapters import RealScraperConfig
|
||||
|
||||
# #2397 Part D1 (#2330 закрыт): _AVITO_WARM_SEARCH_URL/build_warmed_session больше
|
||||
|
|
@ -97,6 +115,38 @@ _OBLAST_AVITO_URL_PATTERNS = tuple(
|
|||
)
|
||||
|
||||
|
||||
# Причина отказа карточки без её URL: 1576 отказов одного прогона должны схлопнуться
|
||||
# в ОДНУ строку, иначе перепись бесполезна.
|
||||
_URL_IN_MESSAGE_RE = re.compile(r"https?://\S+")
|
||||
|
||||
|
||||
def _failure_signature(exc: BaseException) -> str:
|
||||
"""Подпись причины отказа: тип исключения + текст без URL.
|
||||
|
||||
Зачем (замер 2026-08-06): у прогонов 3 и 4 августа counters говорили
|
||||
`attempted=1576, failed=1576, blocked=0` — и ничего больше. Кто отказал,
|
||||
площадка или наш тракт, было видно ТОЛЬКО в логах контейнера, а тот
|
||||
пересоздаётся на каждом деплое и уносит их с собой; в GlitchTip попадают
|
||||
события уровня ERROR, а поштучные отказы — WARNING. Разница между этими
|
||||
двумя диагнозами — разные владельцы задачи (#2686, #2698), поэтому она
|
||||
обязана переживать перезапуск контейнера, то есть лежать в самом прогоне.
|
||||
|
||||
Тип исключения — первый разряд диагноза (AvitoBlockedError = площадка
|
||||
показала 403/firewall; сетевой класс curl_cffi = наш прокси-тракт;
|
||||
ValueError = ответ пришёл, но не разобран), текст — второй.
|
||||
"""
|
||||
message = _URL_IN_MESSAGE_RE.sub("<url>", str(exc)).strip()
|
||||
return f"{type(exc).__name__}: {message}"[:160] if message else type(exc).__name__
|
||||
|
||||
|
||||
def _top_failure(census: Counter[str]) -> str | None:
|
||||
"""Самая частая причина отказа с её долей; None — отказов не было."""
|
||||
if not census:
|
||||
return None
|
||||
reason, hits = census.most_common(1)[0]
|
||||
return f"{reason} ({hits} из {sum(census.values())})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AvitoDetailBackfillResult:
|
||||
"""Counters for one backfill run."""
|
||||
|
|
@ -137,15 +187,25 @@ async def run_avito_detail_backfill(
|
|||
budget_sec: float -- wall-clock budget per run, default 3600s.
|
||||
request_delay_sec: float -- delay between listings, default 6.0s.
|
||||
max_consecutive_blocks: int -- abort threshold, default 5.
|
||||
max_consecutive_failures: int -- порог обрыва по отказам-не-блокам,
|
||||
default 25 (см. комментарий у чтения параметра ниже).
|
||||
|
||||
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
|
||||
mark_done (incl. partial/block-abort) / mark_failed (exception only).
|
||||
mark_backfill_finished (done / banned при блоках / failed при нуле, #2674);
|
||||
mark_failed напрямую — только при исключении.
|
||||
"""
|
||||
batch_size = int(params.get("batch_size", 800))
|
||||
oblast_batch_size = int(params.get("oblast_batch_size", 100))
|
||||
budget_sec = float(params.get("budget_sec", 3600))
|
||||
request_delay_sec = float(params.get("request_delay_sec", 6.0))
|
||||
max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5))
|
||||
# Брейкер на отказы-НЕ-блоки. Блоки свой брейкер имели с самого начала, отказы —
|
||||
# нет, и это стоило трёх ночей подряд: 3-5 августа прогон делал ~1600 попыток,
|
||||
# получал 1600 отказов, ноль обогащений и выедал весь бюджет 9000 с (плюс 1600
|
||||
# запросов через единственный прокси, #2638). Порог заметно выше блочного: пачка
|
||||
# мёртвых карточек (404 → ValueError в curl-режиме) не должна обрывать здоровый
|
||||
# прогон, а 25 отказов подряд без единого успеха — уже не невезение.
|
||||
max_consecutive_failures = int(params.get("max_consecutive_failures", 25))
|
||||
warm_batch = int(params.get("warm_batch", 500))
|
||||
research_every = int(params.get("research_every", 50))
|
||||
block_cooldown_sec = float(params.get("block_cooldown_sec", 30.0))
|
||||
|
|
@ -209,12 +269,19 @@ async def run_avito_detail_backfill(
|
|||
elif not use_curl:
|
||||
# curl_cffi legacy path (scraper_fetch_mode="curl_cffi", use_curl=False):
|
||||
# строим shared сессию через auv, как делает run_avito_city_sweep (kit).
|
||||
# Резолвер по источнику (#2825): пул scrape_proxies с учётом
|
||||
# scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только
|
||||
# если пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все
|
||||
# забанены/нездоровы для avito -- resolve_proxy_url бросает
|
||||
# ProxyPoolExhaustedError (fail-closed, #2616): НАРОЧНО не ловим здесь --
|
||||
# штатный except Exception ниже (mark_failed + logger.exception + raise)
|
||||
# уже даёт явную деградацию run'а с понятным логом, отдельный catch не нужен.
|
||||
own_session = True
|
||||
session = AsyncSession(
|
||||
impersonate="chrome120",
|
||||
timeout=25,
|
||||
headers=DOCUMENT_HEADERS,
|
||||
proxies=http_proxies(settings.scraper_proxy_url),
|
||||
proxies=http_proxies(resolve_proxy_url(db, "avito")),
|
||||
)
|
||||
scraper._cffi = session
|
||||
|
||||
|
|
@ -241,7 +308,7 @@ async def run_avito_detail_backfill(
|
|||
text(
|
||||
"""
|
||||
WITH ekb AS (
|
||||
SELECT id, source_url, 'ekb' AS city_scope
|
||||
SELECT id, source_url, price_rub, 'ekb' AS city_scope
|
||||
FROM listings
|
||||
WHERE source = 'avito'
|
||||
AND detail_enriched_at IS NULL
|
||||
|
|
@ -254,7 +321,7 @@ async def run_avito_detail_backfill(
|
|||
LIMIT CAST(:batch_size AS int)
|
||||
),
|
||||
oblast AS (
|
||||
SELECT id, source_url, 'oblast' AS city_scope
|
||||
SELECT id, source_url, price_rub, 'oblast' AS city_scope
|
||||
FROM listings
|
||||
WHERE source = 'avito'
|
||||
AND detail_enriched_at IS NULL
|
||||
|
|
@ -264,9 +331,9 @@ async def run_avito_detail_backfill(
|
|||
ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST
|
||||
LIMIT CAST(:oblast_batch_size AS int)
|
||||
)
|
||||
SELECT id, source_url, city_scope FROM ekb
|
||||
SELECT id, source_url, price_rub, city_scope FROM ekb
|
||||
UNION ALL
|
||||
SELECT id, source_url, city_scope FROM oblast
|
||||
SELECT id, source_url, price_rub, city_scope FROM oblast
|
||||
"""
|
||||
),
|
||||
{
|
||||
|
|
@ -305,8 +372,17 @@ async def run_avito_detail_backfill(
|
|||
)
|
||||
|
||||
consecutive_blocks = 0
|
||||
consecutive_failures = 0
|
||||
aborted_by_blocks = False
|
||||
do_sleep = False
|
||||
items_since_warm = 0
|
||||
# Перепись причин (блоки + отказы) — переживает пересоздание контейнера,
|
||||
# в отличие от логов; см. _failure_signature.
|
||||
failure_census: Counter[str] = Counter()
|
||||
# #2764: диагнозы всех блоков прогона по ТИПУ исключения. Сойдутся в один —
|
||||
# он и попадёт в scrape_runs.ban_kind, разойдутся — 'unknown' (схлопывает
|
||||
# mark_backfill_finished, один узел на все три backfill'а).
|
||||
block_ban_kinds: set[str] = set()
|
||||
|
||||
for idx, row in enumerate(snapshot):
|
||||
# Budget guard
|
||||
|
|
@ -425,8 +501,9 @@ async def run_avito_detail_backfill(
|
|||
if use_curl:
|
||||
items_since_warm += 1
|
||||
consecutive_blocks = 0
|
||||
consecutive_failures = 0
|
||||
|
||||
except AvitoListingGoneError:
|
||||
except AvitoListingGoneError as gone_exc:
|
||||
# #2034: мёртвый листинг (404 / removed) — НЕ блок, НЕ failed.
|
||||
# Координатные дыры в lat-null очереди в основном dead-листинги;
|
||||
# browser-mode рендерит их «Ошибка 404» без item-view → раньше это
|
||||
|
|
@ -436,12 +513,31 @@ async def run_avito_detail_backfill(
|
|||
# и не сбрасываем). Метим is_active=FALSE → листинг уходит из scope
|
||||
# (snapshot SELECT фильтрует is_active = TRUE) и не тратит фетчи впредь.
|
||||
counters.gone += 1
|
||||
# 404 — честный ответ площадки, значит тракт цел: серия отказов
|
||||
# прерывается (блочный брейкер 404 не трогает — см. #2034).
|
||||
consecutive_failures = 0
|
||||
failure_census[_failure_signature(gone_exc)] += 1
|
||||
try:
|
||||
with db.begin_nested():
|
||||
db.execute(
|
||||
text("UPDATE listings SET is_active = FALSE WHERE id = :id"),
|
||||
{"id": row["id"]},
|
||||
)
|
||||
# #2674: 404 с площадки — самый достоверный сигнал снятия,
|
||||
# фиксируем его в дневной истории (listings_snapshots.status
|
||||
# был константой 'active' у всех строк, 394 299). Тот же
|
||||
# SAVEPOINT, что и UPDATE флага: снимок без флага (или
|
||||
# наоборот) невозможен. price_rub из snapshot-SELECT —
|
||||
# .get() консервативен ради mock-снапшотов старых тестов.
|
||||
gone_price = row.get("price_rub")
|
||||
if gone_price is not None:
|
||||
upsert_listing_snapshot(
|
||||
db,
|
||||
listing_id=row["id"],
|
||||
price_rub=gone_price,
|
||||
run_id=run_id,
|
||||
status="closed",
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"avito_detail_backfill: run_id=%d failed to mark listing %s "
|
||||
|
|
@ -462,6 +558,8 @@ async def run_avito_detail_backfill(
|
|||
except (AvitoBlockedError, AvitoRateLimitedError) as e:
|
||||
consecutive_blocks += 1
|
||||
counters.blocked += 1
|
||||
failure_census[_failure_signature(e)] += 1
|
||||
block_ban_kinds.add(ban_kind_of_exception(e))
|
||||
do_sleep = False
|
||||
logger.warning(
|
||||
"avito_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s",
|
||||
|
|
@ -482,6 +580,7 @@ async def run_avito_detail_backfill(
|
|||
counters.enriched,
|
||||
counters.attempted,
|
||||
)
|
||||
aborted_by_blocks = True
|
||||
break
|
||||
# МГТС sticky-IP: один фикс. exit-IP, per-connection ротации нет (проверено:
|
||||
# 6/6 свежих сессий = тот же IP 109.252.125.80; ротация только вручную
|
||||
|
|
@ -518,12 +617,14 @@ async def run_avito_detail_backfill(
|
|||
exc_info=True,
|
||||
)
|
||||
|
||||
except TimeoutError:
|
||||
except TimeoutError as e:
|
||||
# asyncio.wait_for → TimeoutError (py3.12: asyncio.TimeoutError — alias).
|
||||
# Ловим ДО общего Exception (TimeoutError ⊂ OSError ⊂ Exception). Зависший
|
||||
# fetch отменён → листинг failed, переходим к следующему (loop не зависает,
|
||||
# run не zombie #1950). Не считаем soft-блоком: rotate не дёргаем.
|
||||
counters.failed += 1
|
||||
consecutive_failures += 1
|
||||
failure_census[_failure_signature(e)] += 1
|
||||
logger.warning(
|
||||
"avito_detail_backfill: run_id=%d listing %s TIMEOUT (>%.0fs) -- skip",
|
||||
run_id,
|
||||
|
|
@ -537,6 +638,8 @@ async def run_avito_detail_backfill(
|
|||
|
||||
except Exception as e:
|
||||
counters.failed += 1
|
||||
consecutive_failures += 1
|
||||
failure_census[_failure_signature(e)] += 1
|
||||
logger.warning(
|
||||
"avito_detail_backfill: run_id=%d listing %s failed: %s",
|
||||
run_id,
|
||||
|
|
@ -548,15 +651,35 @@ async def run_avito_detail_backfill(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
if consecutive_failures >= max_consecutive_failures:
|
||||
logger.error(
|
||||
"avito_detail_backfill: run_id=%d ABORT -- %d отказов подряд без "
|
||||
"единого успеха, частая причина: %s. enriched=%d attempted=%d",
|
||||
run_id,
|
||||
consecutive_failures,
|
||||
_top_failure(failure_census) or "неизвестна",
|
||||
counters.enriched,
|
||||
counters.attempted,
|
||||
)
|
||||
break
|
||||
|
||||
if counters.attempted % 25 == 0:
|
||||
current_counters = counters.to_dict()
|
||||
runs_mod.update_heartbeat(db, run_id, current_counters)
|
||||
|
||||
counters.duration_sec = time.monotonic() - start
|
||||
current_counters = counters.to_dict()
|
||||
runs_mod.mark_done(db, run_id, current_counters)
|
||||
runs_mod.mark_backfill_finished(
|
||||
db,
|
||||
run_id,
|
||||
current_counters,
|
||||
source="avito_detail_backfill",
|
||||
aborted_by_blocks=aborted_by_blocks,
|
||||
fail_hint=_top_failure(failure_census),
|
||||
ban_kinds=block_ban_kinds,
|
||||
)
|
||||
logger.info(
|
||||
"avito_detail_backfill: run_id=%d DONE -- attempted=%d enriched=%d "
|
||||
"avito_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "
|
||||
"blocked=%d gone=%d failed=%d duration=%.1fs",
|
||||
run_id,
|
||||
counters.attempted,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,323 @@
|
|||
"""Перенос координат из объявлений в дом, когда объявления между собой согласны (#2771).
|
||||
|
||||
ЗАЧЕМ. Дом получает координаты РОВНО ОДИН РАЗ — в момент создания, из породившего его
|
||||
объявления (`matching/houses.py`). Объявление без координат создаёт дом без координат;
|
||||
позже объявление геокодируется (`geoportal_coords_backfill`, `geocode_missing_listings` —
|
||||
обе живые ежедневные задачи), но пишут они в `listings`, а `ON CONFLICT` у вставки дома
|
||||
обновляет только `address`. Обратно в дом координаты не возвращались никогда: из 52
|
||||
источников расписания `houses.lat`/`houses.lon` не писал ни один. Замер на проде 2026-08-07:
|
||||
1945 домов из 9625 (20.2%) без координат, при этом у 1490 из них объявления с координатами
|
||||
УЖЕ ЕСТЬ. Эта задача — недостающее звено проводки, и именно ПОВТОРЯЮЩЕЕСЯ: однократный
|
||||
скрипт закрыл бы дыру на день, а приток новых домов без координат никуда не девается.
|
||||
|
||||
ЧТО ДЕЛАЕТ. Для каждого дома с lat IS NULL берёт geom всех связанных объявлений, считает
|
||||
центроид и РАЗБРОС — максимальное расстояние от центроида до объявления (ST_DistanceSphere,
|
||||
метры на сфере). Разброс <= max_spread_m → центроид пишется в дом; иначе дом НЕ трогается
|
||||
и попадает в счётчик skipped_scattered.
|
||||
|
||||
ПОРОГ 125 м — почему столько (замер на проде 2026-08-07, ST_DistanceSphere, не приближение
|
||||
«градусы × 111320»). Гистограмма разброса по 258 домам с >1 различной точкой не даёт обрыва:
|
||||
плотное ядро 0-125 м (150 домов), затем ровное плато ~4-5 домов на каждые 25 м вплоть до
|
||||
400 м, и масса из 67 домов дальше 400 м. Раз в данных обрыва нет, порог берётся не из них,
|
||||
а из ПОТРЕБИТЕЛЯ: гео-ограждение `house_dedup_merge` (#2187) считает два дома одним зданием
|
||||
при ST_DistanceSphere <= 250 м. Записанный сюда центроид — оценка со своей погрешностью
|
||||
ровно в этот разброс; если разрешить каждому дому ошибаться на все 250 м, два дома с
|
||||
максимальной ошибкой разъедутся на 500 м, и ограждение отвергнет верную пару (или сведёт
|
||||
неверную). Отдаём своей погрешности ПОЛОВИНУ бюджета ограждения — 125 м. Цена решения
|
||||
мала и потому оно не несущее: 125 м пропускает 1382 дома, 250 м — 1403, разница 21 дом,
|
||||
и это не потеря, а отсрочка (дом остаётся NULL и попадёт в следующий прогон, если его
|
||||
объявления сойдутся).
|
||||
|
||||
ЧЕГО НЕ ДЕЛАЕТ. Не обещает роста числа слияний дублей. Сейчас гео-ограждение отбраковывает
|
||||
проигравших с NULL geom, потому что проверить тождество нечем; после переноса у него
|
||||
появляется ВОЗМОЖНОСТЬ оценить пару — часть сольётся, часть будет отвергнута правильно
|
||||
(дома в разных городах). Ни то ни другое здесь не предрешается.
|
||||
|
||||
ДОМА С БОЛЬШИМ РАЗБРОСОМ — не задача переноса, а дефект сопоставителя: в одну запись дома
|
||||
сшиты объявления из разных населённых пунктов (худший на проде — «ул. Кирова,4», разброс
|
||||
290 км). Они не переносятся, считаются отдельным счётчиком и логируются поимённо (топ-5),
|
||||
чтобы пропуск был виден, а не читался как «покрыто всё».
|
||||
|
||||
ИДЕМПОТЕНТНОСТЬ. UPDATE только по `h.lat IS NULL` (тот же приём, что в
|
||||
`backfill_listings_coords_geoportal`). Повторный прогон не трогает уже проставленные
|
||||
координаты и на чистой базе — no-op с нулевыми счётчиками.
|
||||
|
||||
geom СТРОИТСЯ ЯВНО. Триггер `houses_set_geom_trg` на проде — BEFORE INSERT OR UPDATE OF
|
||||
lat, lon (проверено 2026-08-07), то есть geom построился бы и сам; UPDATE всё равно пишет
|
||||
geom явно — как это делает `_update_listing_coords` в geoportal-задаче — чтобы правка не
|
||||
зависела от живости триггера и не оказалась беззвучной, если его когда-нибудь снимут.
|
||||
|
||||
Запуск:
|
||||
python -m app.tasks.backfill_house_coords_from_listings
|
||||
python -m app.tasks.backfill_house_coords_from_listings --max-spread-m 125
|
||||
Ежедневно через in-app scheduler (source='house_coords_from_listings', migration 239).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services import scrape_runs as runs_mod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Половина 250-метрового гео-ограждения house_dedup_merge (#2187) — обоснование в docstring.
|
||||
DEFAULT_MAX_SPREAD_M = 125.0
|
||||
|
||||
# «Разброс в километрах» — порог отдельного счётчика для дефектов сопоставителя (#2777).
|
||||
SCATTER_DEFECT_M = 5000.0
|
||||
|
||||
# Сколько худших домов с разбросом назвать поимённо в логе.
|
||||
_SCATTER_LOG_TOP = 5
|
||||
|
||||
# Кандидаты: КАЖДЫЙ дом без координат + агрегат по его объявлениям. LEFT JOIN, чтобы
|
||||
# «объявлений нет вовсе» и «объявления есть, но без координат» различались в счётчиках.
|
||||
# ST_Collect/count(geom) пропускают NULL — дом без геокодированных объявлений получает
|
||||
# cen IS NULL → lat/lon NULL → в UPDATE не попадает (n_geocoded = 0).
|
||||
_BUILD_CANDIDATES_SQL = """
|
||||
CREATE TEMP TABLE _2771_house_coords ON COMMIT DROP AS
|
||||
WITH coordless AS (
|
||||
SELECT id FROM houses WHERE lat IS NULL OR lon IS NULL
|
||||
),
|
||||
linked AS (
|
||||
SELECT c.id AS house_id, l.id AS listing_id, l.geom
|
||||
FROM coordless c
|
||||
LEFT JOIN listings l ON l.house_id_fk = c.id
|
||||
),
|
||||
agg AS (
|
||||
SELECT house_id,
|
||||
count(listing_id) AS n_listings,
|
||||
count(geom) AS n_geocoded,
|
||||
ST_Centroid(ST_Collect(geom)) AS cen
|
||||
FROM linked
|
||||
GROUP BY house_id
|
||||
),
|
||||
spread AS (
|
||||
SELECT l.house_id,
|
||||
max(ST_DistanceSphere(l.geom, a.cen)) AS spread_m
|
||||
FROM linked l
|
||||
JOIN agg a ON a.house_id = l.house_id
|
||||
WHERE l.geom IS NOT NULL
|
||||
GROUP BY l.house_id
|
||||
)
|
||||
SELECT a.house_id,
|
||||
a.n_listings,
|
||||
a.n_geocoded,
|
||||
ST_Y(a.cen) AS lat,
|
||||
ST_X(a.cen) AS lon,
|
||||
s.spread_m
|
||||
FROM agg a
|
||||
LEFT JOIN spread s ON s.house_id = a.house_id
|
||||
"""
|
||||
|
||||
_COUNTERS_SQL = """
|
||||
SELECT count(*) AS candidates,
|
||||
count(*) FILTER (WHERE n_listings = 0) AS skipped_no_listings,
|
||||
count(*) FILTER (WHERE n_listings > 0 AND n_geocoded = 0) AS skipped_no_listing_coords,
|
||||
count(*) FILTER (WHERE n_geocoded > 0
|
||||
AND spread_m > CAST(:max_spread_m AS double precision))
|
||||
AS skipped_scattered,
|
||||
count(*) FILTER (WHERE n_geocoded > 0
|
||||
AND spread_m > CAST(:defect_m AS double precision))
|
||||
AS scattered_over_5km,
|
||||
COALESCE(max(spread_m) FILTER (WHERE n_geocoded > 0
|
||||
AND spread_m > CAST(:max_spread_m AS double precision)), 0)
|
||||
AS worst_spread_m,
|
||||
count(*) FILTER (WHERE n_geocoded > 0
|
||||
AND spread_m <= CAST(:max_spread_m AS double precision))
|
||||
AS eligible
|
||||
FROM _2771_house_coords
|
||||
"""
|
||||
|
||||
# Идемпотентность: h.lat IS NULL повторяется в UPDATE (а не только в отборе кандидатов) —
|
||||
# уже проставленные координаты не перезаписываются ни при каком стечении обстоятельств.
|
||||
_APPLY_SQL = """
|
||||
UPDATE houses h
|
||||
SET lat = c.lat,
|
||||
lon = c.lon,
|
||||
geom = ST_SetSRID(ST_MakePoint(c.lon, c.lat), 4326)
|
||||
FROM _2771_house_coords c
|
||||
WHERE h.id = c.house_id
|
||||
AND h.lat IS NULL
|
||||
AND c.n_geocoded > 0
|
||||
AND c.spread_m <= CAST(:max_spread_m AS double precision)
|
||||
"""
|
||||
|
||||
_SCATTER_TOP_SQL = """
|
||||
SELECT c.house_id, h.address, c.n_geocoded, c.spread_m
|
||||
FROM _2771_house_coords c
|
||||
JOIN houses h ON h.id = c.house_id
|
||||
WHERE c.n_geocoded > 0
|
||||
AND c.spread_m > CAST(:max_spread_m AS double precision)
|
||||
ORDER BY c.spread_m DESC
|
||||
LIMIT CAST(:top AS integer)
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class HouseCoordsResult:
|
||||
"""Счётчики прогона. Пропуск виден отдельно по КАЖДОЙ причине — молчаливое усечение
|
||||
читается как «покрыто всё»."""
|
||||
|
||||
candidates: int = 0 # домов без координат всего
|
||||
eligible: int = 0 # объявления согласны (разброс <= порога)
|
||||
updated: int = 0 # реально обновлено (UPDATE rowcount)
|
||||
skipped_no_listings: int = 0 # объявлений у дома нет вовсе
|
||||
skipped_no_listing_coords: int = 0 # объявления есть, но сами без координат
|
||||
skipped_scattered: int = 0 # разброс больше порога — дефект сопоставителя (#2777)
|
||||
scattered_over_5km: int = 0 # из них разъехавшиеся на километры
|
||||
worst_spread_m: int = 0 # худший пропущенный разброс, метры
|
||||
max_spread_m: int = 0 # применённый порог, метры (чтобы счётчики читались без кода)
|
||||
duration_sec: float = field(default=0.0)
|
||||
|
||||
def to_counters(self) -> dict[str, int]:
|
||||
return {
|
||||
"candidates": self.candidates,
|
||||
"eligible": self.eligible,
|
||||
"updated": self.updated,
|
||||
"skipped_no_listings": self.skipped_no_listings,
|
||||
"skipped_no_listing_coords": self.skipped_no_listing_coords,
|
||||
"skipped_scattered": self.skipped_scattered,
|
||||
"scattered_over_5km": self.scattered_over_5km,
|
||||
"worst_spread_m": self.worst_spread_m,
|
||||
"max_spread_m": self.max_spread_m,
|
||||
"duration_sec": int(self.duration_sec),
|
||||
}
|
||||
|
||||
|
||||
def backfill_house_coords(
|
||||
db: Session,
|
||||
*,
|
||||
max_spread_m: float = DEFAULT_MAX_SPREAD_M,
|
||||
) -> HouseCoordsResult:
|
||||
"""Перенести координаты объявлений в дома, где объявления согласны.
|
||||
|
||||
Одна транзакция: TEMP-таблица кандидатов (ON COMMIT DROP) → счётчики → UPDATE.
|
||||
Счётчики считаются по ТОЙ ЖЕ таблице, из которой идёт UPDATE, поэтому «сколько
|
||||
пропущено» относится ровно к этому прогону, а не к соседнему запросу.
|
||||
|
||||
Args:
|
||||
db: сессия tradein-БД.
|
||||
max_spread_m: предел разброса объявлений, метры (см. DEFAULT_MAX_SPREAD_M).
|
||||
|
||||
Returns:
|
||||
HouseCoordsResult со счётчиками по каждой причине пропуска.
|
||||
"""
|
||||
start = time.monotonic()
|
||||
res = HouseCoordsResult(max_spread_m=int(max_spread_m))
|
||||
params = {"max_spread_m": max_spread_m, "defect_m": SCATTER_DEFECT_M}
|
||||
|
||||
db.execute(text(_BUILD_CANDIDATES_SQL))
|
||||
|
||||
row = db.execute(text(_COUNTERS_SQL), params).mappings().one()
|
||||
res.candidates = int(row["candidates"])
|
||||
res.eligible = int(row["eligible"])
|
||||
res.skipped_no_listings = int(row["skipped_no_listings"])
|
||||
res.skipped_no_listing_coords = int(row["skipped_no_listing_coords"])
|
||||
res.skipped_scattered = int(row["skipped_scattered"])
|
||||
res.scattered_over_5km = int(row["scattered_over_5km"])
|
||||
res.worst_spread_m = int(row["worst_spread_m"])
|
||||
|
||||
if res.skipped_scattered:
|
||||
worst = (
|
||||
db.execute(text(_SCATTER_TOP_SQL), {**params, "top": _SCATTER_LOG_TOP}).mappings().all()
|
||||
)
|
||||
logger.warning(
|
||||
"house_coords: ПРОПУЩЕНО %d домов с разбросом объявлений > %.0f м "
|
||||
"(из них %d дальше %.0f км) — это дефект сопоставителя (#2777), не переноса. "
|
||||
"Худшие: %s",
|
||||
res.skipped_scattered,
|
||||
max_spread_m,
|
||||
res.scattered_over_5km,
|
||||
SCATTER_DEFECT_M / 1000,
|
||||
"; ".join(
|
||||
f"id={w['house_id']} {w['address']!r} n={w['n_geocoded']} "
|
||||
f"разброс={w['spread_m'] / 1000:.1f} км"
|
||||
for w in worst
|
||||
),
|
||||
)
|
||||
|
||||
res.updated = int(db.execute(text(_APPLY_SQL), params).rowcount or 0)
|
||||
db.commit()
|
||||
|
||||
res.duration_sec = time.monotonic() - start
|
||||
logger.info(
|
||||
"house_coords: DONE — кандидатов=%d годных=%d обновлено=%d | пропущено: "
|
||||
"без_объявлений=%d объявления_без_координат=%d разброс>%.0fм=%d (худший %d м) "
|
||||
"время=%.1fs",
|
||||
res.candidates,
|
||||
res.eligible,
|
||||
res.updated,
|
||||
res.skipped_no_listings,
|
||||
res.skipped_no_listing_coords,
|
||||
max_spread_m,
|
||||
res.skipped_scattered,
|
||||
res.worst_spread_m,
|
||||
res.duration_sec,
|
||||
)
|
||||
return res
|
||||
|
||||
|
||||
# ── Run lifecycle wrapper (scheduler entrypoint) ─────────────────────────────
|
||||
def run_house_coords_from_listings(
|
||||
db: Session,
|
||||
*,
|
||||
run_id: int,
|
||||
params: dict,
|
||||
) -> HouseCoordsResult:
|
||||
"""Run-lifecycle обёртка (kit scheduler, source='house_coords_from_listings').
|
||||
|
||||
Params (default_params jsonb):
|
||||
max_spread_m: предел разброса в метрах (default 125).
|
||||
"""
|
||||
max_spread_m = float(params.get("max_spread_m", DEFAULT_MAX_SPREAD_M))
|
||||
counters: dict[str, int] = HouseCoordsResult(max_spread_m=int(max_spread_m)).to_counters()
|
||||
|
||||
try:
|
||||
runs_mod.update_heartbeat(db, run_id, counters)
|
||||
res = backfill_house_coords(db, max_spread_m=max_spread_m)
|
||||
counters = res.to_counters()
|
||||
runs_mod.mark_done(db, run_id, counters)
|
||||
return res
|
||||
except Exception as exc:
|
||||
logger.exception("run_house_coords_from_listings: run_id=%d FAILED", run_id)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
logger.warning("run_house_coords_from_listings: rollback failed", exc_info=True)
|
||||
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
||||
raise
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Перенос координат объявлений в дома, где объявления согласны (#2771)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-spread-m",
|
||||
type=float,
|
||||
default=DEFAULT_MAX_SPREAD_M,
|
||||
help=f"Предел разброса объявлений, метры (default: {DEFAULT_MAX_SPREAD_M:.0f})",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
backfill_house_coords(db, max_spread_m=args.max_spread_m)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue