Merge remote-tracking branch 'forgejo/main' into pr2547-privacy-work

# Conflicts:
#	tradein-mvp/backend/app/services/estimator.py
#	tradein-mvp/backend/app/services/product_handlers.py
This commit is contained in:
bot-backend 2026-08-06 15:47:03 +03:00
commit 6820337da0
336 changed files with 45378 additions and 6446 deletions

View file

@ -30,6 +30,7 @@ jobs:
outputs: outputs:
backend: ${{ steps.filter.outputs.backend }} backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }} frontend: ${{ steps.filter.outputs.frontend }}
browser: ${{ steps.filter.outputs.browser }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dorny/paths-filter@v3 - uses: dorny/paths-filter@v3
@ -43,10 +44,23 @@ jobs:
# [tool.uv.workspace] меняют реальные зависимости → гейт обязан бежать. # [tool.uv.workspace] меняют реальные зависимости → гейт обязан бежать.
- 'tradein-mvp/uv.lock' - 'tradein-mvp/uv.lock'
- 'tradein-mvp/pyproject.toml' - 'tradein-mvp/pyproject.toml'
# auth/roles.yaml — общий RBAC-конфиг обоих стеков, лежит В КОРНЕ
# репы и монтируется в tradein-backend (/app/auth/roles.yaml).
# tests/test_rbac.py читает именно его, поэтому правка ролей обязана
# гонять и этот гейт. Без строки правка roles.yaml не запускала НИ
# ОДИН сьют (та же дыра закрыта симметрично в ci.yml) — так на main
# уехал красный test_get_role_known_users (2026-07-30 → PR #2587).
- 'auth/**'
- '.forgejo/workflows/ci-tradein.yml' - '.forgejo/workflows/ci-tradein.yml'
frontend: frontend:
- 'tradein-mvp/frontend/**' - 'tradein-mvp/frontend/**'
- '.forgejo/workflows/ci-tradein.yml' - '.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: backend-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -89,15 +103,73 @@ jobs:
run: uv sync --frozen run: uv sync --frozen
- name: Run pytest (tradein-mvp/backend) - name: Run pytest (tradein-mvp/backend)
# DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает # БЕЗ deselect'ов — сьют гоняется целиком (#2722).
# ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state #
# leak из другого test-модуля, pre-existing. Второй исторический deselect # Здесь два года жил `--deselect tests/test_search_api.py::test_search_cache_hit`
# (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в # с объяснением «падает ТОЛЬКО в whole-suite ordering, в изоляции проходит —
# полном прогоне (проверено локально: 2947 passed / 1 failed). Список обязан # global-state leak из другого модуля». Объяснение было неверным в обеих
# совпадать с test-job в deploy-tradein.yml. # половинах: тест падал и в изоляции тоже (401 vs 200), потому что он —
run: | # единственный HTTP-тест в своём файле — ходил в /api/v1/search БЕЗ заголовка
uv run pytest -q \ # X-Authenticated-User, а RBAC-гард отвечает на такое 401 (ровно то, что
--deselect "tests/test_search_api.py::test_search_cache_hit" # фиксирует tests/test_estimate_idor.py). Причина была в тесте, а не в порядке;
# заголовок добавлен, deselect снят, полный прогон зелёный.
#
# Не добавлять сюда новые deselect'ы: молча выключенный тест — это тот же
# класс дефекта, что каталог вне пайплайна (#2722). Тест либо чинится, либо
# помечается xfail с причиной В КОДЕ, где её видно рядом с самим тестом.
#
# NB: в deploy-tradein.yml (post-merge test-job) свой экземпляр этого
# deselect'а — он остаётся до #2680, который правит тот файл. Расхождение
# безвредно: pre-merge гейт тест гоняет, post-merge просто пропустит зелёный.
run: uv run pytest -q
# Тесты браузерного сайдкара (#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.
run: pytest -q
frontend-checks: frontend-checks:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -133,3 +205,9 @@ jobs:
- name: Lint (next lint) - name: Lint (next lint)
# Blocking: любая ESLint-ошибка → job RED. # Blocking: любая ESLint-ошибка → job RED.
run: npm run lint run: npm run lint
- name: Mera-public isolation guard (#2631)
# Blocking: статический import-graph публичного лэндинга не должен
# достигать закрытого контура (useMe/lib/api/sessionId/isPathAllowed/
# GuardedRoute вне next/dynamic). Инвариант этапа 1 #2545.
run: npm run check:mera-public-isolation

View file

@ -52,6 +52,14 @@ jobs:
backend: backend:
- 'backend/**' - 'backend/**'
- 'data/sql/**' - 'data/sql/**'
# auth/roles.yaml — общий RBAC-конфиг ОБОИХ стеков (bind-mount в
# backend и в tradein-backend). Правка ролей/пользователей меняет
# поведение backend/tests/test_rbac.py, но сам файл лежит вне
# 'backend/**' → без этой строки сьют no-op'ился, и правка уезжала
# в main без единого прогона. Так и случилось 2026-07-30: user2
# переведён в expired, test_get_role_known_users стал красным и
# доехал до main незамеченным (починен в PR #2587).
- 'auth/**'
- '.forgejo/workflows/ci.yml' - '.forgejo/workflows/ci.yml'
frontend: frontend:
- 'frontend/**' - 'frontend/**'

View file

@ -16,6 +16,10 @@ on:
- ".forgejo/workflows/deploy.yml" - ".forgejo/workflows/deploy.yml"
- "data/sql/**" - "data/sql/**"
- "ops/glitchtip-auth-forwarder/**" - "ops/glitchtip-auth-forwarder/**"
# Bootstrap-SQL (создание БД auth, ALTER ROLE паролем из env) исполняется шагом
# деплоя ниже — без этого триггера правка bootstrap-файла молча не доезжала бы
# до прода до следующего чужого коммита в backend/.
- "ops/db-bootstrap/**"
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
@ -320,6 +324,70 @@ jobs:
echo "⚠️ GENDESIGN_FDW_PASSWORD not set in backend/.env.runtime — skipping ALTER ROLE for tradein_fdw_reader" echo "⚠️ GENDESIGN_FDW_PASSWORD not set in backend/.env.runtime — skipping ALTER ROLE for tradein_fdw_reader"
fi fi
# ── БД `auth` — единое хранилище доступов «Меры» и «Птицы» ──────────────
# Расположение файлов: схема лежит в data/sql/auth/ (ПОДКАТАЛОГ, не плоский
# data/sql/) — цикл миграций выше использует `ls -1 data/sql/*.sql`, который в
# подкаталоги не рекурсирует. Значит эти файлы физически не могут примениться
# в БД gendesign, даже если кто-то забудет про разделение; при этом триггер
# `data/sql/**` (paths выше) подкаталог покрывает, деплой запускается сам.
# Свой _schema_migrations живёт ВНУТРИ БД auth: отдельная база — отдельный
# трекинг, имена файлов двух каталогов не конфликтуют между собой.
# Порядок: сразу после bootstrap'а FDW-пароля и ДО `compose up -d` — падение
# здесь останавливает деплой (exit 1) до подъёма нового кода.
# `source backend/.env.runtime` уже выполнен выше (строка с FDW-паролем), из него
# берётся AUTH_DB_PASSWORD.
echo "→ Bootstrapping auth database (idempotent)"
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=on \
< ops/db-bootstrap/create_auth_db.sql \
|| { echo "FAILED to create auth database"; exit 1; }
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on -c "
CREATE TABLE IF NOT EXISTS _schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
"
for sql_file in $(ls -1 data/sql/auth/*.sql 2>/dev/null | sort); do
fname=$(basename "$sql_file")
# `| tr -d '[:space:]'` — как в deploy-tradein.yml: без него psql-вывод с
# лишним пробелом/CR ломает сравнение с "0" и миграция молча считается
# применённой.
applied=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d auth -tAc \
"SELECT COUNT(*) FROM _schema_migrations WHERE filename='$fname'" \
| tr -d '[:space:]')
if [ "$applied" = "0" ]; then
echo "→ Applying auth migration: $fname"
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on \
< "$sql_file" \
|| { echo "FAILED on auth migration: $fname"; exit 1; }
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d auth -c \
"INSERT INTO _schema_migrations (filename) VALUES ('$fname') ON CONFLICT DO NOTHING;"
else
echo "✓ Already applied (auth): $fname"
fi
done
echo "All auth migrations applied."
# Пароль роли auth_app из env (post-migration bootstrap): миграция
# data/sql/auth/002_auth_app_role.sql создаёт роль БЕЗ пароля, пароль живёт
# только в /opt/gendesign/backend/.env.runtime. Пустая переменная — не ошибка:
# PR-1 ещё никого не подключает к этой БД, роль просто остаётся без пароля.
if [ -n "${AUTH_DB_PASSWORD:-}" ]; then
echo "→ Applying auth_app password from env"
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on \
-v "pw=$AUTH_DB_PASSWORD" \
< ops/db-bootstrap/set_auth_app_password.sql
else
echo "⚠️ AUTH_DB_PASSWORD not set in backend/.env.runtime — skipping ALTER ROLE for auth_app"
fi
# Build local-only sidecar images (glitchtip-auth-forwarder). # Build local-only sidecar images (glitchtip-auth-forwarder).
# Эти services не в GHCR — сборка происходит на VPS на каждом deploy. # Эти services не в GHCR — сборка происходит на VPS на каждом deploy.
# Cache-friendly: первый build ~30s, последующие 1-3s если файлы не менялись. # Cache-friendly: первый build ~30s, последующие 1-3s если файлы не менялись.

View file

@ -0,0 +1,38 @@
# Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска).
#
# НЕ pre-merge гейт — эти 4 проверки требуют реального DNS + выпущенного TLS-
# сертификата для meraocenka.ru, т.е. осмысленны ТОЛЬКО против прода после
# деплоя. Запускается вручную (workflow_dispatch) или раз в сутки (cron) —
# страхует от случайной регрессии периметра (например, будущий PR по ошибке
# открывает B2B-путь на публичном домене, или basic_auth gate на gendsgn.ru
# случайно снимают).
#
# ДО того как появится DNS A-record meraocenka.ru → IP VPS, проверки 1 и 2
# (см. scripts/smoke-mera-perimeter.sh) ожидаемо КРАСНЫЕ — это не регресс,
# просто домен ещё не резолвится. Проверки 3 и 4 не зависят от DNS нового
# домена и обязаны быть зелёными всегда.
name: perimeter-smoke-mera
on:
workflow_dispatch: {}
schedule:
# Раз в сутки, 06:17 UTC — вне пиков, время произвольное.
- cron: '17 6 * * *'
concurrency:
group: perimeter-smoke-mera
cancel-in-progress: false
jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Run perimeter smoke checks
run: |
chmod +x scripts/smoke-mera-perimeter.sh
./scripts/smoke-mera-perimeter.sh

211
Caddyfile
View file

@ -11,6 +11,13 @@
# Users managed via caddy/users.caddy.snippet (git history = audit trail). # Users managed via caddy/users.caddy.snippet (git history = audit trail).
# Public exclusions: /health (liveness probe), /preview/* (static mockups). # Public exclusions: /health (liveness probe), /preview/* (static mockups).
# #
# #2558: с 2026-07 basic_auth гейтит ТОЛЬКО Site Finder (`/`, `/api/*`,
# `/analytics` и т.д.). `/trade-in/*` (+ `/sale-share` redirect) вынесены ВЫШЕ
# import'ау trade-in своя авторизация (форма входа + opaque session-cookie,
# см. #2552) поверх RBAC (`tradein-mvp/backend/app/core/rbac.py`). Site Finder
# всё ещё легаси-пилотный basic_auth (roles.yaml dual-mode остаётся живым для
# него — НЕ трогать caddy/users.caddy.snippet).
#
# IMPORTANT: route { } block is required to preserve directive order. # IMPORTANT: route { } block is required to preserve directive order.
# Without route { }, Caddy executes directives in hard-coded default order # Without route { }, Caddy executes directives in hard-coded default order
# (basic_auth runs before handle), making /health and /preview/* exclusions # (basic_auth runs before handle), making /health and /preview/* exclusions
@ -70,26 +77,56 @@ gendsgn.ru {
# Оба ДО auth-import, иначе ассеты страницы уходят в @tradein (под auth) → 401 → без CSS. # Оба ДО auth-import, иначе ассеты страницы уходят в @tradein (под auth) → 401 → без CSS.
@uipreview path /trade-in/ui-preview/* /trade-in/_next/static/* @uipreview path /trade-in/ui-preview/* /trade-in/_next/static/*
handle @uipreview { handle @uipreview {
reverse_proxy tradein-frontend:3000 reverse_proxy tradein-frontend:3000 {
# #2558 review: тот же периметр-scrub, что и у /trade-in/api/* и
# @tradein ниже — этот блок тоже теперь ДО basic_auth, клиент
# мог бы прислать свой X-Authenticated-User. Сейчас инертно
# (страница статична, у tradein-frontend нет секрета для
# X-Internal-Auth-Secret), но убираем ради единообразия периметра,
# а не полагаясь на то, что downstream ничего не делает с заголовком.
header_up -X-Authenticated-User
}
} }
# Auth gate (applies to all routes below within this route block). # #2558: Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker
import caddy/users.caddy.snippet # stack, подключен через gendesign_shared network. Секция ЦЕЛИКОМ ДО
# `import caddy/users.caddy.snippet` ниже — /trade-in имеет собственную
# Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker stack, # авторизацию (форма входа + opaque session-cookie, #2552; RBAC-проверка
# подключен через gendesign_shared network. Routes ДО универсального handle # роли внутри tradein-backend, `app/core/rbac.py`), Site Finder basic_auth
# потому что Caddy матчит handle-блоки сверху вниз. # ей больше не нужен и не должен применяться (short-circuit сверху вниз,
# как /health и /preview/* выше).
#
# X-Authenticated-User — ЯВНОЕ УДАЛЕНИЕ (`header_up -X-Authenticated-User`),
# НЕ `header_up X-Authenticated-User {http.auth.user.id}`. Причина: этот
# блок больше не идёт ПОСЛЕ basic_auth, поэтому `{http.auth.user.id}`
# никогда не резолвится авторизованным юзером на этом пути.
# Проверено эмпирически (echo-стенд на образе caddy:2, `caddy adapt`):
# старая Set-форма (`header_up X-Authenticated-User {http.auth.user.id}`)
# НЕ пропустила бы клиентский заголовок насквозь и НЕ оставила бы поле
# пустым — Caddy подставляет НЕРАЗРЕШЁННЫЙ плейсхолдер как ЛИТЕРАЛЬНУЮ
# строку (`ReplaceKnown`), т.е. upstream получил бы буквально
# `X-Authenticated-User: {http.auth.user.id}`. Для backend (auth_mode=
# "dual", `app/core/config.py`) это НЕ подмена личности — legacy path
# (`rbac.py:186`) сделал бы `get_role("{http.auth.user.id}")`, юзер не
# найден в roles.yaml → 403 для всех. Т.е. старая форма была бы не
# security-дырой, а fail-closed-but-сломанной (все trade-in запросы без
# session-cookie получали бы 403 вместо ожидаемого 401/редиректа на логин).
# `-Field` остаётся правильным выбором не потому что Set был бы дырой, а
# потому что это ЕДИНСТВЕННАЯ форма с явно задокументированной семантикой
# "удалить заголовок" (Caddyfile reverse_proxy directive: `-<field>` =
# delete) — корректное поведение не должно зависеть от того, как именно
# Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции.
# X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается
# из env (Set-операция с непустым значением, никак не связана с auth-гейтом
# basic_auth), это единственное, что теперь отсекает подделку заголовков
# изнутри gendesign_shared network для legacy dual-mode пути.
handle /trade-in/api/* { handle /trade-in/api/* {
# `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api; # `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только # FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
# префикса basePath /trade-in (Next.js basePath leak). # префикса basePath /trade-in (Next.js basePath leak).
uri strip_prefix /trade-in uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 { reverse_proxy tradein-backend:8000 {
header_up X-Authenticated-User {http.auth.user.id} header_up -X-Authenticated-User
# #2213 defense-in-depth: общий секрет Caddy↔tradein-backend. header_up
# с value ПЕРЕЗАПИСЫВАЕТ (стирает) любой клиентский X-Internal-Auth-Secret —
# тот же механизм, что защищает X-Authenticated-User выше. Пусто пока
# TRADEIN_INTERNAL_AUTH_SECRET не задан в .env (fail-open, backend не проверяет).
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET} header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
} }
} }
@ -98,6 +135,23 @@ gendsgn.ru {
# Next basePath=/trade-in → редиректим на канонический /trade-in/sale-share # Next basePath=/trade-in → редиректим на канонический /trade-in/sale-share
# (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL # (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL
# в адресной строке требует отдельного Next-app с basePath=/sale-share. # в адресной строке требует отдельного Next-app с basePath=/sale-share.
# #2558: перенесён ВЫШЕ auth-import вместе с trade-in — редирект ведёт на
# /trade-in/sale-share, для которого теперь нет Caddy basic_auth (как и
# для остального /trade-in). Это НЕ делает страницу публичной: она всё
# ещё за собственной авторизацией trade-in — `RouteGuard` во фронте
# (`app/layout.tsx`) и сессия для `/api/v1/buildings/sale-share*` на
# бэке; без валидной сессии юзер получит редирект на /login, а не
# контент. Смысл переноса — не открыть страницу всем, а убрать
# несогласованность: короткий URL не должен быть строже (Caddy
# basic_auth) целевого адреса, к которому и так уже нет
# basic_auth-барьера (только собственный login trade-in).
#
# ОБНОВЛЕНО 2026-07-31: доступ к разделу сузился с «pilot + admin» до
# ТОЛЬКО admin — «Поиск домов» признан тестовым продуктом, клиентам не
# показывается (deny в auth/roles.yaml для pilot и analyst + в
# DB_ROLE_PATHS для employee/manager). Сам редирект не трогаем: он ведёт
# на страницу, а гейт стоит на роли — для всех, кроме admin, короткий
# адрес приведёт на NoAccessScreen.
@saleshare path /sale-share /sale-share/ @saleshare path /sale-share /sale-share/
handle @saleshare { handle @saleshare {
redir /trade-in/sale-share permanent redir /trade-in/sale-share permanent
@ -110,13 +164,17 @@ gendsgn.ru {
handle @tradein { handle @tradein {
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL # Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
reverse_proxy tradein-frontend:3000 { reverse_proxy tradein-frontend:3000 {
header_up X-Authenticated-User {http.auth.user.id} # См. комментарий над /trade-in/api/* выше — та же логика (явное
# #2213: симметрично с /trade-in/api/* — перезаписываем секрет из env # удаление вместо Set с пустым {http.auth.user.id}).
# (стирает клиентский), на случай SSR-forwardʼa фронтом в backend. header_up -X-Authenticated-User
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET} header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
} }
} }
# Auth gate — с #2558 применяется ТОЛЬКО к Site Finder (handle /api/* и
# handle {} ниже). Trade-In уже отработал и short-circuit'нул выше.
import caddy/users.caddy.snippet
handle /api/* { handle /api/* {
reverse_proxy backend:8000 { reverse_proxy backend:8000 {
header_up X-Authenticated-User {http.auth.user.id} header_up X-Authenticated-User {http.auth.user.id}
@ -135,6 +193,129 @@ www.gendsgn.ru {
redir https://gendsgn.ru{uri} permanent redir https://gendsgn.ru{uri} permanent
} }
# МЕРА B2C — публичный периметр (ЭТАП 1 плана B2C-запуска, БЕЗ функционала).
#
# Архитектурное решение: отдельный домен, а НЕ дырка в блоке gendsgn.ru
# выше. На gendsgn.ru модель "запрещено всё, кроме дырок ВЫШЕ auth-import" —
# порядко-зависимая и общая для B2B (trade-in v2, admin, scrapers, /api/*).
# Здесь, наоборот, allowlist-by-default: basic_auth НЕТ ВООБЩЕ (не импортируем
# caddy/users.caddy.snippet), потому что на этом site-блоке B2B-маршрутов
# физически не объявлено — их нечего "открывать". Явно перечислены РОВНО два
# handle (корень "/" + статика Next _next/*), всё остальное — финальный
# catch-all `handle { respond 404 }`. Регресс-тест на эту модель:
# scripts/smoke-mera-perimeter.sh (проверяет, что B2B-путь здесь = 404, а не
# 200/401 — т.е. не был случайно проброшен).
#
# Next.js basePath=/trade-in запечён в prod-образ tradein-frontend (тот же
# контейнер, что обслуживает и gendsgn.ru/trade-in/*, см. build-args в
# .forgejo/workflows/deploy-tradein.yml) — поэтому корень домена rewrite'ится
# на internal-путь /trade-in/mera-public (страница-заглушка,
# tradein-mvp/frontend/src/app/mera-public/). Пользователь префикс /trade-in
# никогда не видит — rewrite меняет путь ТОЛЬКО для Caddy→backend запроса,
# это не HTTP-редирект браузера.
#
# DNS: A-record meraocenka.ru → IP VPS — ТРЕБУЕТСЯ ДО того, как сюда придёт
# реальный трафик. Если записи ещё нет на момент деплоя этого блока: `caddy
# reload`/`up -d --force-recreate caddy` в deploy.yml НЕ падает (конфиг
# синтаксически валиден, ошибка сертификата асинхронна и per-hostname) — Caddy
# просто залогирует неудачную попытку ACME-выпуска для meraocenka.ru (DNS не
# резолвится на этот сервер → HTTP-01/TLS-ALPN challenge недостижим) и продолжит
# ретраить с backoff, ПОКА запись не появится. Остальные site-блоки в этом же
# Caddyfile (gendsgn.ru, obsidian.gendsgn.ru и т.д.) не затрагиваются —
# автоматический HTTPS в Caddy изолирован per-hostname (тот же принцип, что
# уже описан для status.gendsgn.ru ниже). Повторные неудачные попытки ДО
# появления DNS могут исчерпать rate-limit Let's Encrypt (5 failed
# validations/hostname/hour) — не критично, просто подождать; `docker volume
# rm gendesign_caddy_data` для этого НЕ нужен (и вообще требует user-approval).
meraocenka.ru {
encode zstd gzip
log {
output file /var/log/caddy/meraocenka.ru.log
}
# Корень домена → лэндинг МЕРЫ (#2615 заменил заглушку этого этапа на
# полноценную страницу). rewrite добавляет basePath-префикс только для
# Caddy→backend хопа, пользователь /trade-in никогда не видит.
handle / {
rewrite * /trade-in/mera-public
reverse_proxy tradein-frontend:3000 {
# Тот же периметр-скраб, что у @uipreview (:87) и @tradein ниже.
# Этот блок вообще не под basic_auth, поэтому анонимный клиент
# тем более может прислать свой X-Authenticated-User. Сейчас
# инертно (лэндинг статичен, backend-вызовов нет), но снимаем
# ради единообразия периметра, а не полагаясь на то, что
# downstream ничего не делает с заголовком — иначе на этапе 5,
# когда откроется публичный /estimate, это станет дырой.
header_up -X-Authenticated-User
}
}
# Подстраницы САМОГО лэндинга. Нужны с момента мержа #2615: футер ссылается
# на политику обработки ПДн через next/link (`PRIVACY_PATH`), а Next с
# basePath эмитит её как /trade-in/mera-public/privacy. Без этого handle
# ссылка уходила бы в catch-all 404 ниже — то есть обязательный по 152-ФЗ
# документ был бы недоступен с публичной страницы.
#
# Matcher намеренно узкий — ровно поддерево лэндинга, НЕ /trade-in/*.
# B2B-дерево (/trade-in/v2, /trade-in/api/*, /trade-in/admin/*, /history)
# под него не подпадает и по-прежнему отдаёт 404. Регресс-тест на это —
# в scripts/smoke-mera-perimeter.sh.
handle /trade-in/mera-public/* {
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
}
# Next.js уже эмитит ссылки на статику с /trade-in-префиксом (тот же
# basePath) — passthrough без rewrite. Нужны для рендера страницы (JS/CSS
# чанки), сами по себе не содержат ни B2B-данных, ни секретов.
#
# Именно `static/*`, а не весь `_next/*` — тот же матчер, что у @uipreview
# (:78), который в проде доказал, что этого хватает для рендера. Широкий
# `_next/*` открыл бы анонимам ещё и `/_next/image` (оптимизация картинок,
# CPU-нагрузка по запросу), который на лэндинге не используется вообще:
# next/image в tradein-mvp/frontend/src/app/mera-public/ не импортируется.
handle /trade-in/_next/static/* {
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
}
# #2631: favicon — единственный корневой статик, который браузер запрашивает
# сам; без явного handle падал в allowlist-404. app/favicon.ico отдаёт Next
# по корневому пути через basePath /trade-in.
handle /favicon.ico {
rewrite * /trade-in/favicon.ico
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
}
# Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin,
# /scrapers/*, /trade-in/api/*, /history, ...) — 404, НЕ проксируется.
handle {
respond 404
}
}
# Домены-спутники МЕРА → 301 на канонический meraocenka.ru.
# Решение 2026-07-31: канонический адрес ровно один, остальные две регистрации
# ловят (а) альтернативный транслит «оценка» — ocenka/otsenka, на слух
# неразличимы, (б) прежний рабочий вариант merahome. Отдельные site-блоки, а не
# matcher внутри основного: Caddy матчит по hostname и выпускает свой
# сертификат на каждый, поэтому DNS A-record нужен для КАЖДОГО из них — иначе
# ACME для этого хоста будет ретраиться (безвредно, см. комментарий выше, но
# лучше завести записи сразу).
# `{uri}` сохраняет путь и query — короткая ссылка с визитки не теряет ?id=.
merahome.ru {
redir https://meraocenka.ru{uri} permanent
}
meraotsenka.ru {
redir https://meraocenka.ru{uri} permanent
}
# Obsidian Self-hosted LiveSync (CouchDB backend). # Obsidian Self-hosted LiveSync (CouchDB backend).
# Auto-TLS Let's Encrypt. CORS уже включён на стороне CouchDB через bootstrap # Auto-TLS Let's Encrypt. CORS уже включён на стороне CouchDB через bootstrap
# (см. scripts/setup-couchdb.sh). Basic-auth — на стороне CouchDB (admin user). # (см. scripts/setup-couchdb.sh). Basic-auth — на стороне CouchDB (admin user).

View file

@ -39,6 +39,39 @@ roles:
- "/admin/**" - "/admin/**"
- "/api/v1/admin/**" - "/api/v1/admin/**"
- "/trade-in/api/v1/admin/**" - "/trade-in/api/v1/admin/**"
# Внутренние разделы, закрытые от клиентских аккаунтов (решение владельца
# продукта 2026-07-31): «Доля в продаже» — аналитика рынка, «Кэш» —
# состояние кэшей/скраперов. Зеркало deny-списка DB-ролей employee/manager
# (tradein-mvp/backend/app/services/auth_session.py: DB_ROLE_PATHS).
#
# Зачем копия здесь, если клиенты ходят session-cookie'ой: снаружи легаси
# trusted-header ветка НЕДОСТИЖИМА — с #2558 Caddy срезает входящий
# X-Authenticated-User на всём /trade-in/* (`header_up
# -X-Authenticated-User` в handle /trade-in/api/* и в @tradein), так что
# ни один клиентский аккаунт по ней не ходит. Паттерны нужны для другого:
# 1) ВНУТРИСЕТЕВОЙ dual-mode трафик — запросы изнутри gendesign_shared с
# валидным X-Internal-Auth-Secret; ими ходят QA-смоуки вида
# `docker exec tradein-backend curl localhost:8000
# -H 'X-Authenticated-User: ...'` — они резолвятся именно через
# roles.yaml, и без этих строк смоук показал бы 200 там, где
# реальный клиент получает 403;
# 2) чтобы legacy-pilot не расходился с DB-employee, если dual-режим
# когда-нибудь снова окажется на периметре (откат #2558 / новый
# фронт-прокси) — тогда расхождение молча откроет разделы.
# НЕ удалять как «мёртвые»: они мёртвые только пока Caddy режет заголовок.
#
# Страницы + их API вместе: deny гейтит пункт меню (Topbar через /me),
# саму страницу (RouteGuard) и серверные ручки (rbac_guard).
#
# cache-stats закрыт ГЛОБОМ, а не точным путём, намеренно: точный паттерн
# обходится трейлинг-слэшем ('…/cache-stats/' не равен '…/cache-stats' →
# allowed), и защита повисала бы на Starlette redirect_slashes, а не на
# RBAC. '<prefix>/**' → '^<prefix>(?:/.*)?$': сам путь + слэш + подпути,
# но НЕ соседи по префиксу ('…/cache-statistics' не матчится).
- "/trade-in/sale-share/**"
- "/trade-in/cache/**"
- "/trade-in/api/v1/buildings/**"
- "/trade-in/api/v1/trade-in/cache-stats/**"
analyst: analyst:
# #962 (EPIC18, ТЗ §19): analyst видит ВСЁ (deals, insights, exports, # #962 (EPIC18, ТЗ §19): analyst видит ВСЁ (deals, insights, exports,
# site-finder, analytics, concept) КРОМЕ admin/data-management. # site-finder, analytics, concept) КРОМЕ admin/data-management.
@ -48,12 +81,28 @@ roles:
# для любого role != "admin" → analyst авто-403 на admin-API без доп. кода. # для любого role != "admin" → analyst авто-403 на admin-API без доп. кода.
# deny ниже драйвит фронтовый RouteGuard (deny_paths из /me) для UI-gating # deny ниже драйвит фронтовый RouteGuard (deny_paths из /me) для UI-gating
# /admin/** страниц. # /admin/** страниц.
# Клиентский deny 2026-07-31 (см. pilot выше) распространён на analyst
# ЧАСТИЧНО — асимметрия намеренная, не недосмотр:
# «Поиск домов» (/trade-in/sale-share + /api/v1/buildings/**) — ЗАКРЫТ.
# Решение владельца продукта 2026-07-31: это ТЕСТОВЫЙ продукт, доступ
# только у admin. «Только у админа» = включая внутренние роли, поэтому
# analyst тоже в deny.
# «Кэш» (/trade-in/cache + cache-stats) — ОСТАВЛЕН открытым: это не
# продукт, а диагностика состояния кэшей/скраперов, т.е. ровно тот
# рабочий инструмент, ради которого роль analyst и заведена
# («видит ВСЁ кроме admin-управления», см. выше).
# Обе стороны этой асимметрии запиннены тестом
# tradein-mvp/backend/tests/test_rbac.py::test_yaml_roles_deliberately_outside_client_deny
# — если решение поменяется, тест упадёт и заставит обновить и его, и этот
# комментарий, а не тихо разойтись с реальностью.
paths: paths:
- "/**" - "/**"
deny: deny:
- "/admin/**" - "/admin/**"
- "/api/v1/admin/**" - "/api/v1/admin/**"
- "/trade-in/api/v1/admin/**" - "/trade-in/api/v1/admin/**"
- "/trade-in/sale-share/**"
- "/trade-in/api/v1/buildings/**"
expired: expired:
# Пробный доступ закончился — нет доступа ни к чему. Аккаунт остаётся в # Пробный доступ закончился — нет доступа ни к чему. Аккаунт остаётся в
# caddy/users.caddy.snippet (basic_auth), чтобы дойти до фронта и увидеть # caddy/users.caddy.snippet (basic_auth), чтобы дойти до фронта и увидеть
@ -70,7 +119,8 @@ users:
admin: admin admin: admin
kopylov: pilot kopylov: pilot
user1: pilot user1: pilot
user2: pilot # «Брусника» — доступ восстановлен 2026-07-13 (снят trial-expire от 2026-07-09) user2: expired # «Брусника» — доступ закрыт 2026-07-30 (решение владельца продукта;
# ранее: восстановлен 2026-07-13, trial-expire 2026-07-09)
user3: pilot user3: pilot
user4: pilot user4: pilot
user5: pilot user5: pilot

269
backend/app/core/auth_db.py Normal file
View file

@ -0,0 +1,269 @@
"""Engine + session-factory для БД `auth` — общего реестра людей (эпик «единый вход»).
Отдельный модуль, а не ещё пара строк в `app.core.db`, ровно по одной причине:
`app.core.db` создаёт engine НА ИМПОРТЕ (`create_engine(settings.database_url)` в
теле модуля, db.py:8). Сделай мы так же для БД `auth` приложение начало бы
падать на старте везде, где реестр не сконфигурирован: локально, в pytest и на
любом стенде, где переменных AUTH_* нет. Здесь engine создаётся ЛЕНИВО, при
первом реальном обращении.
Контракт ( после мержа прод обязан работать ТОЧНО как сейчас Caddy basic_auth
ещё стоит и снимается последним PR эпика):
* `AUTH_MODE=legacy` (ДЕФОЛТ; `settings.auth_session_enabled is False`) в этот
модуль не заходит никто: `app.main.rbac_guard` в этом режиме куку не читает
вовсе. Пустая конфигурация БД `auth` при этом не ошибка ни на импорте, ни в
рантайме; ни одно соединение с БД `auth` не открывается.
* Режим включён (`dual`/`db_only`) + не сконфигурированный реестр обращение поднимает
`AuthDatabaseNotConfiguredError` с внятным текстом. Именно исключение, а НЕ
тихий возврат «сессия не найдена»: молчаливая деградация означала бы, что все
владельцы валидных кук выглядят как анонимы, то есть массовый отказ доступа
под видом «просто не залогинен» либо, если guard в этот момент откатывается
на trusted-header, наоборот, раздача прав в обход реестра (включая аккаунты с
access_state 'disabled'). Оба исхода обязаны быть громкими.
«Птица» реестр только ЧИТАЕТ: сессии выдаёт и отзывает единственная форма входа
у «Меры». Здесь нет и не должно появиться ни create-, ни revoke-пути.
Сам DSN этот модуль НЕ выбирает и НЕ склеивает берёт готовый у
`settings.resolved_auth_database_url` (явный `AUTH_DATABASE_URL`, иначе сборка из
`AUTH_DB_PASSWORD` + частей хоста/порта/базы/пользователя, иначе пусто).
В DSN пароль роли `auth_app`. Он не логируется и не попадает в текст
исключений НИ В ОДНОЙ ветке этого модуля: сообщения ниже константы, а ошибку
разбора URL от SQLAlchemy (её текст содержит исходную строку) мы перехватываем и
заменяем своей, обрывая цепочку `from None`, чтобы исходник не всплыл в traceback.
Добавляешь сюда `logger`/`raise ... {dsn}` не добавляй.
`create_engine` сам по себе к серверу не ходит (пул коннектов ленивый) то есть
одна лишь сборка engine доказывает только «DSN не пуст и парсится». Поэтому
`require_auth_db_configured` (fail-fast старта) дополнительно ОТКРЫВАЕТ соединение
и делает `SELECT 1`: неверный пароль, опечатка в хосте, отсутствующая БД и
отозванная роль обязаны ронять деплой, а не превращаться в «ни у кого нет сессии».
Зеркало по подходу: tradein-mvp/backend/app/core/auth_db.py («Мера»). Синхронизация
руками стеки разные, общего кода между ними нет и заводить его этот эпик не
собирается.
"""
from __future__ import annotations
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from sqlalchemy import Engine, create_engine, text
from sqlalchemy.exc import ArgumentError
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings
class AuthDatabaseNotConfiguredError(RuntimeError):
"""`AUTH_MODE` не `legacy`, а DSN БД `auth` не задан/не разобрался."""
class AuthDatabaseUnreachableError(RuntimeError):
"""DSN синтаксически корректен, но соединиться по нему не удалось (старт приложения)."""
_NOT_CONFIGURED_MSG = (
"Приём сессионной куки включён (AUTH_MODE=dual|db_only), но реестр людей "
"(БД `auth`) не сконфигурирован: пусты и AUTH_DB_PASSWORD, и AUTH_DATABASE_URL — "
"подключаться не к чему. Задай в backend/.env.runtime AUTH_DB_PASSWORD (пароль "
"роли auth_app; остальные части DSN — AUTH_DB_HOST/AUTH_DB_PORT/AUTH_DB_NAME/"
"AUTH_DB_USER — имеют прод-дефолты), либо целиком AUTH_DATABASE_URL, либо верни "
"AUTH_MODE=legacy (сегодняшнее поведение: Caddy basic_auth + заголовок "
"X-Authenticated-User)."
)
_UNREACHABLE_MSG = (
"Приём сессионной куки включён (AUTH_MODE=dual|db_only), DSN разобрался, но "
"соединиться с БД `auth` не удалось (см. причину ниже: хост/порт/база/роль/пароль "
"или сеть). Старт прерван намеренно: иначе сломанная конфигурация выглядела бы как "
"«ни у кого нет сессии» — сутками, при живом приложении и 200-х в ответах. Проверь "
"AUTH_DB_* в backend/.env.runtime и пароль роли auth_app (data/sql/auth/002), либо "
"верни AUTH_MODE=legacy."
)
# Текст для нечитаемого DSN. БЕЗ подстановки самого DSN — там пароль; исходную
# ошибку SQLAlchemy (она цитирует строку целиком) гасим `from None`.
_MALFORMED_DSN_MSG = (
"DSN БД `auth` не разобрался SQLAlchemy. Проверь AUTH_DATABASE_URL (если задан "
"явно) либо части AUTH_DB_HOST/AUTH_DB_PORT/AUTH_DB_NAME/AUTH_DB_USER. Схема "
"обязана быть postgresql+psycopg:// (psycopg v3). Сам DSN сюда намеренно НЕ "
"подставлен: в нём пароль роли auth_app."
)
# Кеш engine/factory + защита от гонки: rbac_guard будет резолвить сессию на каждом
# non-public запросе, а uvicorn обслуживает их из нескольких потоков (sync-роуты
# уходят в threadpool). Без лока два одновременных первых запроса создали бы два
# engine — то есть два независимых пула коннектов, один из которых потеряется.
_LOCK = threading.Lock()
_engine: Engine | None = None
_session_factory: sessionmaker[Session] | None = None
def _build() -> tuple[Engine, sessionmaker[Session]]:
"""Создаёт engine + session-factory по текущему DSN. Нет DSN → явная ошибка.
DSN резолвит `settings` (явный AUTH_DATABASE_URL или сборка из AUTH_DB_*)
здесь только «пусто или нет» и создание engine.
`pool_size`/`max_overflow` не переопределяем: дефолтов SQLAlchemy (5+10) хватает
с запасом на запрос приходится один короткий SELECT, а раз в 5 минут ещё и
UPDATE sliding-refresh.
А вот таймауты переопределяем, и это не тюнинг, а требование: реестр НЕ
критический путь «Птицы», его сбой обязан деградировать за секунды, а не за
минуты (в dual-режиме деградация уход на легаси-заголовок, в db_only 401).
* `connect_timeout=3` (libpq, секунды). Без него дропнутые SYN (хост поднят, но
недоступен по сети / фаервол молча глотает пакеты) держат попытку соединения
до TCP-таймаута ОС на Linux порядка 130 с. `pool_pre_ping=True` делает такую
попытку на КАЖДОМ checkout'е.
* `statement_timeout=3000` (мс, серверный). Ограничивает уже установленное
соединение: залипший SELECT/UPDATE в auth-пути не имеет права висеть дольше.
* `pool_timeout=3` ожидание свободного коннекта в пуле. Дефолтные 30 с в
auth-пути не нужны никогда: лучше быстро сдаться.
Резолв сессии в rbac_guard уходит в threadpool (`run_in_threadpool`), так что эти
ожидания не блокируют event loop, но они всё равно держат worker-поток и время
ответа, поэтому короткие.
"""
dsn = settings.resolved_auth_database_url
if not dsn:
raise AuthDatabaseNotConfiguredError(_NOT_CONFIGURED_MSG)
try:
engine = create_engine(
dsn,
pool_pre_ping=True,
future=True,
pool_timeout=3,
connect_args={"connect_timeout": 3, "options": "-c statement_timeout=3000"},
)
except (ArgumentError, ValueError):
# ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до
# `int(port)` и падает с `invalid literal for int() with base 10: 'w'`, где
# 'w' — КУСОК ПАРОЛЯ, съехавший на позицию порта. `from None` обязателен: он
# гасит цепочку, иначе исходная ошибка (а с ней и этот кусок) печатается в
# traceback как «During handling of...».
raise AuthDatabaseNotConfiguredError(_MALFORMED_DSN_MSG) from None
factory = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
return engine, factory
def _ensure_built() -> tuple[Engine, sessionmaker[Session]]:
global _engine, _session_factory
# Быстрый путь читает глобалы РОВНО ОДИН раз, в локальные переменные. Читать их
# второй раз в `return` нельзя: между проверкой и возвратом может вклиниться
# `reset_auth_db()` (обнуляет оба под локом) — и функция вернула бы (None, None),
# то есть вызывающий упал бы на `factory()` → `TypeError: 'NoneType' object is not
# callable` прямо в auth-пути.
engine, factory = _engine, _session_factory
if engine is not None and factory is not None:
return engine, factory
with _LOCK:
if _engine is None or _session_factory is None:
_engine, _session_factory = _build()
return _engine, _session_factory
def get_auth_engine() -> Engine:
"""Engine БД `auth` (создаётся при первом вызове).
Raises:
AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни
AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался.
"""
engine, _ = _ensure_built()
return engine
def get_auth_session_factory() -> sessionmaker[Session]:
"""Session-factory БД `auth` (создаётся при первом вызове).
Raises:
AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни
AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался.
"""
_, factory = _ensure_built()
return factory
@contextmanager
def auth_session() -> Iterator[Session]:
"""Сессия к БД `auth`, закрывается на выходе из блока.
Это НЕ `app.core.db.get_db`: там продуктовая БД gendesign, где таблиц
`users`/`sessions` реестра нет. Прямой вызов из роутов не предполагается
ходи через `app.services.auth_session.resolve_session_token()`.
"""
factory = get_auth_session_factory()
with factory() as db:
yield db
def _probe_connection(engine: Engine) -> None:
"""Открывает соединение и делает `SELECT 1`. Вынесено функцией ради тестов.
Отдельная функция, а не две строки в `require_auth_db_configured`: тестам нужна
точка подмены, чтобы проверять ветвление старта, не поднимая Postgres.
"""
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
def require_auth_db_configured() -> None:
"""Fail-fast для старта приложения: включённый режим обязан иметь РАБОЧИЙ реестр.
Вызывается из `lifespan` (`app/main.py:111`). Смысл проверки именно на старте: если
сломанная конфигурация обнаружится только в rbac_guard, там её поймает общий
`except` вокруг резолва сессии, и она будет выглядеть как «ни у кого нет сессии»
сутками, потому что продуктовая БД жива и приложение работоспособно, а сигнал
остаётся только в логах. Дешевле не стартовать.
Проверяется ИМЕННО СОЕДИНЕНИЕ, а не только синтаксис DSN. `create_engine` к серверу
не ходит вовсе (пул ленивый), поэтому одна лишь сборка engine отлавливала бы ровно
два случая «DSN пуст» и «DSN не парсится», а весь класс вероятных ошибок
(неверный AUTH_DB_PASSWORD, опечатка в хосте, не созданная БД `auth`, отозванная
роль auth_app, нет сетевой связности) проходил бы мимо и материализовался как та
самая тихая деградация, ради которой эта функция и заведена. Проба короткая:
`connect_timeout=3` в `_build`.
Цена контейнер не поднимется, пока БД `auth` недоступна. Это осознанно: реестр
живёт на ТОМ ЖЕ сервере, что и продуктовая БД (сервис `postgres` корневого
docker-compose.prod.yml, см. `app/core/config.py`), так что «реестр недоступен, а
продукт работоспособен» состояние вырожденное, а `restart: unless-stopped`
поднимет контейнер, как только Postgres вернётся.
Режим `legacy` (ДЕФОЛТ) no-op: ни проверки DSN, ни создания engine, ни коннекта.
Дефолтное поведение обязано оставаться ровно сегодняшним.
Raises:
AuthDatabaseNotConfiguredError: режим не `legacy`, но DSN пуст или не разобрался.
AuthDatabaseUnreachableError: DSN разобрался, но соединиться не удалось.
"""
if not settings.auth_session_enabled:
return
engine, _ = _ensure_built()
try:
_probe_connection(engine)
except Exception as exc:
# Исходную ошибку СОХРАНЯЕМ в цепочке (`from exc`): в ней хост/порт/роль и
# причина отказа — то, ради чего проверка и делается. Пароля libpq в тексте
# ошибок не печатает, а наш DSN сюда не подставляется (см. модульный докстринг).
raise AuthDatabaseUnreachableError(_UNREACHABLE_MSG) from exc
def reset_auth_db() -> None:
"""Сбрасывает закешированные engine/factory (смена DSN в рантайме, тесты).
Старый engine `dispose()`-ится вне лока: закрытие пула может блокировать, а
держать в это время лок незачем ссылки на него уже сняты.
"""
global _engine, _session_factory
with _LOCK:
stale = _engine
_engine = None
_session_factory = None
if stale is not None:
stale.dispose()

View file

@ -1,10 +1,46 @@
import os import os
import warnings import warnings
from typing import Annotated from typing import Annotated, Literal
from urllib.parse import quote
from pydantic import field_validator, model_validator from pydantic import SecretStr, field_validator, model_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
# ── Дефолтные части DSN БД `auth` (общий реестр людей, эпик «единый вход») ─────
# Вынесены константами, потому что используются ДВАЖДЫ: как дефолт поля и как
# запасное значение, если переменная окружения задана ПУСТОЙ строкой
# (`AUTH_DB_HOST=` в .env.runtime не должен давать DSN вида `...@:5432/auth`).
#
# ⚠️ ХОСТ — главная ловушка, и для «Птицы» она ЗЕРКАЛЬНА ловушке «Меры».
# У «Меры» (tradein-mvp/backend/app/core/config.py:27) дефолт — `gendesign-postgres`,
# потому что внутри ЕЁ стека имя `postgres` резолвится в её собственный контейнер
# (tradein-mvp/docker-compose.prod.yml:143 собирает им продуктовый DATABASE_URL
# `...@postgres:5432/tradein`), и БД `auth` там нет.
#
# У «Птицы» ровно наоборот: её стек и есть главный. Сервис `postgres` в корневом
# docker-compose.prod.yml:22 (postgis/postgis:16-3.4) — это И ЕСТЬ тот сервер, где
# живёт БД `auth`: bootstrap и миграции data/sql/auth/*.sql применяет к нему шаг
# «Apply DB migrations» в .forgejo/workflows/deploy.yml:339-375. Соседи по тому же
# compose-проекту так к нему и обращаются — `@postgres:5432` (docker-compose.prod.yml:232
# и :265, DATABASE_URL сервисов glitchtip).
#
# Алиас `gendesign-postgres` (docker-compose.prod.yml:43-45) навешен ТОЛЬКО в внешней
# сети `shared` (gendesign_shared) и заведён ради ЧУЖИХ стеков — им и пользуется
# «Мера». Ставить его дефолтом здесь нельзя: в сети `shared` состоят лишь backend и
# worker (`networks: [default, shared]`, строки 152 и 199), а `beat` (строки 201-217)
# сетей не объявляет вовсе — он только в `default`, и `gendesign-postgres` из него
# просто не разрезолвится. `postgres` резолвится из всех трёх.
#
# Порт 5432 — ВНУТРИСЕТЕВОЙ порт контейнера. Публикация `127.0.0.1:5432:5432`
# (docker-compose.prod.yml:31-32) существует только ради SSH-туннеля с хоста и к
# этому пути отношения не имеет.
_AUTH_DB_DEFAULT_HOST = "postgres"
_AUTH_DB_DEFAULT_PORT = 5432
_AUTH_DB_DEFAULT_NAME = "auth"
# Роль приложения из data/sql/auth/002_auth_app_role.sql (least privilege: SELECT/
# INSERT/UPDATE/DELETE на sessions, SELECT + column-level UPDATE на users).
_AUTH_DB_DEFAULT_USER = "auth_app"
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@ -371,5 +407,201 @@ class Settings(BaseSettings):
# на недоступном сервисе. ENV: DADATA_TIMEOUT_S. # на недоступном сервисе. ENV: DADATA_TIMEOUT_S.
dadata_timeout_s: float = 8.0 dadata_timeout_s: float = 8.0
# ── Эпик «единый вход»: «Птица» ПРИНИМАЕТ сессию общего реестра ────────────
# Форма входа во всём продукте одна и живёт у «Меры» (/trade-in/login): она
# проверяет пароль и выдаёт сессию в auth.sessions. «Птица» сессии НЕ выдаёт и
# НЕ отзывает — только читает куку и резолвит её в человека. Кука host-only на
# gendsgn.ru с path="/" (tradein-mvp/backend/app/api/v1/auth.py:173-181),
# поэтому браузер шлёт её на оба продукта одного домена.
#
# Режим — ТРЁХЗНАЧНЫЙ, а не булев флаг, и это сделано ради последнего PR эпика:
# legacy (ДЕФОЛТ) — сегодняшнее поведение бит-в-бит: кука не читается вовсе,
# личность берётся из X-Authenticated-User (Caddy basic_auth);
# engine БД `auth` не создаётся, соединение не открывается,
# отсутствие AUTH_* в окружении не роняет старт;
# dual — сначала кука общего реестра, при её отсутствии/сбое реестра
# деградация на легаси-заголовок (переходный режим: popup
# Caddy ещё стоит и прикрывает заголовок от подделки);
# db_only — легаси-ветка НЕДОСТИЖИМА: нет валидной сессии → 401, даже
# если X-Authenticated-User присутствует.
#
# Почему именно так, а не `AUTH_SESSION_ENABLED=true/false`. В dual-режиме сбой
# реестра (или просто отсутствие куки) уводит запрос на trusted-header. Пока
# popup стоит, это безопасно: заголовок на `/api/*` перезаписывает Caddy из
# basic_auth (Caddyfile:178-182), клиент подставить его не может. Ровно в тот
# момент, когда последний PR эпика снимет `basic_auth` + `header_up`, заголовок
# станет полностью клиентским — и та же деградация превратится в ПОЛНЫЙ обход
# аутентификации (`curl -H 'X-Authenticated-User: admin'`). Булев флаг оставлял бы
# это на память мейнтейнера («не забыть выпилить фолбэк»); режим делает переход
# сменой ОДНОГО значения (`AUTH_MODE=db_only`), а недостижимость легаси-ветки в
# нём закреплена тестами (tests/test_auth_session_guard.py, секция db_only).
# Зеркало «Меры»: tradein-mvp/backend/app/core/config.py:91 (`auth_mode`); там
# значений два — легаси-режима у неё уже нет, она на реестре с #2552.
#
# ⚠️ ДЕФОЛТ `legacy` — ЧАСТЬ КОНТРАКТА PR, А НЕ ЗАГЛУШКА: после мержа прод обязан
# работать ровно как сегодня (popup Caddy снимается последним PR эпика).
# Читатели режима: `app.main.rbac_guard` (какой источник личности и есть ли
# фолбэк), `app.services.auth_session.resolve_session_token` и
# `app.core.auth_db.require_auth_db_configured` — через производное свойство
# `auth_session_enabled` ниже.
#
# Включение на проде = одна переменная: AUTH_DB_PASSWORD в backend/.env.runtime
# уже есть (её пишет ops и читает .forgejo/workflows/deploy.yml:381-386, чтобы
# сделать ALTER ROLE auth_app), остальные части DSN имеют прод-дефолты.
# ENV: AUTH_MODE.
auth_mode: Literal["legacy", "dual", "db_only"] = "legacy"
# DSN БД `auth` целиком. Пусто по умолчанию — задавать руками не обязательно:
# см. `resolved_auth_database_url` ниже, при пустом значении DSN собирается из
# AUTH_DB_PASSWORD + частей. Явное значение, если оно есть, выигрывает всегда
# (аварийный обход: другой хост, sslmode, байпас пула). ENV: AUTH_DATABASE_URL.
auth_database_url: str = ""
# Пароль роли auth_app. Живёт в ОДНОМ месте — этой переменной: требовать вдобавок
# целиковый AUTH_DATABASE_URL значило бы держать один секрет в двух местах
# (сменили пароль роли, забыли переписать DSN → вход ложится молча и целиком).
#
# SecretStr, а не str как у соседних секретов файла: `repr(settings)` и
# `settings.model_dump()` печатают обычные str-поля ДОСЛОВНО. Сегодня их никто не
# рендерит, но появиться такой рендер может тихо — с SecretStr он напечатает
# `SecretStr('**********')`. Значение достаётся ровно в одном месте —
# `.get_secret_value()` в резолвере ниже. Соседи (openai_api_key, dadata_api_secret,
# database_url) остались str — это предсуществующее положение, а не «там безопасно».
# ENV: AUTH_DB_PASSWORD.
auth_db_password: SecretStr = SecretStr("")
# Остальные части — с дефолтами, верными для ЭТОГО стека (см. константы выше и
# разбор ловушки хоста). Переопределяются через ENV для локального запуска (напр.
# AUTH_DB_HOST=localhost + AUTH_DB_PORT=15432 поверх SSH-туннеля).
# ENV: AUTH_DB_HOST, AUTH_DB_PORT, AUTH_DB_NAME, AUTH_DB_USER.
auth_db_host: str = _AUTH_DB_DEFAULT_HOST
auth_db_port: int = _AUTH_DB_DEFAULT_PORT
auth_db_name: str = _AUTH_DB_DEFAULT_NAME
auth_db_user: str = _AUTH_DB_DEFAULT_USER
# Имя cookie сессии. ОБЯЗАНО совпадать с тем, которым пользуется «Мера»
# (tradein-mvp/backend/app/core/config.py:84-86) — иначе браузер шлёт куку, а
# «Птица» её не узнаёт и молча остаётся без сессии.
#
# ⚠️ Имя ИСТОРИЧЕСКОЕ: оно родилось в trade-in до того, как реестр стал общим, и
# «tradein_» в нём теперь ни о чём не говорит. Переименование разлогинивает ВСЕХ
# и СРАЗУ в обоих продуктах (старую куку никто больше не читает), поэтому меняется
# только отдельным решением — синхронно в обоих стеках и с обдуманным моментом.
# ENV: SESSION_COOKIE_NAME.
session_cookie_name: str = "tradein_session"
# TTL сессии в часах (720 = 30 дней) — тот же дефолт, что у «Меры»
# (tradein-mvp/backend/app/core/config.py:88). «Птица» сессии не выдаёт, поэтому
# значение используется ЕДИНСТВЕННЫМ образом: на сколько sliding-refresh отодвигает
# expires_at (app/services/auth_session.py). Держать его РАВНЫМ значению «Меры»
# обязательно — иначе срок жизни сессии начнёт зависеть от того, в каком продукте
# человек кликнул последним. ENV: SESSION_TTL_HOURS.
session_ttl_hours: int = 720
@field_validator("auth_mode", mode="before")
@classmethod
def _blank_auth_mode_means_legacy(cls, value: object) -> object:
"""`AUTH_MODE=` (пустая строка) → `legacy`, а не ValidationError на импорте.
Та же ловушка, что у `AUTH_DB_PORT` ниже: `settings = Settings()` выполняется на
уровне модуля, поэтому невалидное значение роняет ИМПОРТ конфига и уводит
контейнер в restart-loop. Сценарий тот же ops копирует блок AUTH_* в
.env.runtime и заполняет только пароль. Пустое значение обязано означать
«оставили как было», то есть сегодняшнее поведение.
Регистр и обрамляющие пробелы нормализуются: `AUTH_MODE=DB_ONLY ` очевидная
опечатка со смыслом, а не запрос на падение. Непустой мусор (`AUTH_MODE=off`)
по-прежнему валится, и правильно: молча трактовать его как `legacy` значило бы
тихо оставить продукт на trusted-header после снятия popup'а.
"""
if isinstance(value, str):
normalized = value.strip().lower()
return normalized or "legacy"
return value
@property
def auth_session_enabled(self) -> bool:
"""Читает ли «Птица» сессионную куку общего реестра (то есть режим не `legacy`).
Производное от `auth_mode`, а не отдельное поле: два независимых переключателя
рано или поздно разъезжаются, и получилось бы состояние «куку читаем, но режим
легаси» (или наоборот), которого нет ни в одном настоящем сценарии.
Держит инвариант «`legacy` = ни одного коннекта к реестру»: по этому свойству
закорачиваются `app.services.auth_session.resolve_session_token` и
`app.core.auth_db.require_auth_db_configured`. Разница между `dual` и `db_only`
свойству не видна и не должна быть она касается только фолбэка на
легаси-заголовок и живёт в `app.main.rbac_guard`.
"""
return self.auth_mode != "legacy"
@field_validator("auth_db_port", mode="before")
@classmethod
def _blank_auth_db_port_means_default(cls, value: object) -> object:
"""`AUTH_DB_PORT=` (пустая строка) → прод-дефолт, а не падение на импорте.
Симметрия с host/name/user, у которых пустое значение переменной падает
обратно на дефолт в резолвере. Для порта того же добиться нельзя: он
типизирован `int` и валидируется pydantic'ом ДО всякой нашей логики, а
`settings = Settings()` выполняется на уровне модуля то есть `AUTH_DB_PORT=`
в .env.runtime роняло бы ValidationError на импорте конфига и уводило контейнер
в restart-loop. Причём В ЛЮБОМ режиме, включая дефолтный (флаг выключен), где к
БД `auth` не идёт ни одного обращения ровно тот инвариант «дефолт не трогаем»,
который держит весь этот PR.
Сценарий не гипотетический: ops копирует блок AUTH_DB_* в .env.runtime и
заполняет только пароль остальные строки остаются пустыми намеренно.
`mode="before"` потому что вмешаться надо ДО приведения к int. Непустой мусор
(`AUTH_DB_PORT=abc`) по-прежнему валится, и правильно: это опечатка со смыслом,
а не «оставил пустым».
"""
if isinstance(value, str) and not value.strip():
return _AUTH_DB_DEFAULT_PORT
return value
@property
def resolved_auth_database_url(self) -> str:
"""DSN БД `auth` — единственный источник правды для `app.core.auth_db`.
Приоритет:
1. `AUTH_DATABASE_URL`, если задан выигрывает всегда.
2. Иначе, если задан `AUTH_DB_PASSWORD` DSN собирается из частей.
3. Иначе пустая строка, то есть «не сконфигурировано». Это НЕ ошибка сама
по себе: при `AUTH_MODE=legacy` (дефолт) сюда не заходит никто.
Ошибку явную, а не тихий фолбэк поднимает `app.core.auth_db`, и только
когда реестр реально понадобился.
Возвращаемое значение СОДЕРЖИТ ПАРОЛЬ: не логировать, не класть в текст
исключений, не отдавать наружу (`/health`, `/docs`, метрики).
Пароль экранируется `quote(..., safe="")`: спецсимвол (`@`, `:`, `/`, `?`, `#`,
`%`) внутри пароля иначе порвал бы URL по своей грамматике `@` сдвинул бы
границу host, `/` открыл бы path. Разбор дал бы либо ошибку, либо, что хуже,
МОЛЧА другой хост/базу. По той же причине экранируется имя пользователя.
А вот имя БД и хост НЕ экранируются, и это не забывчивость: SQLAlchemy
раскодирует обратно только userinfo (user/password), а path отдаёт как есть.
Прогони мы имя БД через `quote`, в сервер уехало бы литеральное `c%2Fd` вместо
`c/d`. Хосту %-кодирование тоже только мешает оно поломало бы IPv6-скобки.
"""
explicit = self.auth_database_url.strip()
if explicit:
return explicit
# `.strip()` только для ПРОВЕРКИ «задан ли»: пробельная строка в .env — это
# опечатка, а не пароль. В сам DSN идёт значение КАК ЕСТЬ (не стриппится):
# ведущий/хвостовой пробел может быть частью настоящего пароля.
password = self.auth_db_password.get_secret_value()
if not password.strip():
return ""
user = quote(self.auth_db_user.strip() or _AUTH_DB_DEFAULT_USER, safe="")
secret = quote(password, safe="")
host = self.auth_db_host.strip() or _AUTH_DB_DEFAULT_HOST
port = self.auth_db_port
name = self.auth_db_name.strip() or _AUTH_DB_DEFAULT_NAME
# Схема — ровно та же, что у продуктового database_url (psycopg v3;
# `postgresql://` без суффикса увёл бы SQLAlchemy на psycopg2, которого в
# зависимостях нет).
return f"postgresql+psycopg://{user}:{secret}@{host}:{port}/{name}"
settings = Settings() settings = Settings()

View file

@ -3,11 +3,14 @@
import logging import logging
import os import os
import re import re
import threading
import time
from collections.abc import AsyncIterator, Awaitable, Callable from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import sentry_sdk import sentry_sdk
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response from fastapi.responses import JSONResponse, Response
from sentry_sdk.integrations.celery import CeleryIntegration from sentry_sdk.integrations.celery import CeleryIntegration
@ -41,10 +44,12 @@ from app.api.v1 import (
trade_in, trade_in,
users, users,
) )
from app.core import auth_db
from app.core.audit_middleware import audit_log_middleware from app.core.audit_middleware import audit_log_middleware
from app.core.auth import get_role from app.core.auth import get_role
from app.core.config import settings from app.core.config import settings
from app.observability.sentry_scrub import scrub_sensitive_query from app.observability.sentry_scrub import scrub_sensitive_query
from app.services.auth_session import resolve_session_token
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -97,6 +102,18 @@ if settings.glitchtip_dsn:
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# Эпик «единый вход», fail-fast: AUTH_MODE=dual|db_only обязан иметь РАБОЧИЙ
# реестр — проверяется не только разбор DSN, но и живое соединение (`SELECT 1`,
# app/core/auth_db.py). Не соединились → контейнер НЕ стартует. Режим `legacy`
# (ДЕФОЛТ) → no-op: ни проверки DSN, ни создания engine, ни коннекта.
#
# Почему именно на старте, а не «разберёмся в рантайме»: неверный пароль, опечатка
# в хосте, не созданная БД `auth` иначе ловились бы `except`'ом вокруг резолва
# сессии в rbac_guard, и сломанная конфигурация выглядела бы как «ни у кого нет
# сессии» — СУТКАМИ, потому что продуктовая БД жива, приложение отвечает 200, а
# сигнал остаётся только в логах. Дешевле не стартовать: деплой падает сразу и
# громко.
auth_db.require_auth_db_configured()
yield yield
@ -122,10 +139,212 @@ app.middleware("http")(audit_log_middleware)
# 3) /api/v1/admin/* — только role=admin, иначе 403. # 3) /api/v1/admin/* — только role=admin, иначе 403.
# Public paths без auth (/health, /docs, /openapi.json) пропускаем без проверки — # Public paths без auth (/health, /docs, /openapi.json) пропускаем без проверки —
# X-Authenticated-User там просто не приходит из Caddy. # X-Authenticated-User там просто не приходит из Caddy.
#
# Эпик «единый вход»: к правилу 1 добавляется ПЕРВЫЙ источник личности —
# сессионная кука общего реестра (БД `auth`). Выдаёт её единственная форма входа, у
# «Меры» (/trade-in/login); «Птица» сессии только читает. Кука host-only на
# gendsgn.ru с path="/" → браузер шлёт её и сюда. Порядок: кука → легаси-заголовок.
# Дальше — ВСЁ как раньше: роль из auth/roles.yaml, admin-гейт по _ADMIN_API_RE.
# Реестр отвечает на вопрос «кто ты», roles.yaml — «что тебе можно»; продуктовые
# роли реестра (auth.users.role) в «Птицу» намеренно не протаскиваются.
#
# ⚠️ AUTH_MODE=legacy ПО УМОЛЧАНИЮ — popup Caddy basic_auth ещё стоит и снимается
# ПОСЛЕДНИМ PR эпика. Пока режим legacy, этот файл ведёт себя бит-в-бит как до эпика:
# кука не читается, БД `auth` не открывается. `dual` — переходный режим (кука, при её
# отсутствии/сбое реестра фолбэк на заголовок), `db_only` — фолбэка нет вовсе.
#
# ⚠️ ДОЛГ, КОТОРЫЙ ОБЯЗАН БЫТЬ ЗАКРЫТ ДО СНЯТИЯ POPUP'А (не решается этим PR).
# Guard проверяет ровно две вещи: есть ли username в auth/roles.yaml (get_role) и
# admin-гейт по _ADMIN_API_RE. Списки `paths`/`deny` из roles.yaml на бэкенде НЕ
# применяются — это зафиксировано в самом auth/roles.yaml:33-35 («path-level
# enforcement делает frontend RouteGuard»). Следствие: в момент включения режима
# «Птицу» получает КАЖДЫЙ аккаунт реестра, чей username совпадает с записью в
# roles.yaml, — включая роль `expired` (user2: paths: [], deny: "/**"), которую
# сегодня останавливает только фронт. Это не регрессия (те же люди сегодня в
# caddy/users.caddy.snippet и добираются туда же через basic_auth), но эпик делает
# её несущей: (а) до снятия popup'а отзыв доступа имеет ДВА рубильника —
# caddy-snippet и access_state в реестре, их надо держать синхронными; (б) после
# снятия roles.yaml остаётся ЕДИНСТВЕННЫМ гейтом, и `expired` в нём станет чисто
# фронтовой фикцией. Перед включением: сверить `auth.users.username` на проде с
# `users:` в roles.yaml и решить — применять `paths`/`deny` на бэкенде или убрать
# `expired` как вводящий в заблуждение.
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/") _ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"}) _PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"})
def _propagate_authenticated_user(request: Request, username: str) -> None:
"""Инжектит `X-Authenticated-User` в ASGI-scope — ПЕРЕЗАПИСЫВАЯ, а не дополняя.
🔴 Перезапись, а не «поставить, если отсутствует» это требование безопасности,
а не стилистика. В бэкенде «Птицы» ОДИННАДЦАТЬ мест читают этот заголовок НАПРЯМУЮ,
мимо guard'а, и решают по нему, кто автор/кому принадлежат данные:
app/core/audit_middleware.py:169 атрибуция строки аудита;
app/api/v1/me.py:30 чей scope отдать (роль + фильтры);
app/api/v1/insights.py:74/124/138 created_by + _require_user (POST/PUT/DELETE);
app/api/v1/own_projects.py:69/115/131 created_by + _require_user (POST/PUT/DELETE);
app/api/v1/parcels.py:1481 GET /{cad_num}/forecast;
app/api/v1/parcels.py:1902 POST /{cad_num}/analyze (created_by рана,
parcels.py:4212, и 3-й аргумент forecast_site_finder_report.delay, :4226);
сам rbac_guard ниже легаси-ветка.
Ни одно из них не знает про сессию: для них истина сырой заголовок. Оставь мы
skip-if-present клиент с ВАЛИДНОЙ кукой прошёл бы guard как он сам, а во все эти
места уехал бы его собственный подставленный `X-Authenticated-User: <кто угодно>`
(Caddy шлёт этот заголовок на каждый прод-запрос, так что «просто добавить» его
было бы некуда). Ровно этот баг ловили у «Меры» #2552 post-review, CRITICAL.
Резолвнутая сессия ОБЯЗАНА быть единственным источником личности.
Механизм: `request.scope` один и тот же dict, прокинутый ПО ССЫЛКЕ через весь
ASGI-стек (Starlette не копирует scope между слоями). Мутация здесь видна:
всей downstream-цепочке мы мутируем ДО вызова call_next();
audit-middleware он ВНУТРЕННИЙ относительно rbac_guard (см. комментарий у
app.middleware("http")(audit_log_middleware) выше: LIFO-регистрация даёт
порядок rbac_guard audit router), т.е. его Request строится уже после
мутации. У «Меры» этот слой, наоборот, внешний, и там мутация до него
доезжает только потому, что читается ПОСЛЕ call_next.
Имена заголовков в ASGI по спеке всегда lowercase bytes, и uvicorn/TestClient
её соблюдают. Фильтр всё равно нормализует ключ сам (`k.lower()`), а не полагается
на спеку: попади в scope запись `b"X-Authenticated-User"` (другой ASGI-сервер,
самодельный слой, тест-харнесс) точное сравнение оставило бы её в списке рядом с
нашей. Читатели при этом видели бы правильное значение (`Headers.get` лоуэркейсит
искомый ключ, но не хранимый, так что смешанный регистр не матчится никогда), то
есть дыры нет но состояние «две записи с одним именем» в scope не должно
существовать: оно ложное по построению и ломает любой обход списка глазами.
`errors="replace"` в encode: латиницей логины реестра не ограничены, а падать
UnicodeEncodeError в auth-пути нельзя.
NB: `request.headers` САМОГО этого Request уже закеширован (мы читали cookies) и
останется старым. Это не мешает: в session-ветке guard больше не читает заголовок,
а нижележащие слои строят свой Request поверх обновлённого scope.
"""
request.scope["headers"] = [
(k, v) for k, v in request.scope.get("headers", []) if k.lower() != b"x-authenticated-user"
] + [(b"x-authenticated-user", username.encode("latin-1", "replace"))]
# Троттлинг алерта «реестр не отвечает». Резолв сессии идёт на КАЖДОМ non-public
# запросе с кукой, а `logger.exception` уровня ERROR уезжает событием в GlitchTip
# (LoggingIntegration event_level=ERROR, см. sentry_sdk.init выше) — то есть лежащий
# реестр давал бы поток событий, пропорциональный трафику: квота/rate-limit выгорают
# за минуты, и настоящие ошибки этого же периода теряются. Полный traceback печатаем
# не чаще раза в минуту (с числом подавленных за окно), остальное — WARNING без
# exc_info, чтобы факт продолжающегося сбоя всё равно был виден в логах.
# Лок нужен по-настоящему: функция исполняется в threadpool'е, то есть параллельно.
_REGISTRY_FAILURE_ALERT_INTERVAL_S = 60.0
_REGISTRY_FAILURE_LOCK = threading.Lock()
_registry_failure_last_alert = 0.0
_registry_failure_suppressed = 0
def _reset_registry_failure_throttle() -> None:
"""Сбрасывает окно троттлинга. Для тестов: состояние модульное и живёт между ними."""
global _registry_failure_last_alert, _registry_failure_suppressed
with _REGISTRY_FAILURE_LOCK:
_registry_failure_last_alert = 0.0
_registry_failure_suppressed = 0
def _log_registry_failure(path: str) -> None:
"""Логирует сбой резолва: раз в окно — ERROR с traceback, иначе WARNING.
Зовётся ТОЛЬКО из `except`-блока: `logger.exception` берёт traceback из текущего
sys.exc_info().
"""
global _registry_failure_last_alert, _registry_failure_suppressed
now = time.monotonic()
with _REGISTRY_FAILURE_LOCK:
alert = (now - _registry_failure_last_alert) >= _REGISTRY_FAILURE_ALERT_INTERVAL_S
if alert:
suppressed = _registry_failure_suppressed
_registry_failure_last_alert = now
_registry_failure_suppressed = 0
else:
suppressed = 0
_registry_failure_suppressed += 1
if alert:
logger.exception(
"RBAC: резолв сессии не удался на %s — эти запросы обслуживаются по "
"легаси-пути (Caddy basic_auth + X-Authenticated-User); подавлено таких же "
"за предыдущее окно: %d",
path,
suppressed,
)
else:
logger.warning(
"RBAC: резолв сессии не удался на %s (traceback подавлен троттлингом, "
"следующий — не раньше чем через %.0f с)",
path,
_REGISTRY_FAILURE_ALERT_INTERVAL_S,
)
def _resolve_session_username(token: str | None, path: str) -> str | None:
"""Логин из сессионной куки, либо None, если личность по куке не установлена.
🔴 СИНХРОННАЯ и вызывается ТОЛЬКО через `run_in_threadpool` (см. rbac_guard):
внутри psycopg-I/O (checkout из пула + SELECT, раз в 5 минут ещё UPDATE и
commit). Позови её напрямую из корутины guard'а — и весь API «Птицы»
сериализуется за один round-trip к БД `auth` на каждый запрос, а недоступный
реестр (или исчерпанный пул) заморозит event loop целиком, включая /health. Ровно
этот инцидент уже был на соседнем middleware #1202, см. комментарий в
app/core/audit_middleware.py:175-181, там он и починен через `run_in_threadpool`.
Токен принимается ГОТОВЫМ (а не `Request`) именно поэтому: разбор Cookie-заголовка
дёшев и делается на loop'е, в поток уезжает только строка.
None означает ровно одно «личность по куке не установлена», и вызывающий обязан
трактовать это одинаково во всех трёх случаях: куки нет, кука невалидна (нет
строки / истекла / access_state не active), резолв УПАЛ.
Поведение при сбое БД `auth` (осознанный выбор, а не «поймали и забыли»): логируем
ERROR с traceback он уезжает событием в GlitchTip (LoggingIntegration
event_level=ERROR, см. sentry_sdk.init выше), т.е. это алерт, а не строчка, которую
никто не увидит (частота ограничена окном, `_log_registry_failure`), и в режиме
`dual` деградируем к легаси-ветке, то есть к сегодняшнему поведению: Caddy
basic_auth + X-Authenticated-User. В режиме `db_only` деградации нет: guard
отвечает 401.
Почему НЕ 503/500. Пока идёт переходный период, popup basic_auth стоит перед
бэкендом, и легаси-ветка защищена ровно тем же, чем защищён весь продукт сегодня,
множество людей, способных вообще достучаться, не расширяется. Отдавать же 503
значит класть «Птицу» целиком из-за проблемы, которую basic_auth уже покрывает
(отозванный пароль роли auth_app, пересозданная БД `auth`, исчерпанный пул её
engine всё это не мешает продуктовой БД gendesign работать).
Почему это не «тихий фолбэк на легаси». Опасный сценарий не «реестр упал», а
«реестр не сконфигурирован»: тогда права раздавались бы из roles.yaml в обход
реестра (включая аккаунты с access_state disabled/trial_expired) бессрочно и молча.
Этот сценарий сюда НЕ доходит: конфигурацию проверяет lifespan, причём НЕ на глазок
`require_auth_db_configured` открывает соединение и делает `SELECT 1`, так что мимо
него не проходят ни пустой/битый DSN, ни неверный пароль, ни опечатка в хосте, ни
отозванная роль (app/core/auth_db.py). Здесь остаётся только второй рубеж реестр,
отвалившийся ПОСЛЕ успешного старта.
Отдельно про отзыв доступа: пароли Caddy basic_auth (caddy/users.caddy.snippet)
и `auth.users.access_state` РАЗНЫЕ списки. Человек, которому в реестре поставили
disabled/trial_expired, свой basic_auth-пароль не теряет, поэтому на время
недоступности реестра деградация возвращает его в строй. То есть отзыв тут не
«строже сегодняшнего», а откатывается к состоянию ДО отзыва при включении режима
caddy-snippet надо прополоть под список активных аккаунтов реестра.
Когда последний PR эпика снимет popup, эта деградация обязана уйти вместе с ним:
без basic_auth впереди фолбэк на легаси-заголовок превращается в дыру заголовок
станет полностью клиентским. Механика перехода уже готова: `AUTH_MODE=db_only`
(см. app/core/config.py), в нём легаси-ветка недостижима и этот возврат None
означает 401, а не «попробуем заголовок».
"""
if not token:
# Нет куки — ни одного обращения к БД `auth`. Это весь сегодняшний трафик.
return None
try:
session_user = resolve_session_token(token)
except Exception:
_log_registry_failure(path)
return None
if session_user is None:
return None
return session_user.username
@app.middleware("http") @app.middleware("http")
async def rbac_guard( async def rbac_guard(
request: Request, request: Request,
@ -134,6 +353,17 @@ async def rbac_guard(
# Test-mode bypass: pytest бьёт по app мимо Caddy → нет X-Authenticated-User. # Test-mode bypass: pytest бьёт по app мимо Caddy → нет X-Authenticated-User.
# СТРОГО gated на settings.testing (default False) — прод RBAC не затронут. # СТРОГО gated на settings.testing (default False) — прод RBAC не затронут.
# RBAC-логика покрыта отдельно в tests/test_rbac.py (своя копия middleware). # RBAC-логика покрыта отдельно в tests/test_rbac.py (своя копия middleware).
#
# ⚠️ Он ОТКЛЮЧАЕТ ВЕСЬ guard целиком, включая session-ветку ниже, — и это сказано
# здесь явно, чтобы не выглядело недосмотром. Следствие для тестов: сессионный путь
# НЕЛЬЗЯ проверять запросом к настоящему `app` через TestClient (conftest ставит
# settings.testing=True глобально, guard просто не отработает, тест «прошёл бы» ни о
# чём). Он и проверяется иначе: tests/test_auth_session_guard.py зовёт ЭТУ САМУЮ
# функцию напрямую, сняв settings.testing через monkeypatch, — то есть прод-код, а
# не копию. Копия guard'а в tests/test_rbac.py про куку намеренно НЕ знает и
# покрывает только режим legacy (там об этом написано). Сдвигать session-ветку ВЫШЕ
# bypass'а нельзя: получился бы полуработающий guard (личность резолвится, а 401/403
# не применяются) — состояние, которого нет ни в одном настоящем режиме.
if settings.testing: if settings.testing:
return await call_next(request) return await call_next(request)
@ -141,8 +371,42 @@ async def rbac_guard(
if path in _PUBLIC_PATHS: if path in _PUBLIC_PATHS:
return await call_next(request) return await call_next(request)
username = request.headers.get("X-Authenticated-User") # Внешний `if` по режиму — не дубль проверки внутри resolve_session_token(), а
if not username: # гарантия инварианта «legacy = поведение не меняется ни на байт»: в нём не
# трогается даже request.cookies (разбор Cookie-заголовка).
token = (
request.cookies.get(settings.session_cookie_name) if settings.auth_session_enabled else None
)
# 🔴 Резолв — В THREADPOOL. Внутри синхронный psycopg-I/O, а мы в корутине: прямой
# вызов блокировал бы event loop на каждом запросе с кукой (инцидент #1202, тот же
# класс, что чинили в app/core/audit_middleware.py:175-183). `if token` перед
# хопом — не микрооптимизация: без куки резолвить нечего, и весь сегодняшний
# трафик не платит ни за поток, ни за коннект.
session_username = (
await run_in_threadpool(_resolve_session_username, token, path) if token else None
)
if session_username is not None:
username = session_username
# 🔴 До call_next и до всего остального: личность из сессии обязана вытеснить
# клиентский заголовок для одиннадцати прямых читателей (см. функцию).
_propagate_authenticated_user(request, username)
elif settings.auth_mode == "db_only":
# Легаси-ветка ОТКЛЮЧЕНА: нет валидной сессии → отказ, даже если
# X-Authenticated-User присутствует. Это конечное состояние эпика — режим
# включается тем же PR, который снимает `basic_auth` + `header_up` из Caddy и
# тем самым делает заголовок полностью клиентским. Отдельный текст ответа:
# «no authenticated user» ниже говорит про basic_auth, которого в этот момент
# уже нет.
return JSONResponse(
status_code=401,
content={"detail": "valid session required"},
)
else:
# ---- легаси trusted-header путь — БИТ-В-БИТ как до эпика ----
header_user = request.headers.get("X-Authenticated-User")
if not header_user:
# Любой non-public path без auth-header → 401. Локальный curl мимо Caddy # Любой non-public path без auth-header → 401. Локальный curl мимо Caddy
# или прокси-фронт без header_up. 401 точнее чем 403 — "сначала # или прокси-фронт без header_up. 401 точнее чем 403 — "сначала
# аутентифицируйся". # аутентифицируйся".
@ -150,12 +414,23 @@ async def rbac_guard(
status_code=401, status_code=401,
content={"detail": "no authenticated user (Caddy basic_auth required)"}, content={"detail": "no authenticated user (Caddy basic_auth required)"},
) )
username = header_user
try: try:
role = get_role(username) role = get_role(username)
except KeyError: except KeyError:
# Юзер в Caddy basic_auth, но не в roles.yaml → 403 на ВСЁ. # Юзер в Caddy basic_auth, но не в roles.yaml → 403 на ВСЁ.
# Decided 2026-05-25: «человек без ролей вообще ничего не видит». # Decided 2026-05-25: «человек без ролей вообще ничего не видит».
if session_username is not None:
# Тот же отказ, но отдельным сообщением: «есть в реестре, нет в roles.yaml» —
# это рассинхрон двух списков (типовой при заведении нового аккаунта), а не
# подделка заголовка, и чинится он в другом месте.
logger.warning(
"RBAC: сессия резолвлена в %r, но юзера нет в auth/roles.yaml — отказ на %s",
username,
path,
)
else:
logger.warning("RBAC: unknown user %r tried %s", username, path) logger.warning("RBAC: unknown user %r tried %s", username, path)
return JSONResponse( return JSONResponse(
status_code=403, status_code=403,

View file

@ -0,0 +1,256 @@
"""Резолв сессионной куки общего реестра (БД `auth`) — сторона «Птицы».
Эпик «единый вход»: вместо браузерного popup'а Caddy basic_auth у продукта одна
нейтральная форма входа. Живёт она у «Меры» (`/trade-in/login`): та проверяет
пароль, пишет строку в `auth.sessions` и ставит куку host-only на gendsgn.ru с
`path="/"` поэтому браузер шлёт её и на `/site-finder/**` тоже.
«Птица» эту куку ТОЛЬКО ЧИТАЕТ. Здесь нет и не должно появиться `create_session` /
`revoke_session`: выдача и отзыв исключительная ответственность единственной
формы входа, второй эмитент сессий означал бы два места, где решается «кого
пускать», и расходящиеся правила блокировки.
Что модуль отдаёт вызывающему: `resolve_session_token(token)` `SessionUser`
(username + состояние доступа) либо None. Что делать с username дальше дело
guard'а: авторизация «Птицы» (какие пути кому видны) по-прежнему живёт в
`auth/roles.yaml` (`app.core.auth.get_role`), продуктовые роли реестра
(`auth.users.role` admin/manager/employee, миграция data/sql/auth/004) сюда
намеренно НЕ протаскиваются: это другая ролевая модель, и её отображение на
roles.yaml отдельное решение стадии 2, а не побочный эффект резолва сессии.
Токены опаковые (`secrets.token_urlsafe` на стороне «Меры») не JWT, не подписаны:
валидность проверяется исключительно наличием строки в БД + `expires_at` +
состоянием доступа юзера. Никакого разделяемого секрета между стеками для этого
не нужно только доступ к одной БД.
Имена таблиц (`users`, `sessions`) и колонок литералы из data/sql/auth/001 и 004;
снаружи в SQL-строку не попадает ничего, значения идут bind-параметрами.
Зеркало по подходу: tradein-mvp/backend/app/services/auth_session.py («Мера»). Там
модуль дополнительно умеет две схемы (переходный `identity_store`) и выдачу сессий
здесь этого нет за ненадобностью.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core import auth_db
from app.core.config import settings
logger = logging.getLogger(__name__)
# Sliding-window refresh: last_seen_at/expires_at продлеваются НЕ чаще раза в 5
# минут — иначе каждый API-запрос авторизованного юзера бил бы в БД лишним UPDATE
# (guard резолвит сессию на КАЖДЫЙ non-public запрос). Значение и механика — те же,
# что у «Меры» (tradein-mvp/.../auth_session.py:51): сессия общая, и продлевать её
# два продукта обязаны одинаково.
_SLIDING_REFRESH_INTERVAL = timedelta(minutes=5)
class AccessState(StrEnum):
"""Состояние доступа аккаунта — значения дословно из `auth.users.access_state`.
CHECK-констрейнт `users_access_state_ck`, миграция data/sql/auth/004; семантика
оттуда же (решение владельца от 2026-07-31):
active доступ есть;
trial_expired пароль верный, но пробный период истёк;
disabled доступ закрыт владельцем.
Для «Птицы» все три состояния делятся надвое (`can_sign_in`): отдельный экран
«пробный доступ закончился» сюжет формы входа, то есть «Меры»; сюда приходит
уже вошедший человек, и всё, что не `active`, для него значит одно сессии нет.
"""
ACTIVE = "active"
TRIAL_EXPIRED = "trial_expired"
DISABLED = "disabled"
@property
def can_sign_in(self) -> bool:
"""True только для `active` — единственная проверка «пускать ли».
Вынесена в свойство, чтобы вызывающий не писал `state == "active"`: добавится
четвёртое состояние оно по умолчанию окажется «не пускать», а не «пускать,
потому что не disabled».
"""
return self is AccessState.ACTIVE
def to_access_state(value: object) -> AccessState:
"""Приводит значение колонки `users.access_state` к `AccessState`.
Fail-closed: неизвестная строка, NULL и любой неожиданный тип `disabled` +
WARNING. Обратный выбор (пускать всё, что не `disabled`) означал бы, что новое
состояние, добавленное миграцией раньше кода, молча раздаёт доступ а миграции
БД `auth` применяются деплоем «Птицы» (.forgejo/workflows/deploy.yml), то есть
опередить код они могут запросто.
"""
if isinstance(value, str):
try:
return AccessState(value)
except ValueError:
logger.warning(
"auth_session: неизвестное состояние доступа %r → трактую как disabled", value
)
return AccessState.DISABLED
logger.warning(
"auth_session: состояние доступа %r неожиданного типа %s → трактую как disabled",
value,
type(value).__name__,
)
return AccessState.DISABLED
@dataclass(frozen=True, slots=True)
class SessionUser:
"""Кто стоит за валидной сессионной кукой.
Attributes:
username: логин из реестра. Именно он, а не значение куки, дальше едет в
RBAC «Птицы» (`app.core.auth.get_role`).
access_state: всегда `AccessState.ACTIVE` не-active сюда не доходит
(см. `get_session_user`). Поле оставлено явным, чтобы состояние доступа
во всём коде называлось и выражалось одинаково, а не превращалось в
неявное «раз объект вернулся, значит active».
"""
username: str
access_state: AccessState
def get_session_user(db: Session, token: str) -> SessionUser | None:
"""Резолвит сессионный токен в пользователя, или None если сессия невалидна.
Невалидна = не найдена / истекла / состояние доступа юзера не `active`.
Состояние доступа: пропускается ТОЛЬКО `AccessState.ACTIVE`. Любое другое
(`disabled`, `trial_expired`, а также нераспознанное `to_access_state`
fail-closed'ит его в `disabled`) делает уже выданную сессию недействительной
НЕМЕДЛЕННО, не дожидаясь `expires_at`. Иначе заблокированный человек продолжал
бы работать до истечения TTL (до 30 дней), а sliding-refresh продлевал бы ему
сессию бесконечно то есть блокировка в реестре не блокировала бы ничего.
Sliding refresh: если с последнего `last_seen_at` прошло >= 5 минут продлевает
`last_seen_at`/`expires_at` ОДНИМ UPDATE (ровно как «Мера»: тот же интервал, тот
же одиночный UPDATE обеих колонок, тот же best-effort). Продлевать обе колонки
обязательно: обновляй «Птица» только `last_seen_at`, человек, работающий весь
день в ней одной, был бы разлогинен по `expires_at` несмотря на активность.
Сбой refresh (напр. read-only реплика) логируется и НЕ мешает вернуть валидного
юзера это best-effort продление, а не часть решения «валидна ли сессия».
Принимает уже открытую сессию БД `auth` (не открывает сам) так модуль остаётся
тривиально unit-тестируемым. Обычный вызывающий берёт `resolve_session_token`.
`db` ОБЯЗАНА быть сессией БД `auth` (`app.core.auth_db.auth_session()`), а не
`app.core.db.get_db`: в продуктовой БД gendesign таблиц `users`/`sessions` нет.
Исключения БД наружу НЕ глушатся (кроме best-effort refresh): сбой реестра
часть auth-решения, и вызывающий обязан его увидеть, чтобы закрыться, а не
трактовать как «сессии нет».
"""
if not token:
return None
row = db.execute(
text(
"""
SELECT s.expires_at, s.last_seen_at, u.username, u.access_state
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token = :token
AND s.expires_at > now()
"""
),
{"token": token},
).fetchone()
if row is None:
return None
now = datetime.now(UTC)
# Второй пояс к `AND s.expires_at > now()` в SELECT'е выше. Первый пояс — часами
# БД, и это принципиально: строку продлевает UPDATE ниже, где `expires_at =
# now() + interval` считает СЕРВЕР. Реши мы срок годности только часами процесса
# (`datetime.now(UTC)`), отставание этих часов давало бы не «сессия проживёт на
# дельту дольше», а НЕОБРАТИМОЕ воскрешение: строку, которую БД уже считает
# мёртвой, Python пропустил бы, тут же сработал бы sliding-refresh и отодвинул
# expires_at на полный TTL от серверного now(). Секунда расхождения → +30 дней.
# Обе стороны сравнения обязаны брать время из одного источника.
#
# Проверку на None оставляем первой: `expires_at` объявлен NOT NULL
# (data/sql/auth/001), но если колонку когда-нибудь ослабят, это дешевле
# разбирательства, почему сравнение с None упало TypeError'ом в auth-пути.
if row.expires_at is None or row.expires_at <= now:
return None
access_state = to_access_state(row.access_state)
if not access_state.can_sign_in:
return None
if row.last_seen_at is None or (now - row.last_seen_at) >= _SLIDING_REFRESH_INTERVAL:
try:
db.execute(
text(
"""
UPDATE sessions
SET last_seen_at = now(),
expires_at = now() + make_interval(hours => CAST(:ttl_hours AS integer))
WHERE token = :token
"""
),
{"ttl_hours": settings.session_ttl_hours, "token": token},
)
db.commit()
except Exception:
# Без username в сообщении: строка лога — не место для связки
# «кто именно» + «в какой момент», а разбор всё равно идёт по времени.
logger.warning("auth_session: sliding refresh failed", exc_info=True)
try:
db.rollback()
except Exception:
# Причина сбоя UPDATE'а может быть оборванным соединением — тогда и
# rollback бросит. Без этого except «best-effort продление» переставало
# бы быть best-effort: валидный юзер, чью сессию не удалось продлить,
# получал бы не доступ, а исключение наружу (и в guard'е — деградацию
# на легаси-заголовок, а в db_only — отказ).
logger.warning("auth_session: rollback after failed refresh failed", exc_info=True)
return SessionUser(username=row.username, access_state=access_state)
def resolve_session_token(token: str | None) -> SessionUser | None:
"""Резолвит токен сессионной куки, сам открывая соединение с БД `auth`.
Точка входа для `rbac_guard` (`app/main.py`), который зовёт её в threadpool
внутри синхронный psycopg-I/O, а guard живёт на event loop'е. Возвращает None,
если сессии нет или она недействительна.
Режим `legacy` (`AUTH_MODE=legacy`, ДЕФОЛТ) None СРАЗУ, без единого
обращения к БД: инвариант «выключенный флаг = ни одного коннекта к реестру»
держится этим модулем, а не соглашением с вызывающим. Тихий None здесь безопасен,
потому что направлен в сторону fail-closed он означает ровно «session-auth не
используется», то есть сегодняшнее поведение (Caddy basic_auth + trusted-header),
и никому ничего не открывает.
Исключения НЕ глушатся ни `AuthDatabaseNotConfiguredError` (флаг включён, DSN
пуст/битый), ни ошибки соединения. Решение «что делать со сломанным реестром»
принимает guard, и оно неочевидно: молча откатиться на trusted-header значит
раздавать права из roles.yaml в обход реестра, включая заблокированные аккаунты.
Прятать такое внутри резолвера нельзя.
Raises:
AuthDatabaseNotConfiguredError: флаг включён, а DSN БД `auth` пуст или не
разобрался (см. `app.core.auth_db`).
"""
if not settings.auth_session_enabled:
return None
if not token:
return None
with auth_db.auth_session() as db:
return get_session_user(db, token)

View file

@ -0,0 +1,182 @@
"""Инварианты миграций БД `auth` (data/sql/auth/*.sql) + её bootstrap (ops/db-bootstrap/*.sql).
Прецедента manifest-теста для КОРНЕВОГО data/sql в этом репозитории нет (он есть только
в tradein: tradein-mvp/backend/tests/test_migrations_manifest.py по
tradein-mvp/backend/data/sql/_manifest_applied.txt). Заводить manifest на 154 legacy-файла
корневого каталога не задача этого PR, поэтому здесь проверяются инварианты, которые
можно проверить БЕЗ снимка «уже применённого»: они выполнимы на новом каталоге с первого
дня и ловят регрессии, которые иначе всплывают только на проде во время деплоя.
Тест не требует БД только чтение файлов.
"""
from __future__ import annotations
import re
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[3]
_AUTH_SQL_DIR = _REPO_ROOT / "data" / "sql" / "auth"
_BOOTSTRAP_SQL_DIR = _REPO_ROOT / "ops" / "db-bootstrap"
_DEPLOY_WORKFLOW = _REPO_ROOT / ".forgejo" / "workflows" / "deploy.yml"
_FILENAME_RE = re.compile(r"^(\d{3})_[a-z0-9_]+\.sql$")
# Признаки утёкшего пароля в git. bcrypt-хеши ($2a$/$2b$/$2y$) запрещены наравне с
# plaintext: хеш из репозитория брутфорсится офлайн и переживает ротацию пароля,
# оставаясь в истории коммитов. Конвенция репо — сид вставляет password_hash = NULL,
# значения проставляются на проде (прецедент: tradein м.193).
_SECRET_PATTERNS = (
re.compile(r"\$2[aby]\$\d{2}\$"), # bcrypt hash
re.compile(r"PASSWORD\s+'", re.IGNORECASE), # CREATE/ALTER ROLE ... PASSWORD 'literal'
)
def _auth_sql_files() -> list[Path]:
"""Файлы, к которым применимы конвенции миграций (имя NNN_*, обёртка BEGIN/COMMIT)."""
return sorted(_AUTH_SQL_DIR.glob("*.sql"))
def _secret_scanned_files() -> list[Path]:
"""Файлы, по которым гоняется поиск паролей/хешей — ШИРЕ, чем список миграций.
НЕ «унифицируй» этот список с _auth_sql_files(): разделение намеренное.
* data/sql/auth/*.sql миграции: обязаны иметь имя NNN_snake_case.sql и обёртку
BEGIN;/COMMIT; (см. test_filenames_and_unique_prefix, test_migrations_are_transactional).
* ops/db-bootstrap/*.sql bootstrap: НЕ миграции, поэтому намеренно без NNN-префикса
(порядок задан явными шагами deploy.yml, не сортировкой) и намеренно без транзакции
(CREATE DATABASE запрещён внутри транзакционного блока). Прогонять по ним проверки
имён/BEGIN-COMMIT значит сломать тест на корректных файлах.
А вот запрет на пароли применим к обоим каталогам, и именно bootstrap здесь важнее:
единственное место в репозитории с конструкцией `ALTER ROLE ... PASSWORD` это
ops/db-bootstrap/set_*_password.sql, то есть ровно тот файл, куда проще всего однажды
«временно» вписать литерал вместо чтения из env. Другого контроля на это нет:
в .pre-commit-config.yaml из секрет-сканеров только detect-private-key (bcrypt не ловит),
а репо-wide grep невозможен caddy/users.caddy.snippet легально содержит bcrypt-хеши
действующих логинов.
"""
return _auth_sql_files() + sorted(_BOOTSTRAP_SQL_DIR.glob("*.sql"))
def test_scanned_dirs_are_not_empty() -> None:
"""Sanity: пути до каталогов не разъехались (иначе все проверки ниже — пустые).
Red => каталог переименован/перенесён, а тест этого не заметил бы: `glob` по
несуществующему пути возвращает [], и все циклы ниже стали бы no-op'ами, оставаясь
зелёными. Особенно опасно для проверки паролей «зелено, потому что ничего не проверено».
"""
assert _auth_sql_files(), f"Не найдено *.sql в {_AUTH_SQL_DIR}"
assert sorted(_BOOTSTRAP_SQL_DIR.glob("*.sql")), f"Не найдено *.sql в {_BOOTSTRAP_SQL_DIR}"
def test_filenames_and_unique_prefix() -> None:
"""Имя вида NNN_snake_case.sql, префикс NNN уникален.
Red => прод применяет файлы в порядке `ls | sort`; два файла с одним NNN дают
неоднозначный порядок (например, роль/гранты раньше таблиц). Присвой следующий
свободный номер.
"""
seen: dict[str, str] = {}
bad_names: list[str] = []
collisions: list[str] = []
for path in _auth_sql_files():
m = _FILENAME_RE.match(path.name)
if m is None:
bad_names.append(path.name)
continue
prefix = m.group(1)
if prefix in seen:
collisions.append(f"{path.name} (префикс {prefix} уже у {seen[prefix]})")
else:
seen[prefix] = path.name
assert not bad_names, (
f"Имена не соответствуют NNN_snake_case.sql: {bad_names}. "
"Порядок применения на проде определяется сортировкой имён."
)
assert not collisions, "Дублирующийся NNN-префикс: " + "; ".join(collisions)
def test_migrations_are_transactional() -> None:
"""Каждая миграция обёрнута в BEGIN; ... COMMIT; (.claude/rules/sql.md).
Red => частично применённая миграция оставит БД auth в промежуточном состоянии:
деплой падает на ON_ERROR_STOP, а уже выполненный DDL не откатывается.
"""
broken: list[str] = []
for path in _auth_sql_files():
text = path.read_text(encoding="utf-8")
statements = [
line.strip()
for line in text.splitlines()
if line.strip() and not line.strip().startswith("--")
]
if not statements or statements[0] != "BEGIN;" or statements[-1] != "COMMIT;":
broken.append(path.name)
assert (
not broken
), f"Миграции без обёртки BEGIN;/COMMIT;: {broken} (.claude/rules/sql.md → Structure)."
def test_no_concurrent_index_in_migrations() -> None:
"""Ни одной CREATE/DROP INDEX CONCURRENTLY в data/sql/auth/*.sql.
Red => миграция гарантированно падает на проде: CONCURRENTLY нельзя выполнять внутри
транзакционного блока (Postgres: 25001 «CREATE INDEX CONCURRENTLY cannot run inside a
transaction block»), а обёртка BEGIN;/COMMIT; здесь обязательна для всех файлов
(test_migrations_are_transactional). Две проверки по отдельности зелёные, а вместе
невыполнимые поэтому запрет нужен явный: комбинация ловится только здесь.
Нужен CONCURRENTLY на большой таблице это отдельный ручной прогон вне auto-apply,
а не файл в этом каталоге.
"""
hits: list[str] = []
for path in _auth_sql_files():
text = path.read_text(encoding="utf-8")
for line_no, line in enumerate(text.splitlines(), start=1):
if line.lstrip().startswith("--"):
continue # комментарий может объяснять запрет, не нарушая его
if re.search(r"\bCONCURRENTLY\b", line, re.IGNORECASE):
hits.append(f"{path.name}:{line_no}: {line.strip()}")
assert not hits, "CONCURRENTLY внутри BEGIN/COMMIT — упадёт на деплое: " + "; ".join(hits)
def test_no_password_material_in_auth_sql() -> None:
"""Ни в data/sql/auth, ни в ops/db-bootstrap нет plaintext-паролей и bcrypt-хешей.
Покрытие шире каталога миграций сознательно обоснование в _secret_scanned_files().
Red => пароль/хеш попал в git. Убери значение: сид вставляет password_hash = NULL,
пароль роли ставится из env через ops/db-bootstrap/set_auth_app_password.sql
(значение приезжает из .env.runtime на VPS и в репозитории не существует).
"""
hits: list[str] = []
for path in _secret_scanned_files():
rel = path.relative_to(_REPO_ROOT).as_posix()
text = path.read_text(encoding="utf-8")
for line_no, line in enumerate(text.splitlines(), start=1):
if line.lstrip().startswith("--"):
continue # комментарии описывают запрет, а не нарушают его
for pattern in _SECRET_PATTERNS:
if pattern.search(line):
hits.append(f"{rel}:{line_no}: {line.strip()}")
assert not hits, "Похоже на пароль/хеш в SQL: " + "; ".join(hits)
def test_deploy_workflow_applies_auth_migrations() -> None:
"""deploy.yml реально прогоняет data/sql/auth/*.sql.
Каталог обособлен намеренно: основной цикл миграций использует `ls -1 data/sql/*.sql`
и в подкаталоги НЕ рекурсирует (чтобы файлы auth физически не могли примениться в БД
gendesign). Обратная сторона без отдельного цикла в deploy.yml эти файлы не
применяются вообще и никто этого не заметит. Red => wiring удалён или переименован.
"""
workflow = _DEPLOY_WORKFLOW.read_text(encoding="utf-8")
assert "data/sql/auth/*.sql" in workflow, (
f"В {_DEPLOY_WORKFLOW.name} нет цикла по data/sql/auth/*.sql — миграции БД auth "
"не применяются на деплое."
)
assert (
"ops/db-bootstrap/create_auth_db.sql" in workflow
), f"В {_DEPLOY_WORKFLOW.name} нет bootstrap-шага создания БД auth."

View file

@ -0,0 +1,497 @@
"""DSN и ленивый engine БД `auth` — `app/core/config.py` + `app/core/auth_db.py`.
Эпик «единый вход», стадия 3. Три группы:
1. Дефолты. Они ЧАСТЬ КОНТРАКТА PR, а не декорация: пока Caddy basic_auth стоит,
прод обязан вести себя ровно как до эпика флаг выключен, DSN не сконфигурирован,
engine не создаётся, отсутствие AUTH_* в окружении не роняет старт.
2. Сборка DSN из частей: приоритет явного URL, экранирование секрета, пустые
значения переменных прод-дефолты (а не мусорный DSN и не падение на импорте).
3. `auth_db`: ленивость, кеш, внятная ошибка вместо утечки пароля.
Сеть здесь не нужна: `create_engine` пул создаёт лениво и к серверу не ходит.
"""
from __future__ import annotations
import os
import subprocess
import sys
from collections.abc import Iterator
from pathlib import Path
import pytest
from pydantic import SecretStr, ValidationError
from sqlalchemy.engine import make_url
from app.core import auth_db
from app.core.auth_db import AuthDatabaseNotConfiguredError, AuthDatabaseUnreachableError
from app.core.config import Settings, settings
_AUTH_ENV_VARS = (
"AUTH_MODE",
"AUTH_DATABASE_URL",
"AUTH_DB_PASSWORD",
"AUTH_DB_HOST",
"AUTH_DB_PORT",
"AUTH_DB_NAME",
"AUTH_DB_USER",
"SESSION_COOKIE_NAME",
"SESSION_TTL_HOURS",
)
# Заведомо синтаксически корректный DSN на несуществующий хост: engine по нему
# создаётся, но соединение не открывается (пул ленивый), поэтому тесты офлайновы.
_OFFLINE_DSN = "postgresql+psycopg://auth_app:pw@127.0.0.1:1/auth"
@pytest.fixture
def clean_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ни одной AUTH_*/SESSION_* переменной — тест дефолтов не зависит от машины."""
for name in _AUTH_ENV_VARS:
monkeypatch.delenv(name, raising=False)
@pytest.fixture(autouse=True)
def _reset_engine_cache() -> Iterator[None]:
"""Ни один тест не оставляет за собой закешированный engine БД `auth`."""
auth_db.reset_auth_db()
yield
auth_db.reset_auth_db()
# ---------------------------------------------------------------------------
# 1. Дефолты
# ---------------------------------------------------------------------------
def test_session_mode_is_off_and_unconfigured_by_default(clean_env: None) -> None:
"""Свежие настройки без AUTH_*: режим legacy, DSN пуст — и это НЕ ошибка."""
fresh = Settings()
assert fresh.auth_mode == "legacy"
assert fresh.auth_session_enabled is False
assert fresh.resolved_auth_database_url == ""
def test_live_settings_singleton_is_off() -> None:
"""Тот же инвариант на настоящем синглтоне, которым пользуется приложение."""
assert settings.auth_mode == "legacy"
assert settings.auth_session_enabled is False
@pytest.mark.parametrize("raw", ["", " ", "LEGACY", " legacy "])
def test_blank_or_odd_case_auth_mode_falls_back_to_legacy(
clean_env: None, monkeypatch: pytest.MonkeyPatch, raw: str
) -> None:
"""`AUTH_MODE=` (или регистр/пробелы) → legacy, а не ValidationError на импорте.
`settings = Settings()` выполняется на уровне модуля: невалидное значение уронило бы
ИМПОРТ конфига и увело контейнер в restart-loop. Сценарий бытовой ops копирует
блок AUTH_* в .env.runtime и заполняет только пароль.
"""
monkeypatch.setenv("AUTH_MODE", raw)
assert Settings().auth_mode == "legacy"
def test_meaningful_garbage_in_auth_mode_still_fails(
clean_env: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`AUTH_MODE=off` — опечатка со смыслом, и она обязана падать.
Молча трактовать её как legacy значило бы тихо оставить продукт на trusted-header
после того, как последний PR эпика снимет popup.
"""
monkeypatch.setenv("AUTH_MODE", "off")
with pytest.raises(ValidationError):
Settings()
@pytest.mark.parametrize(
("mode", "enabled"), [("legacy", False), ("dual", True), ("db_only", True)]
)
def test_auth_session_enabled_is_derived_from_mode(
clean_env: None, monkeypatch: pytest.MonkeyPatch, mode: str, enabled: bool
) -> None:
"""Свойство `auth_session_enabled` = «режим не legacy» — единый выключатель реестра.
Оно и закорачивает `resolve_session_token` / `require_auth_db_configured`; разница
dual vs db_only ему не видна и не должна быть (она про фолбэк в rbac_guard).
"""
monkeypatch.setenv("AUTH_MODE", mode)
assert Settings().auth_session_enabled is enabled
def test_default_host_is_this_stacks_postgres(clean_env: None) -> None:
"""🪤 Дефолт хоста — `postgres`, и это ЗЕРКАЛЬНО «Мере», а не копия с неё.
У «Меры» дефолт `gendesign-postgres`, потому что внутри её стека имя `postgres`
занято её собственным контейнером. У «Птицы» наоборот: её стек главный, сервис
`postgres` корневого docker-compose.prod.yml и есть сервер с БД `auth`. Алиас
`gendesign-postgres` живёт только во внешней сети `shared`, куда входят не все
сервисы (beat нет), поэтому дефолтом он быть не может.
"""
fresh = Settings()
assert fresh.auth_db_host == "postgres"
assert fresh.auth_db_host != "gendesign-postgres"
assert fresh.auth_db_port == 5432
assert fresh.auth_db_name == "auth"
assert fresh.auth_db_user == "auth_app"
def test_cookie_defaults_match_the_other_product(clean_env: None) -> None:
"""Имя куки и TTL обязаны совпадать с «Мерой» — иначе общая сессия не общая.
Имя историческое («tradein_» уже ни о чём не говорит); переименование
разлогинивает всех сразу в обоих продуктах, поэтому оно закреплено тестом.
"""
fresh = Settings()
assert fresh.session_cookie_name == "tradein_session"
assert fresh.session_ttl_hours == 720
# ---------------------------------------------------------------------------
# 2. Сборка DSN
# ---------------------------------------------------------------------------
def test_explicit_url_wins_over_parts(clean_env: None) -> None:
"""AUTH_DATABASE_URL — аварийный обход (другой хост, sslmode): выигрывает всегда."""
fresh = Settings(
auth_database_url=" postgresql+psycopg://u:p@elsewhere:6432/auth?sslmode=require ",
auth_db_password=SecretStr("ignored"),
auth_db_host="postgres",
)
assert (
fresh.resolved_auth_database_url
== "postgresql+psycopg://u:p@elsewhere:6432/auth?sslmode=require"
)
def test_dsn_built_from_password_and_defaults(clean_env: None) -> None:
"""Включение на проде = одна переменная: пароль + прод-дефолты остальных частей."""
fresh = Settings(auth_db_password=SecretStr("s3cret"))
assert (
fresh.resolved_auth_database_url
== "postgresql+psycopg://auth_app:s3cret@postgres:5432/auth"
)
def test_password_special_chars_survive_round_trip(clean_env: None) -> None:
"""Пароль экранируется: `@`/`/`/`:`/`#`/`%` иначе порвали бы URL по его грамматике.
Проверяем не наличие процентов в строке, а РАЗБОР обратно: важно, что SQLAlchemy
видит тот же пароль и, главное, тот же хост/базу. Незакавыченный `@` молча увёл бы
подключение на другой хост.
"""
raw = "p@ss:w/rd#1%zz?x"
url = make_url(Settings(auth_db_password=SecretStr(raw)).resolved_auth_database_url)
assert url.password == raw
assert url.host == "postgres"
assert url.port == 5432
assert url.database == "auth"
assert url.username == "auth_app"
def test_username_is_quoted_too(clean_env: None) -> None:
url = make_url(
Settings(
auth_db_password=SecretStr("pw"), auth_db_user="odd:user@name"
).resolved_auth_database_url
)
assert url.username == "odd:user@name"
assert url.host == "postgres"
def test_password_whitespace_is_preserved_not_stripped(clean_env: None) -> None:
"""Ведущий/хвостовой пробел может быть частью настоящего пароля — не режем."""
url = make_url(Settings(auth_db_password=SecretStr(" pw ")).resolved_auth_database_url)
assert url.password == " pw "
def test_blank_password_means_not_configured(clean_env: None) -> None:
"""Пробельная строка — опечатка в .env, а не пароль: «не сконфигурировано»."""
assert Settings(auth_db_password=SecretStr(" ")).resolved_auth_database_url == ""
assert Settings(auth_db_password=SecretStr("")).resolved_auth_database_url == ""
def test_blank_parts_fall_back_to_defaults(clean_env: None) -> None:
"""`AUTH_DB_HOST=` в .env.runtime не должен давать DSN вида `...@:5432/auth`.
Сценарий бытовой: ops копирует блок AUTH_DB_* целиком и заполняет только пароль.
"""
url = make_url(
Settings(
auth_db_password=SecretStr("pw"),
auth_db_host=" ",
auth_db_name="",
auth_db_user=" ",
).resolved_auth_database_url
)
assert (url.host, url.database, url.username) == ("postgres", "auth", "auth_app")
@pytest.mark.parametrize("blank", ["", " "])
def test_blank_port_does_not_break_import(clean_env: None, blank: str) -> None:
"""`AUTH_DB_PORT=` → дефолт, а НЕ ValidationError.
`settings = Settings()` выполняется на уровне модуля: падение здесь уводило бы
контейнер в restart-loop причём в дефолтном режиме, где к БД `auth` не идёт ни
одного обращения.
"""
assert Settings(auth_db_port=blank).auth_db_port == 5432
def test_non_blank_garbage_port_still_fails(clean_env: None) -> None:
"""`AUTH_DB_PORT=abc` — опечатка со смыслом, её глушить нельзя."""
with pytest.raises(ValueError):
Settings(auth_db_port="abc")
def test_password_is_not_printed_by_repr_or_dump(clean_env: None) -> None:
"""SecretStr: пароль не утекает в `repr(settings)` / `model_dump()`.
Сегодня их никто не рендерит, но появиться такой рендер (лог старта, /debug) может
тихо а рядом с обычным str-полем это была бы утечка секрета в открытый лог.
"""
fresh = Settings(auth_db_password=SecretStr("s3cret"))
assert "s3cret" not in repr(fresh)
assert "s3cret" not in str(fresh.model_dump())
assert fresh.auth_db_password.get_secret_value() == "s3cret"
# ---------------------------------------------------------------------------
# 3. auth_db: ленивость, кеш, ошибки
# ---------------------------------------------------------------------------
def test_import_without_any_auth_env_does_not_build_engine() -> None:
"""Импорт в чистом окружении: ошибки нет, engine не создан, DSN пуст.
Проверяется отдельным процессом, потому что в текущем модуль импортирован давно и
любое утверждение про «на импорте» было бы про уже случившийся импорт. Это отличие
от `app.core.db`, где engine создаётся в теле модуля: сделай мы так же, приложение
падало бы на старте везде, где реестр не сконфигурирован локально, в pytest, на
любом стенде. Ровно тот контракт, который держит дефолтное поведение прода.
"""
env = {k: v for k, v in os.environ.items() if k not in _AUTH_ENV_VARS}
code = (
"from app.core import auth_db\n"
"from app.core.config import settings\n"
"print(auth_db._engine, repr(settings.resolved_auth_database_url), "
"settings.auth_session_enabled)\n"
)
proc = subprocess.run(
[sys.executable, "-c", code],
cwd=Path(__file__).resolve().parents[1],
env=env,
capture_output=True,
text=True,
)
assert proc.returncode == 0, proc.stderr
assert proc.stdout.strip() == "None '' False"
def test_unconfigured_registry_raises_with_actionable_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Пустой DSN → явная ошибка с инструкцией, а не «сессия не найдена»."""
monkeypatch.setattr(settings, "auth_database_url", "")
monkeypatch.setattr(settings, "auth_db_password", SecretStr(""))
with pytest.raises(AuthDatabaseNotConfiguredError) as excinfo:
auth_db.get_auth_engine()
assert "AUTH_MODE" in str(excinfo.value)
assert "AUTH_DB_PASSWORD" in str(excinfo.value)
@pytest.mark.parametrize(
"broken",
[
"not-a-dsn-at-all",
# «Почти URL»: разбор доходит до int(port) и падает, унося в текст ошибки
# кусок пароля, съехавший на позицию порта.
"postgresql+psycopg://u:pa@ss@host:wo/auth",
],
)
def test_malformed_dsn_does_not_leak_into_the_error(
monkeypatch: pytest.MonkeyPatch, broken: str
) -> None:
"""Битый DSN → своя ошибка БЕЗ самого DSN и без исходного traceback.
Текст ошибки SQLAlchemy цитирует строку целиком, а в ней пароль роли auth_app.
`from None` обязателен: без него исходная ошибка печаталась бы в traceback как
«During handling of the above exception...» то есть пароль всё равно оказался бы
в логе.
"""
monkeypatch.setattr(settings, "auth_database_url", broken)
with pytest.raises(AuthDatabaseNotConfiguredError) as excinfo:
auth_db.get_auth_engine()
assert broken not in str(excinfo.value)
assert "pa@ss" not in str(excinfo.value)
assert excinfo.value.__suppress_context__ is True
assert excinfo.value.__cause__ is None
def test_engine_is_built_once_and_reused(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN)
first = auth_db.get_auth_engine()
second = auth_db.get_auth_engine()
assert first is second
assert auth_db.get_auth_session_factory().kw["bind"] is first
assert first.url.database == "auth"
def test_reset_drops_the_cached_engine(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN)
first = auth_db.get_auth_engine()
auth_db.reset_auth_db()
assert auth_db._engine is None
assert auth_db.get_auth_engine() is not first
# ---------------------------------------------------------------------------
# require_auth_db_configured — fail-fast на старте (lifespan)
# ---------------------------------------------------------------------------
def test_startup_check_is_noop_while_flag_is_off(monkeypatch: pytest.MonkeyPatch) -> None:
"""🔒 Дефолт: пустой DSN на старте — не ошибка, и engine не создаётся.
Ровно то, что произойдёт на проде сразу после мержа этого PR.
"""
monkeypatch.setattr(settings, "auth_mode", "legacy")
monkeypatch.setattr(settings, "auth_database_url", "")
monkeypatch.setattr(settings, "auth_db_password", SecretStr(""))
auth_db.require_auth_db_configured()
assert auth_db._engine is None
def test_startup_check_fails_fast_when_enabled_without_dsn(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Флаг включили, DSN не задали → контейнер не стартует.
Иначе пустой DSN ловил бы `except` в guard'е, и сломанная конфигурация выглядела бы
как «ни у кого нет сессии» сутками, при живом приложении и 200-х в ответах.
"""
monkeypatch.setattr(settings, "auth_mode", "dual")
monkeypatch.setattr(settings, "auth_database_url", "")
monkeypatch.setattr(settings, "auth_db_password", SecretStr(""))
with pytest.raises(AuthDatabaseNotConfiguredError):
auth_db.require_auth_db_configured()
def test_startup_check_builds_engine_and_probes_connection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Режим включён и реестр отвечает → engine готов ещё до первого запроса.
Проба соединения подменена: поднимать Postgres ради этого теста незачем, важно, что
она вызывается ИМЕННО на том engine, который останется закешированным.
"""
monkeypatch.setattr(settings, "auth_mode", "dual")
monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN)
probed: list[object] = []
monkeypatch.setattr(auth_db, "_probe_connection", probed.append)
auth_db.require_auth_db_configured()
assert auth_db._engine is not None
assert probed == [auth_db._engine]
def test_startup_check_fails_when_dsn_parses_but_connection_does_not(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""🔴 Смысл проверки: DSN разобрался — это ещё НЕ «реестр рабочий».
`create_engine` к серверу не ходит, поэтому одна лишь сборка engine отлавливала бы
ровно два случая (DSN пуст / не парсится). Весь вероятный класс ошибок неверный
AUTH_DB_PASSWORD, опечатка в хосте, не созданная БД `auth`, отозванная роль
auth_app, нет сети проходил бы мимо, контейнер стартовал бы зелёным, `/health`
отвечал бы 200, а каждый запрос с кукой молча деградировал бы на легаси-заголовок.
Сутками. Ровно то, что комментарий в app/main.py обещает НЕ допускать.
"""
monkeypatch.setattr(settings, "auth_mode", "dual")
monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN)
def _refused(_engine: object) -> None:
raise OSError("connection to server at 127.0.0.1, port 1 failed: Connection refused")
monkeypatch.setattr(auth_db, "_probe_connection", _refused)
with pytest.raises(AuthDatabaseUnreachableError) as excinfo:
auth_db.require_auth_db_configured()
# Причина сохранена в цепочке — ради неё проверка и делается; DSN (в нём пароль) в
# наш текст не подставляется.
assert isinstance(excinfo.value.__cause__, OSError)
assert "AUTH_MODE" in str(excinfo.value)
assert _OFFLINE_DSN not in str(excinfo.value)
def test_startup_check_does_not_probe_while_flag_is_off(monkeypatch: pytest.MonkeyPatch) -> None:
"""🔒 legacy: ни коннекта, ни пробы — даже если DSN задан и валиден."""
monkeypatch.setattr(settings, "auth_mode", "legacy")
monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN)
def _boom(_engine: object) -> None:
raise AssertionError("в режиме legacy соединение с реестром недопустимо")
monkeypatch.setattr(auth_db, "_probe_connection", _boom)
auth_db.require_auth_db_configured()
assert auth_db._engine is None
def test_engine_has_short_timeouts(monkeypatch: pytest.MonkeyPatch) -> None:
"""Реестр — не критический путь: его сбой обязан деградировать за секунды.
Без `connect_timeout` дропнутые SYN (фаервол молча глотает пакеты) держали бы
попытку до TCP-таймаута ОС на Linux ~130 с, и так на КАЖДОМ checkout'е, потому
что включён `pool_pre_ping`. `pool_timeout` по дефолту 30 с в auth-пути столько
ждать свободный коннект незачем.
"""
monkeypatch.setattr(settings, "auth_database_url", _OFFLINE_DSN)
captured: dict[str, object] = {}
real_create_engine = auth_db.create_engine
def _spy(dsn: str, **kwargs: object) -> object:
captured.update(kwargs)
return real_create_engine(dsn, **kwargs) # type: ignore[arg-type]
monkeypatch.setattr(auth_db, "create_engine", _spy)
auth_db.get_auth_engine()
assert captured["pool_timeout"] == 3
assert captured["pool_pre_ping"] is True
connect_args = captured["connect_args"]
assert isinstance(connect_args, dict)
assert connect_args["connect_timeout"] == 3
assert "statement_timeout=3000" in connect_args["options"]

View file

@ -0,0 +1,861 @@
"""Dual-mode `rbac_guard` «Птицы» — эпик «единый вход», стадия 3 (тесты).
Что здесь проверяется и почему именно так.
ТЕСТИРУЕТСЯ НАСТОЯЩИЙ `app.main.rbac_guard`, а не его копия. `app.middleware("http")`
у Starlette возвращает саму функцию (декоратор регистрирует dispatch и отдаёт `func`),
поэтому middleware вызывается напрямую: `await rbac_guard(request, call_next)`. Это
принципиально в отличие от `tests/test_rbac.py`, где живёт РУЧНАЯ КОПИЯ guard'а
(она заведена, чтобы не тянуть тяжёлые импорты, и ценой этого расходится с прод-кодом
при каждой правке). Главный тест этого файла про подделку заголовка, то есть про
безопасность; проверять безопасность на копии нельзя, копия не деплоится.
Почему не через `TestClient(app)`: `rbac_guard` первой строкой уходит в
test-mode bypass при `settings.testing=True`, а conftest.py ставит этот флаг
глобально (иначе весь остальной сьют получал бы 401). Прямой вызов middleware
позволяет снять именно этот флаг (monkeypatch, см. `_no_test_bypass`) и получить
прод-поведение guard'а целиком: и session-ветку, и легаси-ветку, и 401/403.
Как проверяется «downstream видит нужного юзера». `_propagate_authenticated_user`
перезаписывает заголовок в `request.scope["headers"]`; scope прокинут по ссылке через
весь ASGI-стек, и следующий слой (audit-middleware, роутер) строит поверх него СВОЙ
`Request`. Дублёр `_Downstream` делает ровно это `Request(request.scope)` то есть
видит заголовок так же, как одиннадцать мест бэкенда, читающих его напрямую мимо
guard'а (перечислены в докстринге `_propagate_authenticated_user`).
БД `auth` здесь не поднимается: подменяется `app.core.auth_db.auth_session` (сам резолв
сессии `get_session_user` прогоняется НАСТОЯЩИЙ, чтобы «истекла»/«не active»
проверялись кодом, а не заглушкой). Юнит-тесты самого резолва в
`tests/test_auth_session_service.py`, конфигурация DSN в `tests/test_auth_db.py`.
"""
from __future__ import annotations
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
import pytest
from fastapi import Request
from fastapi.responses import JSONResponse, Response
import app.main as app_main
from app.core import auth as auth_mod
from app.core import auth_db
from app.core.config import settings
from app.main import rbac_guard
# Логины из auth/roles.yaml (см. tests/test_rbac.py::test_get_role_known_users):
_ADMIN_LOGIN = "admin" # role=admin
_PILOT_LOGIN = "user1" # role=pilot
_NOT_IN_ROLES_YAML = "ghost" # роли нет вообще → 403 на всё
_VALID_TOKEN = "tok-valid"
_EXPIRED_TOKEN = "tok-expired"
_UNKNOWN_TOKEN = "tok-never-issued"
# ---------------------------------------------------------------------------
# Дублёры
# ---------------------------------------------------------------------------
@dataclass
class _Row:
"""Строка ответа SELECT'а из `app/services/auth_session.py` (4 колонки)."""
expires_at: datetime | None
last_seen_at: datetime | None
username: str
access_state: str
class _FetchOne:
def __init__(self, row: _Row | None) -> None:
self._row = row
def fetchone(self) -> _Row | None:
return self._row
# Форма запросов к реестру. Без этих проверок дублёр диспетчеризует по одному лишь
# `startswith`, и тела SQL не покрыты ВООБЩЕ: мутационный прогон показал, что
# `sessions`→`sessionz`, `users`→`userz`, `s.token`→`s.tokenX`, `last_seen_at`→
# `last_seen_atX` не роняли ни одного теста. Настоящего Postgres в сьюте нет, а цена
# опечатки/дрейфа схемы здесь высокая: не 500, а «ни у кого нет сессии» с тихим
# откатом на легаси-заголовок (после снятия popup'а — локаут всех).
_SELECT_MUST_CONTAIN = (
"FROM sessions s",
"JOIN users u ON u.id = s.user_id",
"WHERE s.token = :token",
# Срок годности отсекается часами БД — теми же, которыми UPDATE ниже пишет
# expires_at. Питоновская проверка остаётся вторым поясом.
"AND s.expires_at > now()",
"s.expires_at",
"s.last_seen_at",
"u.username",
"u.access_state",
)
_UPDATE_MUST_CONTAIN = (
"UPDATE sessions",
"last_seen_at = now()",
# Обе колонки одним UPDATE: продлевай «Птица» только last_seen_at — человек,
# работающий весь день в ней одной, был бы разлогинен по expires_at.
"expires_at = now() + make_interval(hours => CAST(:ttl_hours AS integer))",
"WHERE token = :token",
)
def _assert_select_shape(sql: str) -> None:
for fragment in _SELECT_MUST_CONTAIN:
assert fragment in sql, f"SELECT к БД auth потерял {fragment!r}: {sql}"
def _assert_update_shape(sql: str) -> None:
for fragment in _UPDATE_MUST_CONTAIN:
assert fragment in sql, f"UPDATE к БД auth потерял {fragment!r}: {sql}"
class FakeAuthDb:
"""Дублёр сессии SQLAlchemy к БД `auth`: понимает ровно два запроса модуля.
Считает обращения (`select_tokens`, `updates`) по ним тесты доказывают не только
результат, но и что запрос вообще был/не был сделан.
"""
def __init__(self, rows: dict[str, _Row] | None = None, *, fail_refresh: bool = False) -> None:
self.rows: dict[str, _Row] = dict(rows or {})
self.select_tokens: list[str] = []
self.updates: list[dict[str, Any]] = []
self.commits = 0
self.rollbacks = 0
self.closed = False
self.fail_refresh = fail_refresh
def execute(self, clause: Any, params: dict[str, Any]) -> _FetchOne:
sql = " ".join(str(clause).split())
if sql.startswith("SELECT"):
_assert_select_shape(sql)
self.select_tokens.append(params["token"])
return _FetchOne(self.rows.get(params["token"]))
if sql.startswith("UPDATE sessions"):
_assert_update_shape(sql)
if self.fail_refresh:
raise RuntimeError("sessions is read-only on this replica")
self.updates.append(dict(params))
return _FetchOne(None)
raise AssertionError(f"неожиданный SQL к БД auth: {sql}")
def commit(self) -> None:
self.commits += 1
def rollback(self) -> None:
self.rollbacks += 1
def _install_auth_db(monkeypatch: pytest.MonkeyPatch, db: FakeAuthDb | None) -> None:
"""Подменяет `auth_db.auth_session`. `db=None` → любое обращение к БД падает."""
@contextmanager
def _fake_session() -> Iterator[FakeAuthDb]:
if db is None:
raise RuntimeError("connection to auth registry refused")
yield db
monkeypatch.setattr(auth_db, "auth_session", _fake_session)
class _Downstream:
"""`call_next`: запоминает, каким юзером запрос выглядит для следующего слоя."""
def __init__(self) -> None:
self.calls = 0
self.seen_users: list[str | None] = []
self.seen_header_counts: list[int] = []
async def __call__(self, request: Request) -> Response:
# Именно так заголовок видят 11 прямых читателей: свой Request поверх того же
# scope, который guard уже успел переписать.
downstream = Request(request.scope)
self.calls += 1
self.seen_users.append(downstream.headers.get("X-Authenticated-User"))
self.seen_header_counts.append(
sum(1 for k, _ in request.scope["headers"] if k == b"x-authenticated-user")
)
return JSONResponse({"ok": True})
def _make_request(
path: str,
*,
cookie_token: str | None = None,
header_user: str | None = None,
cookie_name: str | None = None,
header_name: bytes = b"x-authenticated-user",
) -> Request:
"""ASGI-scope запроса. Имена заголовков lowercase — как их отдаёт любой сервер.
`header_name` позволяет подсунуть имя в НЕканоническом регистре: спека ASGI требует
lowercase, но полагаться на неё в фильтре `_propagate_authenticated_user` мы не
хотим (чужой ASGI-слой/харнесс может её нарушить).
"""
headers: list[tuple[bytes, bytes]] = [(b"host", b"gendsgn.ru")]
if cookie_token is not None:
name = cookie_name or settings.session_cookie_name
headers.append((b"cookie", f"{name}={cookie_token}".encode()))
if header_user is not None:
headers.append((header_name, header_user.encode("latin-1")))
return Request(
{
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.3"},
"http_version": "1.1",
"method": "GET",
"scheme": "https",
"server": ("gendsgn.ru", 443),
"client": ("203.0.113.7", 51234),
"root_path": "",
"path": path,
"raw_path": path.encode(),
"query_string": b"",
"headers": headers,
}
)
async def _run_guard(request: Request) -> tuple[Response, _Downstream]:
downstream = _Downstream()
response = await rbac_guard(request, downstream)
return response, downstream
def _valid_session(username: str, *, access_state: str = "active") -> _Row:
now = datetime.now(UTC)
return _Row(
expires_at=now + timedelta(days=7),
last_seen_at=now - timedelta(seconds=30), # свежее 5 минут → без UPDATE
username=username,
access_state=access_state,
)
# ---------------------------------------------------------------------------
# Фикстуры
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _reset_auth_cache() -> None:
"""Свежий YAML-кэш ролей на каждый тест (как в tests/test_rbac.py)."""
auth_mod.reset_cache_for_tests()
@pytest.fixture(autouse=True)
def _reset_registry_throttle() -> Iterator[None]:
"""Окно троттлинга алерта «реестр не отвечает» — модульное состояние app.main.
Без сброса первый же тест, поймавший сбой реестра, глушил бы ERROR у всех
следующих в течение минуты, и они краснели/зеленели бы в зависимости от порядка
и скорости прогона.
"""
app_main._reset_registry_failure_throttle()
yield
app_main._reset_registry_failure_throttle()
@pytest.fixture(autouse=True)
def _no_test_bypass(monkeypatch: pytest.MonkeyPatch) -> None:
"""Снимает test-mode bypass: без этого guard возвращает call_next первой строкой.
conftest.py ставит `settings.testing = True` глобально; monkeypatch вернёт его
обратно после каждого теста, так что остальной сьют не затронут.
"""
monkeypatch.setattr(settings, "testing", False)
@pytest.fixture
def resolve_spy(monkeypatch: pytest.MonkeyPatch) -> list[str | None]:
"""Считает вызовы `resolve_session_token` из app.main, не подменяя его логику.
Нужен, чтобы доказывать НЕ-обращения: «флаг выключен к реестру не ходим»,
«публичный путь к реестру не ходим».
"""
calls: list[str | None] = []
real = app_main.resolve_session_token
def _spy(token: str | None) -> Any:
calls.append(token)
return real(token)
monkeypatch.setattr(app_main, "resolve_session_token", _spy)
return calls
@pytest.fixture
def no_engine_build(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ломает создание engine БД `auth`: тест покраснеет, если его вообще строят."""
def _boom() -> tuple[Any, Any]:
raise AssertionError("engine БД `auth` не должен создаваться в этом сценарии")
monkeypatch.setattr(auth_db, "_build", _boom)
# ---------------------------------------------------------------------------
# ФЛАГ ВЫКЛЮЧЕН (дефолт) — прод обязан вести себя ровно как до эпика
# ---------------------------------------------------------------------------
async def test_flag_is_off_by_default() -> None:
"""Дефолт синглтона settings — режим legacy. Весь файл ниже опирается на это."""
assert settings.auth_mode == "legacy"
assert settings.auth_session_enabled is False
async def test_flag_off_legacy_header_still_works(
monkeypatch: pytest.MonkeyPatch, resolve_spy: list[str | None], no_engine_build: None
) -> None:
"""Сегодняшний путь (Caddy basic_auth → X-Authenticated-User) не изменился."""
monkeypatch.setattr(settings, "auth_mode", "legacy")
_install_auth_db(monkeypatch, None)
response, downstream = await _run_guard(_make_request("/api/v1/me", header_user=_ADMIN_LOGIN))
assert response.status_code == 200
assert downstream.seen_users == [_ADMIN_LOGIN]
assert resolve_spy == []
async def test_flag_off_ignores_session_cookie_and_never_touches_registry(
monkeypatch: pytest.MonkeyPatch, resolve_spy: list[str | None], no_engine_build: None
) -> None:
"""🔒 Инвариант PR: при выключенном флаге кука не читается, к БД `auth` не идём.
Валидная кука + нет легаси-заголовка 401, как сегодня у любого запроса мимо
Caddy. `resolve_spy`/`no_engine_build` доказывают, что дело не в «не нашли
сессию», а в том, что резолв вообще не запускался и engine не строился.
"""
monkeypatch.setattr(settings, "auth_mode", "legacy")
_install_auth_db(monkeypatch, FakeAuthDb({_VALID_TOKEN: _valid_session(_ADMIN_LOGIN)}))
response, downstream = await _run_guard(_make_request("/api/v1/me", cookie_token=_VALID_TOKEN))
assert response.status_code == 401
assert downstream.calls == 0
assert resolve_spy == []
async def test_flag_off_unknown_user_still_403(monkeypatch: pytest.MonkeyPatch) -> None:
"""Легаси-ветка целиком: юзер не в roles.yaml → 403 «user not in roles config»."""
monkeypatch.setattr(settings, "auth_mode", "legacy")
response, downstream = await _run_guard(
_make_request("/api/v1/me", header_user=_NOT_IN_ROLES_YAML)
)
assert response.status_code == 403
assert downstream.calls == 0
# ---------------------------------------------------------------------------
# ФЛАГ ВКЛЮЧЁН — сессионная кука как источник личности
# ---------------------------------------------------------------------------
@pytest.fixture
def session_on(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "auth_mode", "dual")
async def test_valid_cookie_grants_access_without_any_header(
monkeypatch: pytest.MonkeyPatch, session_on: None
) -> None:
"""Валидная кука пускает — легаси-заголовка при этом нет вовсе."""
db = FakeAuthDb({_VALID_TOKEN: _valid_session(_PILOT_LOGIN)})
_install_auth_db(monkeypatch, db)
response, downstream = await _run_guard(_make_request("/api/v1/me", cookie_token=_VALID_TOKEN))
assert response.status_code == 200
assert downstream.seen_users == [_PILOT_LOGIN]
assert db.select_tokens == [_VALID_TOKEN]
async def test_expired_session_cookie_does_not_grant_access(
monkeypatch: pytest.MonkeyPatch, session_on: None
) -> None:
"""Истёкшая сессия = сессии нет: без легаси-заголовка это 401."""
now = datetime.now(UTC)
db = FakeAuthDb(
{
_EXPIRED_TOKEN: _Row(
expires_at=now - timedelta(seconds=1),
last_seen_at=now - timedelta(days=1),
username=_ADMIN_LOGIN,
access_state="active",
)
}
)
_install_auth_db(monkeypatch, db)
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token=_EXPIRED_TOKEN)
)
assert response.status_code == 401
assert downstream.calls == 0
# Истёкшая сессия не продлевается sliding-refresh'ем — иначе она была бы вечной.
assert db.updates == []
@pytest.mark.parametrize("access_state", ["disabled", "trial_expired", "some_future_state"])
async def test_non_active_access_state_does_not_grant_access(
monkeypatch: pytest.MonkeyPatch, session_on: None, access_state: str
) -> None:
"""Блокировка в реестре действует НЕМЕДЛЕННО, не дожидаясь expires_at.
`some_future_state` состояние, добавленное миграцией раньше кода: fail-closed
(`to_access_state` disabled), а не «раз не disabled, значит пускаем».
"""
db = FakeAuthDb({_VALID_TOKEN: _valid_session(_ADMIN_LOGIN, access_state=access_state)})
_install_auth_db(monkeypatch, db)
response, downstream = await _run_guard(_make_request("/api/v1/me", cookie_token=_VALID_TOKEN))
assert response.status_code == 401
assert downstream.calls == 0
assert db.updates == []
async def test_session_user_missing_from_roles_yaml_is_403(
monkeypatch: pytest.MonkeyPatch, session_on: None, caplog: pytest.LogCaptureFixture
) -> None:
"""Есть в реестре, нет в auth/roles.yaml → 403 + отдельное сообщение о рассинхроне.
Реестр отвечает «кто ты», roles.yaml «что тебе можно»; человек, заведённый только
в реестре, не получает доступ по умолчанию.
"""
db = FakeAuthDb({_VALID_TOKEN: _valid_session("brand_new_hire")})
_install_auth_db(monkeypatch, db)
with caplog.at_level(logging.WARNING, logger="app.main"):
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token=_VALID_TOKEN)
)
assert response.status_code == 403
assert downstream.calls == 0
assert any("roles.yaml" in r.getMessage() for r in caplog.records)
# ---------------------------------------------------------------------------
# 🔴 ГЛАВНОЕ: подделка X-Authenticated-User при валидной куке
# ---------------------------------------------------------------------------
async def test_valid_cookie_overrides_client_supplied_header(
monkeypatch: pytest.MonkeyPatch, session_on: None
) -> None:
"""🔴 Кука выигрывает у присланного клиентом заголовка — downstream видит ВЛАДЕЛЬЦА КУКИ.
Сценарий: у человека есть валидная сессия (`user1`, pilot), и он вручную добавляет
к запросу `X-Authenticated-User: admin`. На проде Caddy шлёт этот заголовок на
каждый запрос, так что «поставить только если отсутствует» здесь не сработало бы:
заголовок присутствует ВСЕГДА, и любой из одиннадцати прямых читателей (аудит,
/me, created_by в insights/own-projects, forecast/analyze) увидел бы подделку.
Проверяем оба следствия перезаписи: значение владелец куки, и заголовок в scope
РОВНО ОДИН (append без фильтра оставил бы два, а `headers.get` вернул бы первый
то есть подделанный).
"""
db = FakeAuthDb({_VALID_TOKEN: _valid_session(_PILOT_LOGIN)})
_install_auth_db(monkeypatch, db)
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token=_VALID_TOKEN, header_user=_ADMIN_LOGIN)
)
assert response.status_code == 200
assert downstream.seen_users == [_PILOT_LOGIN], "downstream увидел подделанный заголовок"
assert downstream.seen_header_counts == [1], "в scope осталось два X-Authenticated-User"
async def test_forged_admin_header_cannot_escalate_to_admin_api(
monkeypatch: pytest.MonkeyPatch, session_on: None
) -> None:
"""🔴 Та же подделка на admin-эндпоинте: роль берётся от владельца куки → 403.
Это тест на ЭСКАЛАЦИЮ ПРИВИЛЕГИЙ, а не на атрибуцию, и он ловит другую поломку,
чем тест выше. Проверено мутацией: подмена перезаписи заголовка на append его НЕ
красит guard решает по локальной переменной `username`, уже взятой из сессии.
Покраснеет он от поломки ПОРЯДКА: «сначала заголовок, потом кука» или повторное
чтение `request.headers` после резолва тогда pilot с подделанным `admin` вошёл
бы в /api/v1/admin/*. Оба теста нужны: один держит downstream, другой сам guard.
"""
db = FakeAuthDb({_VALID_TOKEN: _valid_session(_PILOT_LOGIN)})
_install_auth_db(monkeypatch, db)
response, downstream = await _run_guard(
_make_request(
"/api/v1/admin/scrape/status", cookie_token=_VALID_TOKEN, header_user=_ADMIN_LOGIN
)
)
assert response.status_code == 403
assert response.body == b'{"detail":"admin only"}'
assert downstream.calls == 0
async def test_cookie_owner_wins_even_when_forged_header_is_unknown_user(
monkeypatch: pytest.MonkeyPatch, session_on: None
) -> None:
"""Обратная сторона: мусор в заголовке не мешает владельцу валидной куки войти.
Пинует порядок «кука заголовок»: если бы заголовок проверялся первым, `ghost`
дал бы 403 человеку с законной сессией.
"""
db = FakeAuthDb({_VALID_TOKEN: _valid_session(_ADMIN_LOGIN)})
_install_auth_db(monkeypatch, db)
response, downstream = await _run_guard(
_make_request(
"/api/v1/admin/scrape/status",
cookie_token=_VALID_TOKEN,
header_user=_NOT_IN_ROLES_YAML,
)
)
assert response.status_code == 200
assert downstream.seen_users == [_ADMIN_LOGIN]
# ---------------------------------------------------------------------------
# Dual-mode: нет куки / кука не резолвится → легаси-заголовок
# ---------------------------------------------------------------------------
async def test_no_cookie_falls_back_to_legacy_header(
monkeypatch: pytest.MonkeyPatch, session_on: None, resolve_spy: list[str | None]
) -> None:
"""Флаг включён, куки нет — работает заголовок, и в БД `auth` не идёт ни запроса."""
_install_auth_db(monkeypatch, None) # любое обращение к реестру → RuntimeError
response, downstream = await _run_guard(_make_request("/api/v1/me", header_user=_ADMIN_LOGIN))
assert response.status_code == 200
assert downstream.seen_users == [_ADMIN_LOGIN]
assert resolve_spy == [], "куки нет — резолвить нечего, коннект открывать незачем"
async def test_unknown_token_falls_back_to_legacy_header(
monkeypatch: pytest.MonkeyPatch, session_on: None
) -> None:
"""Кука есть, сессии в реестре нет (протухла/отозвана) → легаси-путь, не отказ.
Пока стоит popup, это ровно тот же уровень доступа, что и сегодня; отказывать
здесь значило бы ломать вход людям со старой кукой в браузере.
"""
db = FakeAuthDb() # пусто: токен не найден
_install_auth_db(monkeypatch, db)
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token=_UNKNOWN_TOKEN, header_user=_ADMIN_LOGIN)
)
assert response.status_code == 200
assert downstream.seen_users == [_ADMIN_LOGIN]
assert db.select_tokens == [_UNKNOWN_TOKEN]
async def test_foreign_cookie_name_is_not_a_session(
monkeypatch: pytest.MonkeyPatch, session_on: None, resolve_spy: list[str | None]
) -> None:
"""Чужая кука (другое имя) сессией не считается — читаем только session_cookie_name."""
_install_auth_db(monkeypatch, None)
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token="whatever", cookie_name="ym_uid")
)
assert response.status_code == 401
assert downstream.calls == 0
assert resolve_spy == []
async def test_no_cookie_no_header_is_401(
monkeypatch: pytest.MonkeyPatch, session_on: None
) -> None:
"""Ни куки, ни заголовка → 401 с прежним текстом (его читает фронт)."""
_install_auth_db(monkeypatch, None)
response, downstream = await _run_guard(_make_request("/api/v1/me"))
assert response.status_code == 401
assert b"no authenticated user" in response.body
assert downstream.calls == 0
# ---------------------------------------------------------------------------
# Публичные пути
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("path", sorted(app_main._PUBLIC_PATHS))
async def test_public_paths_need_nothing_and_touch_no_registry(
monkeypatch: pytest.MonkeyPatch,
session_on: None,
resolve_spy: list[str | None],
no_engine_build: None,
path: str,
) -> None:
"""/health и прочие публичные пути — без куки, без заголовка, без коннекта к `auth`.
Параметризация по самому `_PUBLIC_PATHS`: добавят путь в список он проверится.
"""
_install_auth_db(monkeypatch, None)
response, downstream = await _run_guard(_make_request(path))
assert response.status_code == 200
assert downstream.calls == 1
assert resolve_spy == []
async def test_public_path_with_cookie_still_skips_registry(
monkeypatch: pytest.MonkeyPatch, session_on: None, resolve_spy: list[str | None]
) -> None:
"""Публичный путь + кука в браузере → всё равно ни одного запроса к реестру."""
_install_auth_db(monkeypatch, FakeAuthDb({_VALID_TOKEN: _valid_session(_ADMIN_LOGIN)}))
response, _ = await _run_guard(_make_request("/health", cookie_token=_VALID_TOKEN))
assert response.status_code == 200
assert resolve_spy == []
# ---------------------------------------------------------------------------
# Сбой БД `auth` при резолве
# ---------------------------------------------------------------------------
async def test_registry_failure_does_not_silently_admit_cookie_owner(
monkeypatch: pytest.MonkeyPatch, session_on: None, caplog: pytest.LogCaptureFixture
) -> None:
"""Реестр упал → 401 (нет легаси-заголовка) + ERROR с traceback, а не тихий проход.
Два независимых требования:
1) владелец куки НЕ входит «на всякий случай» упавший резолв не даёт личности;
2) событие громкое: `logger.exception` уровня ERROR уезжает в GlitchTip
(LoggingIntegration event_level=ERROR), т.е. это алерт, а не строка в логе.
"""
_install_auth_db(monkeypatch, None)
with caplog.at_level(logging.ERROR, logger="app.main"):
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token=_VALID_TOKEN)
)
assert response.status_code == 401
assert downstream.calls == 0
errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
assert len(errors) == 1, "сбой реестра обязан быть ровно одним ERROR-событием"
assert errors[0].exc_info is not None, "нужен traceback: без него алерт бесполезен"
async def test_registry_failure_degrades_to_legacy_while_popup_is_up(
monkeypatch: pytest.MonkeyPatch, session_on: None, caplog: pytest.LogCaptureFixture
) -> None:
"""Осознанная переходная деградация: сломанный реестр → сегодняшний путь + ERROR.
Пока Caddy basic_auth стоит перед бэкендом, легаси-заголовок защищён ровно тем же,
чем защищён весь продукт сегодня, и класть «Птицу» целиком (503) из-за проблемы
реестра незачем.
Этот тест маркер долга, а не одобрение поведения навсегда. Последний PR эпика
снимает popup; вместе с ним деградация обязана уйти (у «Меры» это auth_mode=db_only),
иначе заголовок станет полностью клиентским. Тест тогда переписывается на отказ.
"""
_install_auth_db(monkeypatch, None)
with caplog.at_level(logging.ERROR, logger="app.main"):
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token=_VALID_TOKEN, header_user=_ADMIN_LOGIN)
)
assert response.status_code == 200
assert downstream.seen_users == [_ADMIN_LOGIN]
assert [r for r in caplog.records if r.levelno >= logging.ERROR]
# ---------------------------------------------------------------------------
# Test-mode bypass остаётся выключателем ВСЕГО guard'а
# ---------------------------------------------------------------------------
async def test_testing_bypass_disables_session_branch_too(
monkeypatch: pytest.MonkeyPatch, session_on: None, resolve_spy: list[str | None]
) -> None:
"""`settings.testing=True` отключает и session-ветку — сознательно, не по недосмотру.
Промежуточного состояния «личность резолвим, а 401/403 не применяем» нет ни в одном
реальном режиме; поэтому весь остальной сьют (conftest ставит testing=True) не
начинает вдруг ходить в БД `auth`.
"""
monkeypatch.setattr(settings, "testing", True)
_install_auth_db(monkeypatch, None)
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token=_VALID_TOKEN, header_user=_ADMIN_LOGIN)
)
assert response.status_code == 200
assert downstream.seen_users == [_ADMIN_LOGIN], "bypass не должен переписывать заголовок"
assert resolve_spy == []
async def test_forged_header_in_mixed_case_is_replaced_not_duplicated(
monkeypatch: pytest.MonkeyPatch, session_on: None
) -> None:
"""Заголовок в НЕканоническом регистре тоже вытесняется, а не остаётся вторым.
По спеке ASGI имена заголовков в scope всегда lowercase, и uvicorn её соблюдает
но `_propagate_authenticated_user` на это не полагается. Если бы фильтр сравнивал
сырые байты, в scope осталась бы ВТОРАЯ запись `X-Authenticated-User: admin` рядом
с нашей. Эксплуатируемой дыры это не давало (`Headers.get` лоуэркейсит искомый
ключ, но не хранимый, поэтому смешанный регистр не матчится никогда), но состояние
«две записи с одним именем» ложное по построению и в чужом ASGI-слое, который
регистр нормализует, оно стало бы подделкой.
"""
db = FakeAuthDb({_VALID_TOKEN: _valid_session(_PILOT_LOGIN)})
_install_auth_db(monkeypatch, db)
request = _make_request(
"/api/v1/me",
cookie_token=_VALID_TOKEN,
header_user=_ADMIN_LOGIN,
header_name=b"X-Authenticated-User",
)
response, downstream = await _run_guard(request)
assert response.status_code == 200
assert downstream.seen_users == [_PILOT_LOGIN]
names = [k for k, _ in request.scope["headers"] if k.lower() == b"x-authenticated-user"]
assert names == [b"x-authenticated-user"], "подделка осталась в scope вторым заголовком"
async def test_registry_failure_alert_is_throttled(
monkeypatch: pytest.MonkeyPatch, session_on: None, caplog: pytest.LogCaptureFixture
) -> None:
"""Лежащий реестр даёт ОДИН ERROR на окно, остальное — WARNING без traceback.
Guard резолвит сессию на каждом non-public запросе с кукой, а ERROR уезжает
событием в GlitchTip (LoggingIntegration event_level=ERROR). Без троттлинга сбой
реестра выжигал бы квоту за минуты и настоящие ошибки этого же периода терялись
бы вместе с ней. Факт продолжающегося сбоя при этом остаётся видимым в логах.
"""
_install_auth_db(monkeypatch, None)
with caplog.at_level(logging.WARNING, logger="app.main"):
for _ in range(3):
response, _ = await _run_guard(_make_request("/api/v1/me", cookie_token=_VALID_TOKEN))
assert response.status_code == 401
errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(errors) == 1, "второй и третий сбой обязаны быть подавлены троттлингом"
assert errors[0].exc_info is not None
assert len(warnings) == 2, "подавленные сбои всё равно обязаны быть видны в логе"
assert all(w.exc_info is None for w in warnings)
# ---------------------------------------------------------------------------
# AUTH_MODE=db_only — конечное состояние эпика: легаси-ветка НЕДОСТИЖИМА
# ---------------------------------------------------------------------------
@pytest.fixture
def db_only(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "auth_mode", "db_only")
async def test_db_only_ignores_legacy_header_completely(
monkeypatch: pytest.MonkeyPatch, db_only: None, resolve_spy: list[str | None]
) -> None:
"""🔴 Ради этого режим и заведён: `X-Authenticated-User` больше не пускает никого.
Этот режим включается тем же PR, который снимает `basic_auth` + `header_up` из
Caddy, то есть делает заголовок полностью клиентским. Пройди `curl -H
'X-Authenticated-User: admin'` здесь это был бы полный обход аутентификации.
"""
_install_auth_db(monkeypatch, None)
response, downstream = await _run_guard(_make_request("/api/v1/me", header_user=_ADMIN_LOGIN))
assert response.status_code == 401
assert b"valid session required" in response.body
assert downstream.calls == 0
assert resolve_spy == [], "куки нет — резолвить нечего"
async def test_db_only_rejects_when_registry_is_down(
monkeypatch: pytest.MonkeyPatch, db_only: None, caplog: pytest.LogCaptureFixture
) -> None:
"""Сбой реестра в db_only = отказ, а не деградация на заголовок.
Тот же вход, что в `test_registry_failure_degrades_to_legacy_while_popup_is_up`
(кука + заголовок + лежащий реестр), но исход противоположный. Пара тестов и есть
механическая защита: удалить легаси-фолбэк забудут этот тест покраснеет, если
db_only начнёт вести себя как dual.
"""
_install_auth_db(monkeypatch, None)
with caplog.at_level(logging.ERROR, logger="app.main"):
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token=_VALID_TOKEN, header_user=_ADMIN_LOGIN)
)
assert response.status_code == 401
assert downstream.calls == 0
assert [r for r in caplog.records if r.levelno >= logging.ERROR]
@pytest.mark.parametrize("access_state", ["disabled", "trial_expired"])
async def test_db_only_blocked_account_cannot_fall_back_to_header(
monkeypatch: pytest.MonkeyPatch, db_only: None, access_state: str
) -> None:
"""Заблокированный в реестре не добирает доступ подделанным заголовком."""
db = FakeAuthDb({_VALID_TOKEN: _valid_session(_ADMIN_LOGIN, access_state=access_state)})
_install_auth_db(monkeypatch, db)
response, downstream = await _run_guard(
_make_request("/api/v1/me", cookie_token=_VALID_TOKEN, header_user=_ADMIN_LOGIN)
)
assert response.status_code == 401
assert downstream.calls == 0
async def test_db_only_admits_valid_session(monkeypatch: pytest.MonkeyPatch, db_only: None) -> None:
"""Валидная сессия работает и в db_only — режим убирает фолбэк, а не вход."""
db = FakeAuthDb({_VALID_TOKEN: _valid_session(_PILOT_LOGIN)})
_install_auth_db(monkeypatch, db)
response, downstream = await _run_guard(_make_request("/api/v1/me", cookie_token=_VALID_TOKEN))
assert response.status_code == 200
assert downstream.seen_users == [_PILOT_LOGIN]
async def test_db_only_keeps_public_paths_open(
monkeypatch: pytest.MonkeyPatch, db_only: None, no_engine_build: None
) -> None:
"""/health и прочие публичные пути остаются публичными — иначе упадёт healthcheck."""
_install_auth_db(monkeypatch, None)
response, downstream = await _run_guard(_make_request("/health"))
assert response.status_code == 200
assert downstream.calls == 1

View file

@ -0,0 +1,305 @@
"""Резолв сессии общего реестра — `app/services/auth_session.py` (эпик «единый вход»).
Слой ниже guard'а: «что считать валидной сессией» и «когда продлевать». Через guard
эти правила проверяются end-to-end в `tests/test_auth_session_guard.py`; здесь
поштучно, включая ветки, до которых из guard'а дотянуться дорого (sliding refresh,
сбой продления, исключения БД).
Дублёр сессии БД (`FakeAuthDb`) намеренно ОДИН на оба файла и живёт в guard-тестах:
разъехавшиеся двойники типовой способ получить два зелёных теста при одном сломанном
поведении. Прецедент кросс-импорта внутри пакета tests `tests/integration/*`.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime, timedelta
import pytest
from pydantic import SecretStr
from app.core import auth_db
from app.core.auth_db import AuthDatabaseNotConfiguredError
from app.core.config import settings
from app.services import auth_session as svc
from app.services.auth_session import AccessState, SessionUser, get_session_user, to_access_state
from tests.test_auth_session_guard import FakeAuthDb, _install_auth_db, _Row
_TOKEN = "tok-1"
_USER = "user1"
def _row(
*,
expires_in: timedelta = timedelta(days=7),
last_seen_ago: timedelta | None = timedelta(seconds=30),
username: str = _USER,
access_state: str = "active",
) -> _Row:
now = datetime.now(UTC)
return _Row(
expires_at=now + expires_in,
last_seen_at=None if last_seen_ago is None else now - last_seen_ago,
username=username,
access_state=access_state,
)
# ---------------------------------------------------------------------------
# AccessState / to_access_state — fail-closed
# ---------------------------------------------------------------------------
def test_only_active_can_sign_in() -> None:
assert AccessState.ACTIVE.can_sign_in is True
assert AccessState.TRIAL_EXPIRED.can_sign_in is False
assert AccessState.DISABLED.can_sign_in is False
def test_to_access_state_known_values() -> None:
assert to_access_state("active") is AccessState.ACTIVE
assert to_access_state("trial_expired") is AccessState.TRIAL_EXPIRED
assert to_access_state("disabled") is AccessState.DISABLED
@pytest.mark.parametrize("value", ["frozen", "", None, 42])
def test_to_access_state_unknown_is_disabled_with_warning(
value: object, caplog: pytest.LogCaptureFixture
) -> None:
"""Неизвестное/NULL/чужой тип → disabled + WARNING.
Миграции БД `auth` применяет деплой «Птицы», то есть новое состояние может
появиться в базе раньше, чем код о нём узнает. Обратный выбор («не disabled =
пускаем») означал бы, что такая миграция молча раздаёт доступ.
"""
with caplog.at_level(logging.WARNING, logger="app.services.auth_session"):
assert to_access_state(value) is AccessState.DISABLED
assert caplog.records
# ---------------------------------------------------------------------------
# get_session_user — что считается валидной сессией
# ---------------------------------------------------------------------------
def test_valid_session_resolves_to_user() -> None:
db = FakeAuthDb({_TOKEN: _row()})
assert get_session_user(db, _TOKEN) == SessionUser(
username=_USER, access_state=AccessState.ACTIVE
)
assert db.select_tokens == [_TOKEN]
def test_empty_token_short_circuits_without_query() -> None:
db = FakeAuthDb({_TOKEN: _row()})
assert get_session_user(db, "") is None
assert db.select_tokens == []
def test_unknown_token_returns_none() -> None:
db = FakeAuthDb()
assert get_session_user(db, "never-issued") is None
assert db.updates == []
def test_expired_session_returns_none_and_is_not_refreshed() -> None:
"""Истёкшая сессия не воскресает sliding-refresh'ем — иначе TTL был бы вечным."""
db = FakeAuthDb(
{_TOKEN: _row(expires_in=timedelta(seconds=-1), last_seen_ago=timedelta(days=1))}
)
assert get_session_user(db, _TOKEN) is None
assert db.updates == []
def test_null_expires_at_returns_none() -> None:
"""`expires_at IS NULL` (колонку ослабили) → сессии нет, а не TypeError в auth-пути."""
row = _row()
row.expires_at = None
db = FakeAuthDb({_TOKEN: row})
assert get_session_user(db, _TOKEN) is None
@pytest.mark.parametrize("access_state", ["disabled", "trial_expired", "unheard_of"])
def test_non_active_user_returns_none_immediately(access_state: str) -> None:
"""Блокировка в реестре бьёт сразу, не дожидаясь expires_at (иначе до 30 дней)."""
db = FakeAuthDb({_TOKEN: _row(access_state=access_state)})
assert get_session_user(db, _TOKEN) is None
assert db.updates == []
# ---------------------------------------------------------------------------
# Sliding refresh
# ---------------------------------------------------------------------------
def test_refresh_skipped_for_recent_session() -> None:
"""Свежий last_seen_at → ни одного UPDATE: иначе каждый API-запрос бил бы в БД."""
db = FakeAuthDb({_TOKEN: _row(last_seen_ago=timedelta(seconds=30))})
assert get_session_user(db, _TOKEN) is not None
assert db.updates == []
assert db.commits == 0
@pytest.mark.parametrize(
"last_seen_ago", [timedelta(minutes=5), timedelta(hours=3), None], ids=["at-5m", "3h", "null"]
)
def test_refresh_extends_after_interval(last_seen_ago: timedelta | None) -> None:
""">= 5 минут (и NULL) → один UPDATE на обе колонки + commit.
TTL берётся из настроек и обязан совпадать с «Мерой»: продлевает сессию тот
продукт, в котором кликнули последним, и срок жизни не должен от этого зависеть.
"""
db = FakeAuthDb({_TOKEN: _row(last_seen_ago=last_seen_ago)})
assert get_session_user(db, _TOKEN) is not None
assert db.updates == [{"ttl_hours": settings.session_ttl_hours, "token": _TOKEN}]
assert db.commits == 1
def test_refresh_uses_configured_ttl(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "session_ttl_hours", 12)
db = FakeAuthDb({_TOKEN: _row(last_seen_ago=timedelta(hours=1))})
get_session_user(db, _TOKEN)
assert db.updates == [{"ttl_hours": 12, "token": _TOKEN}]
def test_refresh_failure_does_not_block_valid_session(caplog: pytest.LogCaptureFixture) -> None:
"""Продление — best-effort: сбой логируется и откатывается, юзер всё равно валиден.
Иначе read-only реплика или блокировка строки разлогинивала бы всех, у кого
сессия старше пяти минут.
"""
db = FakeAuthDb({_TOKEN: _row(last_seen_ago=timedelta(hours=1))}, fail_refresh=True)
with caplog.at_level(logging.WARNING, logger="app.services.auth_session"):
user = get_session_user(db, _TOKEN)
assert user == SessionUser(username=_USER, access_state=AccessState.ACTIVE)
assert db.rollbacks == 1
assert any("sliding refresh failed" in r.getMessage() for r in caplog.records)
# В сообщении не должно быть ни username, ни токена: лог — не место для связки
# «кто именно» + «когда», а разбор идёт по времени.
assert not any(_USER in r.getMessage() or _TOKEN in r.getMessage() for r in caplog.records)
def test_select_failure_is_not_swallowed() -> None:
"""Сбой SELECT'а летит наружу: решение «что делать со сломанным реестром» — не здесь.
Проглоти резолвер ошибку вызывающий получил бы «сессии нет», то есть отказ
выглядел бы как «просто не залогинен», а откат на trusted-header как норма.
"""
db = FakeAuthDb()
def _boom(*_a: object, **_k: object) -> None:
raise RuntimeError("auth registry is down")
db.execute = _boom # type: ignore[method-assign]
with pytest.raises(RuntimeError, match="auth registry is down"):
get_session_user(db, _TOKEN)
# ---------------------------------------------------------------------------
# resolve_session_token — точка входа guard'а
# ---------------------------------------------------------------------------
def test_resolve_returns_none_without_touching_db_when_flag_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""🔒 Инвариант «выключенный флаг = ни одного коннекта» держится этим модулем.
Он не полагается на то, что вызывающий сам не позовёт резолв: даже с валидным
токеном соединение не открывается.
"""
monkeypatch.setattr(settings, "auth_mode", "legacy")
_install_auth_db(monkeypatch, None) # открытие сессии → RuntimeError
assert svc.resolve_session_token(_TOKEN) is None
@pytest.mark.parametrize("token", [None, ""])
def test_resolve_returns_none_for_empty_token(
monkeypatch: pytest.MonkeyPatch, token: str | None
) -> None:
monkeypatch.setattr(settings, "auth_mode", "dual")
_install_auth_db(monkeypatch, None)
assert svc.resolve_session_token(token) is None
def test_resolve_opens_registry_session_when_flag_on(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "auth_mode", "dual")
db = FakeAuthDb({_TOKEN: _row()})
_install_auth_db(monkeypatch, db)
assert svc.resolve_session_token(_TOKEN) == SessionUser(
username=_USER, access_state=AccessState.ACTIVE
)
assert db.select_tokens == [_TOKEN]
def test_resolve_propagates_not_configured_error(monkeypatch: pytest.MonkeyPatch) -> None:
"""Флаг включён, DSN пуст → исключение наружу, а не «сессия не найдена».
Тихий None здесь означал бы либо массовый отказ доступа под видом «не залогинен»,
либо (в guard'е) бессрочную раздачу прав в обход реестра. Настоящий `auth_db` не
подменяется проверяется именно связка сервис конфигурация.
"""
monkeypatch.setattr(settings, "auth_mode", "dual")
monkeypatch.setattr(settings, "auth_database_url", "")
monkeypatch.setattr(settings, "auth_db_password", SecretStr(""))
auth_db.reset_auth_db()
try:
with pytest.raises(AuthDatabaseNotConfiguredError):
svc.resolve_session_token(_TOKEN)
finally:
auth_db.reset_auth_db()
def test_expiry_is_also_filtered_by_db_clock() -> None:
"""Срок годности отсекается ЧАСАМИ БД, а не только часами процесса.
Асимметрия, которую это закрывает: решение «жива ли сессия» принимал Python
(`datetime.now(UTC)`), а продление писало `expires_at = now() + interval` часами
СЕРВЕРА. Отставание часов приложения давало бы не «сессия проживёт на дельту
дольше», а необратимое воскрешение: строку, которую БД уже считает мёртвой, Python
пропускал бы, тут же срабатывал sliding-refresh и отодвигал expires_at на полный
TTL от серверного now(). Секунда расхождения +30 дней жизни.
Форма запроса проверяется дублёром (`_assert_select_shape`), поэтому здесь
достаточно одного прохода: потеряется `AND s.expires_at > now()` тест покраснеет.
"""
db = FakeAuthDb({_TOKEN: _row()})
assert get_session_user(db, _TOKEN) is not None
def test_rollback_failure_does_not_break_the_resolve(caplog: pytest.LogCaptureFixture) -> None:
"""Сбой самого rollback'а (оборванный коннект) не отменяет валидную сессию.
Иначе «best-effort продление» переставало быть best-effort: исключение улетало бы
из get_session_user наружу, и валидный юзер получал бы вместо доступа ERROR в
GlitchTip и деградацию на легаси-заголовок (а в db_only отказ).
"""
db = FakeAuthDb({_TOKEN: _row(last_seen_ago=timedelta(hours=1))}, fail_refresh=True)
def _dead_connection() -> None:
raise RuntimeError("server closed the connection unexpectedly")
db.rollback = _dead_connection # type: ignore[method-assign]
with caplog.at_level(logging.WARNING, logger="app.services.auth_session"):
user = get_session_user(db, _TOKEN)
assert user == SessionUser(username=_USER, access_state=AccessState.ACTIVE)
assert any("rollback" in r.getMessage() for r in caplog.records)

View file

@ -42,11 +42,26 @@ def _reset_auth_cache() -> None:
# Test app — копия rbac_guard из app/main.py, чтобы не подтягивать тяжёлые # Test app — копия rbac_guard из app/main.py, чтобы не подтягивать тяжёлые
# импорты (weasyprint, celery worker, ...). Если поведение middleware меняется # импорты (weasyprint, celery worker, ...). Если поведение middleware меняется
# в проде — синхронизируй здесь. # в проде — синхронизируй здесь.
#
# NB: копия воспроизводит ЛЕГАСИ-ВЕТКУ принятия решения (trusted-header) и намеренно
# не знает про сессионную куку общего реестра, добавленную эпиком «единый вход»:
# при AUTH_MODE=legacy (дефолт) прод-guard принимает решение ровно так же, и тесты
# ниже проверяют именно тот режим. Режимы dual/db_only (кука → заголовок, приоритет
# куки над подделанным заголовком, 401/403, публичные пути) покрыты в
# tests/test_auth_session_guard.py — там вызывается НАСТОЯЩИЙ app.main.rbac_guard,
# без копии.
#
# «Ровно так же» — про ЛОГИКУ, не про списки: `_PUBLIC_PATHS` ниже держится
# синхронным с прод-версией руками (расхождение уже случалось — в копии не было
# /api/v1/ping), и никакой механики, которая бы это гарантировала, нет. Прод-список
# параметризован в test_auth_session_guard.py, поэтому его расширение хотя бы там
# проверяется автоматически.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/") _ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"}) # Синхронно с app.main._PUBLIC_PATHS (там же и /api/v1/ping — он был потерян здесь).
_PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"})
def _build_test_app() -> FastAPI: def _build_test_app() -> FastAPI:
@ -110,11 +125,24 @@ def client() -> TestClient:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Пилотные логины user1..user10 в auth/roles.yaml. user2 — «Брусника»: доступ
# закрыт владельцем продукта 2026-07-30, роль переведена pilot → expired. Это
# ЕДИНСТВЕННОЕ отклонение от «все userN = pilot», и оно ожидаемое; хардкод
# именно здесь, отдельной константой, а не магическим `if` в цикле.
_EXPIRED_PILOT_LOGINS = {"user2": "«Брусника», доступ закрыт 2026-07-30"}
def test_get_role_known_users() -> None: def test_get_role_known_users() -> None:
"""Ловит рассинхрон auth/roles.yaml с ожиданиями теста: roles.yaml лежит вне
`backend/**`, поэтому правка ролей не попадает в paths-filter CI и такой
рассинхрон CI молча пропускает (так и случилось с user2 expired)."""
assert auth_mod.get_role("admin") == "admin" assert auth_mod.get_role("admin") == "admin"
assert auth_mod.get_role("kopylov") == "pilot" assert auth_mod.get_role("kopylov") == "pilot"
for n in range(1, 11): for n in range(1, 11):
assert auth_mod.get_role(f"user{n}") == "pilot" login = f"user{n}"
expected = "expired" if login in _EXPIRED_PILOT_LOGINS else "pilot"
why = _EXPIRED_PILOT_LOGINS.get(login, "обычный пилотный логин")
assert auth_mod.get_role(login) == expected, f"{login}: ожидали {expected}{why}"
def test_get_role_unknown_user_raises() -> None: def test_get_role_unknown_user_raises() -> None:

View file

@ -220,4 +220,8 @@ COMMENT ON MATERIALIZED VIEW mv_quarter_price_index IS
'Consumer: estimator service (#647-3) reads O(1) by quarter_cad_number. ' 'Consumer: estimator service (#647-3) reads O(1) by quarter_cad_number. '
'Issue: #760.'; 'Issue: #760.';
-- C3 (#2583): DROP CASCADE выше уносит грант из 99b — без ре-гранта tradein FDW
-- (quarter_price_index, роль tradein_fdw_reader) ловит permission denied.
GRANT SELECT ON public.mv_quarter_price_index TO tradein_fdw_reader;
COMMIT; COMMIT;

View file

@ -0,0 +1,22 @@
-- 188_regrant_quarter_price_index_fdw.sql
-- C3 (#2583): tradein-эстиматор потерял квартальный индекс 2026-07-05.
--
-- Причина: 179_mv_quarter_price_cadastral_floor.sql делает
-- DROP MATERIALIZED VIEW mv_quarter_price_index CASCADE; CREATE ...
-- DROP+CREATE не сохраняет гранты — GRANT из 99b_grant_quarter_price_index_fdw.sql
-- пропал, и tradein-сторона (foreign table quarter_price_index, роль
-- tradein_fdw_reader) с 05.07 получала:
-- permission denied for materialized view mv_quarter_price_index
--
-- Симптом «не выполняется с 5 июля» — это дата применения 179, а не поломка
-- рефреша: REFRESH MATERIALIZED VIEW (beat: 05:00 МСК 5-го числа) гранты не трогает.
--
-- Применено вживую на прод 2026-08-04 (FDW-чтение проверено: 1894 строки).
-- Этот файл — идемпотентное закрепление. Парный фикс: GRANT дописан в конец 179,
-- чтобы повторное применение 179 больше не теряло грант.
BEGIN;
GRANT SELECT ON public.mv_quarter_price_index TO tradein_fdw_reader;
COMMIT;

View file

@ -0,0 +1,123 @@
-- auth/001: users + sessions — единое хранилище доступов для «Меры» и «Птицы».
--
-- WHY (почему отдельная БД и почему таблицы называются нейтрально):
-- Владелец продукта решил (2026-07-31) свести вход в «Меру» (trade-in, /trade-in) и
-- «Птицу» (раздел Site Finder, /site-finder/analysis/[cad]/ptica) к ОДНОЙ нейтральной
-- форме входа, вместо браузерного popup'а Caddy basic_auth. Значит, у хранилища доступов
-- два потребителя, и оно не должно принадлежать ни одному из них: живёт в отдельной БД
-- `auth` на платформенном сервере gendesign-postgres (тот же кластер, отдельная база —
-- новый контейнер не заводим; оба бэкенда сидят в сети gendesign_shared и TCP-достают
-- до gendesign-postgres-1:5432, проверено на проде 2026-07-31).
-- Отсюда имена без префикса продукта: `users`, а не `tradein_users`. Префикс продукта в
-- нейтральном хранилище означал бы, что вторая система — гость в чужой таблице, и через
-- полгода никто бы не помнил, кто владелец схемы.
--
-- Здесь НЕТ колонки `role` — сознательно. Идентичность («кто это, какой у него пароль,
-- активен ли доступ») общая для двух продуктов; полномочия внутри продукта (admin/manager/
-- employee в «Мере», админ-роуты в «Птице») — это знание продукта, оно остаётся в
-- продуктовых БД (tradein_users.role) и не переезжает сюда. Иначе `auth` пришлось бы
-- менять каждый раз, когда в одном из продуктов появляется новая роль.
--
-- WHAT:
-- 1. users — identity. password_hash NULL допустим (см. комментарий к колонке): пароли
-- НИКОГДА не попадают в git, ни plaintext, ни bcrypt-хешем — конвенция репо, прецедент
-- tradein-mvp/backend/data/sql/193_tradein_users_seed.sql. Сид (003) вставляет строки
-- с password_hash = NULL, хеши проставляются на проде отдельно.
-- 2. sessions — токен-based сессии, ON DELETE CASCADE от users (удалили пользователя —
-- его сессии теряют смысл). last_seen_at отдельно от created_at — для idle-timeout,
-- иначе «сессия жива 30 дней» и «человек не заходил 30 дней» неразличимы.
-- 3. ASCII-CHECK на username — обязателен ДО появления прод-данных (см. ниже).
--
-- IDEMPOTENCY:
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS; CHECK-констрейнты объявлены
-- inline в CREATE TABLE, а не через ALTER — при повторном прогоне CREATE TABLE не
-- выполняется вообще, значит констрейнт физически не может задублироваться (паттерн из
-- 192_tradein_users_auth.sql).
--
-- Тип id: `bigint GENERATED ALWAYS AS IDENTITY` — стандартный (SQL-standard) эквивалент
-- bigserial: та же bigint-колонка на той же последовательности, но sequence принадлежит
-- таблице жёстко и не переживает DROP COLUMN сиротой, а прямой INSERT в id запрещён
-- (случайная вставка «своего» id, ломающая счётчик, невозможна). Ровно так объявлен
-- tradein_users.id в 192 — держим один тип на обе таблицы, чтобы будущий код, читающий
-- обе, не спотыкался о разницу.
--
-- Dependencies: нет (пустая БД `auth`, создаётся bootstrap-шагом деплоя,
-- см. ops/db-bootstrap/create_auth_db.sql).
-- Deploy order: Foundation. Роль приложения + гранты — 002, сид — 003. Python-код логина,
-- логин-страница и снятие Caddy basic_auth — отдельные PR'ы ПОСЛЕ этого
-- (SQL-схема первой, см. .claude/rules/sql.md «Migration order»).
BEGIN;
CREATE TABLE IF NOT EXISTS users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username text NOT NULL UNIQUE,
password_hash text NULL,
display_name text NULL,
org_name text NULL,
email text NULL,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT users_username_ascii_ck CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$')
);
COMMENT ON TABLE users IS
'Единое хранилище доступов для «Меры» (trade-in) и «Птицы» (Site Finder) — только '
'идентичность. Полномочия внутри продукта (роли) остаются в продуктовых БД: иначе эту '
'таблицу пришлось бы менять при каждом изменении ролевой модели любого из продуктов.';
COMMENT ON COLUMN users.password_hash IS
'NULL = пароль ещё не проставлен, вход по паролю для этой строки невозможен. Хеши '
'НИКОГДА не хранятся в git (ни в сидах, ни в фикстурах) — их проставляют на проде '
'отдельно от миграции; иначе один утёкший коммит открывает вход всем аккаунтам сразу.';
COMMENT ON COLUMN users.is_active IS
'false = доступ закрыт владельцем продукта. Отдельная колонка, а не удаление строки: '
'удаление каскадом снесло бы сессии и историю, а закрытие доступа обратимо и его надо '
'уметь отличать от «такого пользователя никогда не было».';
COMMENT ON COLUMN users.org_name IS
'Организация пользователя. NULL, пока реальные данные не подтверждены владельцем '
'продукта — выдуманное название хуже пустого, оно выглядит достоверным.';
COMMENT ON CONSTRAINT users_username_ascii_ck ON users IS
'Fail-closed запрет не-ASCII логинов (перенесено из tradein м.193, deep-review #2561): '
'downstream-код кодирует username сессии через encode("latin-1","replace"), поэтому два '
'кириллических логина ОДИНАКОВОЙ длины схлопываются в одну и ту же byte-строку из «?» — '
'разные люди получают общую идентичность, общую квоту и взаимный IDOR (один видит данные '
'другого). Констрейнт на уровне схемы, а не проверка в UI/API: проверку в коде однажды '
'забудут добавить в новый путь создания пользователя, схему обойти нельзя.';
CREATE TABLE IF NOT EXISTS sessions (
token text PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
last_seen_at timestamptz NOT NULL DEFAULT now(),
ip_address inet NULL,
user_agent text NULL
);
COMMENT ON TABLE sessions IS
'Активные сессии единой формы входа (общие для «Меры» и «Птицы»). ON DELETE CASCADE от '
'users: оставшаяся сессия удалённого пользователя — это действующий доступ без владельца.';
COMMENT ON COLUMN sessions.last_seen_at IS
'Обновляется на каждом запросе — нужен для idle-timeout: без него «сессия не истекла» и '
'«человек ещё работает» неразличимы, и забытая открытая вкладка живёт до expires_at.';
COMMENT ON COLUMN sessions.ip_address IS
'IP на момент выдачи токена — для разбора инцидентов («откуда зашли под этим логином»), '
'не для авторизации: привязка к IP ломает мобильных пользователей при смене сети.';
-- Индексы — как в tradein м.192: уборка протухших сессий по expires_at и выборка/отзыв
-- всех сессий одного пользователя по user_id (FK сам по себе индекс не создаёт, а без него
-- ON DELETE CASCADE на users делает seq scan по всей таблице сессий).
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx
ON sessions (expires_at);
CREATE INDEX IF NOT EXISTS sessions_user_id_idx
ON sessions (user_id);
COMMIT;

View file

@ -0,0 +1,82 @@
-- auth/002: роль приложения auth_app + гранты (least privilege).
--
-- WHY:
-- Миграции этой БД прогоняются суперюзером кластера ($POSTGRES_USER), он же владелец
-- таблиц. Бэкенды «Меры» и «Птицы» ходить под суперюзером не должны: скомпрометированный
-- бэкенд не обязан уметь DROP TABLE users. Поэтому отдельная login-роль с точечными
-- грантами. БД `auth` НЕ принадлежит auth_app (владелец — суперюзер): владелец таблицы
-- имеет на неё все права независимо от GRANT'ов, и разграничение ниже стало бы фикцией.
--
-- Пароль роли здесь НЕ задаётся — роль создаётся passwordless, пароль ставится отдельным
-- bootstrap-шагом деплоя из env (AUTH_DB_PASSWORD в /opt/gendesign/backend/.env.runtime,
-- см. ops/db-bootstrap/set_auth_app_password.sql). Ровно тот же паттерн, что у
-- gendesign_reader (tradein м.101 + set_gendesign_reader_password.sql) и tradein_fdw_reader
-- (data/sql/100_tradein_fdw_role.sql). Пароль в git не попадает ни при каких условиях.
--
-- Периметр прав (обосновано по-операционно):
-- sessions — SELECT/INSERT/UPDATE/DELETE. Полный набор: выдать токен (INSERT), проверить
-- на каждом запросе (SELECT), обновить last_seen_at (UPDATE), разлогинить и вычистить
-- протухшие (DELETE).
-- users — SELECT (найти по username, прочитать hash и is_active) + UPDATE (смена пароля
-- самим пользователем и проставление хеша админом).
-- users — INSERT/DELETE НЕ выдаются, сознательно:
-- * INSERT — создание аккаунтов в PR-1 не существует ни как код, ни как UI. Выдать грант
-- «на будущее» = держать открытой операцию, которой никто не пользуется и которую никто
-- не тестирует. Когда появится админский путь создания пользователей, грант добавляется
-- новой миграцией в одну строку (плюс GRANT USAGE на sequence, идентичность требует
-- nextval). Обратная ошибка дороже: снять грант, на который уже опирается прод-код,
-- нельзя без синхронного релиза.
-- * DELETE — не выдаётся и дальше: закрытие доступа делается через is_active = false
-- (см. комментарий к колонке в 001). Физическое удаление каскадом сносит сессии и
-- обрывает связь с историей действий пользователя в продуктовых БД, где user_id/username
-- остаются висеть; это операция уровня «руками через psql с осознанием последствий»,
-- а не то, что должен уметь HTTP-хендлер.
--
-- IDEMPOTENCY:
-- CREATE ROLE через DO-блок с проверкой pg_roles (нет ADD ROLE IF NOT EXISTS), GRANT/REVOKE
-- идемпотентны по определению. Повторный прогон — no-op. Роли в PostgreSQL общие на кластер,
-- поэтому DO-блок отработает корректно, даже если роль уже создана из другой БД.
--
-- Dependencies: 001_identity_schema.sql (гранты ссылаются на users/sessions).
BEGIN;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'auth_app') THEN
CREATE ROLE auth_app LOGIN;
END IF;
END$$;
COMMENT ON ROLE auth_app IS
'Прикладная роль единой формы входа («Мера» + «Птица»). Пароль ставится '
'.forgejo/workflows/deploy.yml из env AUTH_DB_PASSWORD (backend/.env.runtime) через '
'ops/db-bootstrap/set_auth_app_password.sql. Пароль никогда не хранится в SQL-миграциях.';
-- Никто, кроме владельца БД и явно поименованных ролей, не должен даже подключаться:
-- по умолчанию PostgreSQL даёт CONNECT роли PUBLIC, то есть любая login-роль кластера
-- (glitchtip, tradein_fdw_reader, gendesign_reader) может открыть сессию в `auth`.
-- Хранилище паролей — не то место, где стоит полагаться на «а таблицы им всё равно не видны».
--
-- ЭТА СТРОКА ПРОДУБЛИРОВАНА в ops/db-bootstrap/create_auth_db.sql — намеренно, инвариант
-- держится в двух местах. Здесь — ради самодостаточности миграции: применённая на пустую БД
-- (scratch/staging, ручной psql -f) она обязана давать полный периметр прав, не полагаясь на
-- то, что кто-то отдельно прогнал bootstrap. В bootstrap — ради переприменяемости: миграция
-- выполняется РОВНО ОДИН РАЗ (трекинг в _schema_migrations), а БД может быть пересоздана из
-- дампа в обход миграций, и тогда дефолтный PUBLIC-CONNECT вернулся бы молча. Не «сокращай»
-- дубль — ни одна из копий не покрывает сценарий другой.
REVOKE ALL ON DATABASE auth FROM PUBLIC;
-- Defense-in-depth: явный REVOKE-периметр перед точечными грантами — любые унаследованные
-- или PUBLIC-гранты на существующих объектах обнуляются (паттерн из 100_tradein_fdw_role.sql).
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM auth_app;
REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM auth_app;
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA public FROM auth_app;
GRANT CONNECT ON DATABASE auth TO auth_app;
GRANT USAGE ON SCHEMA public TO auth_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON sessions TO auth_app;
GRANT SELECT, UPDATE ON users TO auth_app;
COMMIT;

View file

@ -0,0 +1,111 @@
-- auth/003: сид 13 существующих аккаунтов (org-карта владельца продукта, 2026-07-30/31).
--
-- WHY:
-- 001 создала схему, но без данных единая форма входа не заработает: реальные аккаунты
-- сейчас живут только в Caddy basic_auth (caddy/users.caddy.snippet + tradein auth/roles.yaml)
-- и в tradein_users. Эта миграция переносит список людей — БЕЗ ЕДИНОГО ПАРОЛЯ.
--
-- password_hash = NULL у ВСЕХ строк. Это конвенция репо, а не недоделка: ни plaintext, ни
-- bcrypt-хеш не должны попадать в git (прецедент — tradein-mvp/backend/data/sql/
-- 193_tradein_users_seed.sql, там сид тоже вставляет NULL, хеши проставляются отдельно на
-- проде). Хеш в git — это офлайн-brute-force для любого, кто получил доступ к репозиторию,
-- и он переживает любую ротацию пароля в истории коммитов.
-- Пока hash = NULL, вход по паролю через новую форму для строки невозможен, но доступ НЕ
-- теряется: PR-1 ничего не переключает, прод продолжает пускать через существующий
-- Caddy basic_auth ровно как сейчас. Переключение — отдельные PR'ы.
--
-- Состав (утверждён владельцем продукта):
-- admin — владелец
-- kopylov — отдельный клиент, display_name «Копылов»
-- praktika — ГК «Практика»
-- user1, user3..user10 — свободные слоты, is_active = true
-- user2 — «Брусника», is_active = FALSE (доступ закрыт 2026-07-30);
-- в roles.yaml он role=expired — расхождение семантики,
-- см. ⚠️ у строки user2 в VALUES ниже
-- display_name заполнен только у kopylov (единственная фамилия, подтверждённая в коде:
-- tradein auth.py::_USERNAME_PROFILE). Остальным NULL — реальных данных нет, выдумывать
-- нельзя: выдуманное ФИО в UI неотличимо от настоящего.
-- QA-фикстуры НЕ мигрируются — им нечего делать в общем хранилище доступов двух продуктов.
-- Состав фикстур неоднороден, и это важно при сверке списков (проверено по обоим файлам):
-- admintest, pilottest — действующие логины: есть И в caddy/users.caddy.snippet
-- (basic_auth-запись с хешем), И в auth/roles.yaml (role-mapping). Реально входят.
-- analysttest, expiredtest — существуют ТОЛЬКО в auth/roles.yaml как role-mapping,
-- basic_auth-записи в caddy/users.caddy.snippet у них нет, то есть войти под ними
-- снаружи сегодня нельзя вообще. Это тестовые фикстуры, а не аккаунты: analysttest
-- гоняется в backend/tests (test_rbac.py, test_insights.py, test_audit_middleware.py),
-- expiredtest — в tradein-mvp/backend/tests/test_rbac.py как покрытие role=expired.
--
-- IDEMPOTENCY (логика и обоснование перенесены из tradein м.193, deep-review #2564):
-- INSERT ... ON CONFLICT (username) DO UPDATE, но НЕ безусловно: password_hash, display_name,
-- org_name, email защищены COALESCE(текущее, EXCLUDED). Если админ уже проставил пароль или
-- поправил профиль между двумя прогонами файла (обычный auto-apply трекает filename в
-- _schema_migrations и не запускает файл дважды на одном окружении — но ручной re-apply при
-- recovery и scratch/staging БД такого трекинга не имеют), повторный прогон НЕ должен
-- затереть это состояние NULL-ом. В м.193 это был живой баг: назначенный через API manager_id
-- тихо обнулялся повторным прогоном сида.
-- Направление COALESCE односторонее: NULL в БД можно дозаполнить значением из сида, но
-- значение из БД никогда не перетирается сидом.
--
-- is_active НАМЕРЕННО отсутствует в SET — и не как COALESCE тоже: колонка NOT NULL, значит
-- COALESCE(NOT NULL-значение, x) никогда не возьмёт x, это был бы мёртвый код с видимостью
-- защиты. Открытие/закрытие доступа — решение владельца продукта, оно принимается в
-- интерфейсе, а не повторным прогоном seed-файла: после первой вставки колонка сознательно
-- «замораживается» на текущем значении в БД.
-- (В м.193 в SET присутствовал ещё role — как источник истины org-карты. Здесь колонки role
-- нет вовсе: полномочия остаются в продуктовых БД, см. заголовок 001.)
--
-- updated_at = now() выставляется на любом конфликте, даже когда ни одна колонка фактически
-- не изменилась — паритет с м.193; «строка была затронута прогоном сида» это честно отражает.
--
-- Разрывы в users.id после повторного прогона — норма, НЕ следы удалённых строк. Дефолт
-- GENERATED ALWAYS AS IDENTITY вычисляется ДО обнаружения конфликта, поэтому каждый
-- повторный прогон сжигает 13 значений последовательности впустую. Функционально безвредно;
-- упомянуто, чтобы дыры в id не увели разбор инцидента в сторону «кого-то удалили».
--
-- Dependencies: 001_identity_schema.sql (users + ASCII-CHECK на username; все логины ниже
-- ASCII, констрейнту не противоречат).
BEGIN;
INSERT INTO users (username, password_hash, display_name, org_name, email, is_active)
VALUES
('admin', NULL, NULL, NULL, NULL, true),
('kopylov', NULL, 'Копылов', NULL, NULL, true),
('praktika', NULL, NULL, NULL, NULL, true),
('user1', NULL, NULL, NULL, NULL, true),
-- user2 — «Брусника», доступ закрыт владельцем продукта 2026-07-30.
--
-- ⚠️ ОТКРЫТАЯ РАЗВИЛКА, решается в PR-2/3 (переключение на единую форму входа), НЕ здесь:
-- сегодня в auth/roles.yaml у user2 role=expired, и семантика ДРУГАЯ, чем is_active=false.
-- expired != disabled: expired-юзер проходит гейт (basic_auth-запись в
-- caddy/users.caddy.snippet у него есть), доходит до фронта и видит осмысленный экран
-- «пробный доступ закончился» (roles.yaml → блок expired: paths: [] + deny "/**";
-- frontend NoAccessScreen variant="trial"). is_active=false — это отказ на этапе входа,
-- неотличимый для пользователя от «неверный пароль».
-- Сейчас расхождение безобидно: PR-1 ничего не переключает, прод по-прежнему ходит через
-- Caddy basic_auth + roles.yaml, и никакой код эту колонку не читает. Но в момент
-- переключения trial-экран пропадёт МОЛЧА — тесты не упадут, роль просто перестанет
-- существовать как состояние. Решать тогда: если trial-UX сохраняем, нужно отдельное
-- состояние (колонка status / отдельная роль), а не булев флаг — is_active схлопывает
-- «доступ закрыт» и «пробный период истёк» в одно значение. Схему в этом PR НЕ трогаем.
('user2', NULL, NULL, NULL, NULL, false),
('user3', NULL, NULL, NULL, NULL, true),
('user4', NULL, NULL, NULL, NULL, true),
('user5', NULL, NULL, NULL, NULL, true),
('user6', NULL, NULL, NULL, NULL, true),
('user7', NULL, NULL, NULL, NULL, true),
('user8', NULL, NULL, NULL, NULL, true),
('user9', NULL, NULL, NULL, NULL, true),
('user10', NULL, NULL, NULL, NULL, true)
ON CONFLICT (username) DO UPDATE SET
-- COALESCE(текущее, EXCLUDED): сид дозаполняет пустые поля, но никогда не затирает
-- уже проставленные вручную (в первую очередь password_hash — иначе повторный прогон
-- отключал бы вход всем, кому пароль уже выдали).
password_hash = COALESCE(users.password_hash, EXCLUDED.password_hash),
display_name = COALESCE(users.display_name, EXCLUDED.display_name),
org_name = COALESCE(users.org_name, EXCLUDED.org_name),
email = COALESCE(users.email, EXCLUDED.email),
-- is_active НЕ в SET: NOT NULL-колонка, COALESCE был бы мёртвым кодом (см. IDEMPOTENCY).
updated_at = now();
COMMIT;

View file

@ -0,0 +1,408 @@
-- auth/004: продуктовые роли + org-иерархия + трёхзначный access_state вместо булева is_active.
--
-- ⚠️ ЭТА МИГРАЦИЯ СОЗНАТЕЛЬНО ОТМЕНЯЕТ РЕШЕНИЯ, ЗАПИСАННЫЕ В 001 И 002.
-- Это не рассинхрон и не ошибка автора: решение владельца продукта от 2026-07-31 принято
-- ПОСЛЕ того, как 001-003 были написаны и применены на проде. Применённую миграцию править
-- нельзя (повторно она не выполнится — трекинг в _schema_migrations), поэтому актуальная
-- правда живёт здесь, а в 001/002 остаются исторические формулировки:
-- * 001:15-19 «Здесь НЕТ колонки role — сознательно» → ОТМЕНЕНО, см. WHY-1;
-- * 002:22-33 «users — INSERT/DELETE НЕ выдаются, сознательно» → ОТМЕНЕНО ЧАСТИЧНО: INSERT
-- выдаётся (без него переезд не состоится), DELETE — по-прежнему нет, см. Часть 4;
-- * 002:26-27 «идентичность требует nextval» (грант USAGE на sequence) → ФАКТИЧЕСКИ
-- НЕВЕРНО, гранта не требуется; проверено, разбор в Части 4;
-- * 003:78-90 «открытая развилка про trial-экран, решается в PR-2/3» → ЗАКРЫТА, см. WHY-2.
-- Ориентир для читателя: актуальное состояние колонок описано COMMENT'ами в БД, они
-- переписаны здесь. Заголовок 001 — археология, а не спецификация.
--
-- WHY-1 — продуктовые роли переезжают в `auth` (отмена решения 001):
-- 001 строилась на схеме «идентичность общая, полномочия у продукта»: auth.users знает, КТО
-- человек, tradein_users знает, ЧТО ему можно. Владелец выбрал другой сценарий — ПОЛНЫЙ
-- переезд: tradein_users (БД tradein) в итоге удаляется, auth.users остаётся единственным
-- реестром людей. Как только реестр один, роль перестаёт быть «знанием продукта»: без неё в
-- auth.users нельзя ни завести сотрудника, ни собрать раздел «Команда», ни ответить на вопрос
-- «чьи заявки видит этот менеджер» — а спросить больше не у кого, второй таблицы не будет.
-- Промежуточный вариант (человек в auth.users, его роль в tradein_users) — это два реестра,
-- которые кто-то обязан держать синхронными руками; их расхождение выглядит как «пользователь
-- есть, но он никто» и чинится только вручную по факту жалобы.
-- Цена решения ровно та, которую 001 и называла: новая роль в любом из продуктов = миграция
-- этой БД. Принято сознательно — это дешевле, чем двойной реестр людей.
--
-- WHY-2 — три состояния доступа вместо булева is_active (закрытие развилки из 003):
-- Булев флаг схлопывает два РАЗНЫХ события в одно значение: «пробный период закончился» и
-- «доступ закрыт владельцем». Для пользователя разница видимая и она уже реализована в
-- сегодняшнем стеке: expired-аккаунт доходит до фронта и видит осмысленный экран «пробный
-- доступ закончился» (auth/roles.yaml → expired: paths: [] + deny "/**"; frontend
-- NoAccessScreen variant="trial"), а закрытый — просто не входит. Переключившись на единую
-- форму входа с булевым is_active, мы бы потеряли trial-экран МОЛЧА: состояние перестало бы
-- существовать, и ни один тест бы не упал. Ровно это и было записано как открытая развилка в
-- 003:78-90. Решение: состояний три.
-- active — доступ есть, обычный вход.
-- trial_expired — пароль ВЕРНЫЙ, но пробный период истёк: логин отвечает 403 с отдельным
-- кодом и текстом «пробный доступ закончился», сессия НЕ выдаётся.
-- disabled — жёсткая блокировка: generic 401, для пользователя неотличимо от «неверный
-- пароль».
-- Неверный пароль в ЛЮБОМ состоянии → generic 401. Иначе отдельный 403 превращается в оракул
-- существования логина: перебором можно перечислить аккаунты, не зная ни одного пароля.
-- Осмысленный ответ полагается только тому, кто пароль уже доказал.
-- text + CHECK, а не enum-тип: добавить четвёртое состояние — это ALTER одного констрейнта в
-- обычной миграции, тогда как ALTER TYPE ... ADD VALUE нельзя использовать в той же
-- транзакции, где значение добавлено (PG16), и enum тянет за собой отдельный тип в дампах.
-- Enum-типов в репозитории нет вовсе — не заводим первый ради трёх значений.
--
-- WHAT:
-- 1. role — text NOT NULL + CHECK ('admin','manager','employee'). Тип, набор значений
-- и отсутствие DEFAULT — зеркало tradein_users.role (м.192:42).
-- 2. manager_id — self-FK ON DELETE SET NULL + иерархический CHECK + запрет self-manager +
-- partial index. Зеркало м.192:43/50-52/84-86, чтобы код «Меры» переехал на
-- auth.users без правок.
-- 3. access_state — text NOT NULL DEFAULT 'active' + CHECK на три значения; backfill из
-- is_active, точечный перевод user2 («Брусника») в trial_expired, затем
-- DROP COLUMN is_active.
-- 4. Гранты auth_app — INSERT на users (DELETE НЕ выдаётся) + сужение табличного UPDATE (002:80) до
-- column-level: новые колонки role/access_state не должны попасть под него
-- молча.
--
-- IDEMPOTENCY:
-- ADD COLUMN IF NOT EXISTS / DROP COLUMN IF EXISTS / CREATE INDEX IF NOT EXISTS; констрейнты —
-- через DO-блок с проверкой pg_constraint (в PostgreSQL нет ADD CONSTRAINT IF NOT EXISTS для
-- CHECK/FK, паттерн из м.193:80-90); GRANT идемпотентен по определению; UPDATE-backfill'ы
-- отфильтрованы так, что второй прогон не находит строк (детали у каждого блока).
-- Проверка pg_constraint здесь фильтрует ДОПОЛНИТЕЛЬНО по conrelid (в отличие от м.193, где
-- только conname): имена констрейнтов уникальны в пределах таблицы, а не БД — одноимённый
-- констрейнт на соседней таблице заставил бы миграцию молча пропустить создание своего.
--
-- ⚠️ ПОСЛЕ 004 ФАЙЛЫ 001 И 003 БОЛЬШЕ НЕ ПЕРЕИГРЫВАЮТСЯ ПООТДЕЛЬНОСТИ.
-- Обе ссылаются на колонку is_active, которой после этой миграции нет, и обе падают на уже
-- мигрированной БД с «column is_active does not exist»:
-- * 001 — на `COMMENT ON COLUMN users.is_active` (001:75). CREATE TABLE IF NOT EXISTS
-- пропускается, а COMMENT выполняется всегда — то есть ручной `psql -f 001` падает
-- РАНЬШЕ 003, вопреки интуиции «ломается только сид».
-- * 003 — на INSERT со списком колонок, включающим is_active (а если бы и не упал —
-- role NOT NULL без DEFAULT не даст вставить строку).
-- Это следствие требования «применённые миграции не правим», а не регресс. Поддерживаемый
-- сценарий восстановления — прогон каталога ЦЕЛИКОМ по возрастанию номеров (001→002→003→004)
-- на пустой БД; он рабочий, порядок гарантирован сортировкой имён в deploy.yml. Нужно добить
-- сид на живой БД — пиши новый файл 00N, не переигрывай 003.
--
-- Dependencies: 001_identity_schema.sql (users), 002_auth_app_role.sql (роль auth_app — гранты
-- Части 4 её предполагают), 003_users_seed.sql (13 строк, которым backfill проставляет role).
-- Deploy order: применяется на прод авто-циклом deploy.yml по data/sql/auth/*.sql. Python-кода в
-- этом PR нет и поведение прода не меняется — в БД `auth` пока никто не ходит; код логина,
-- чтение role/access_state и удаление tradein_users — отдельные PR'ы ПОСЛЕ (см.
-- .claude/rules/sql.md «Migration order»: схема первой).
BEGIN;
-- ---------------------------------------------------------------------------------------------
-- Часть 1: role
-- ---------------------------------------------------------------------------------------------
-- DEFAULT сознательно НЕТ (как в м.192): роль — осознанное решение того, кто заводит человека.
-- С дефолтом INSERT, забывший указать роль, тихо создал бы работающий аккаунт с полномочиями
-- «по умолчанию»; без дефолта он падает на NOT NULL — это и есть нужное поведение.
-- Колонка добавляется NULLable, заполняется backfill'ом ниже и только потом получает NOT NULL:
-- прямой ADD COLUMN ... NOT NULL без DEFAULT упал бы на 13 уже существующих строках сида.
ALTER TABLE users ADD COLUMN IF NOT EXISTS role text;
-- Backfill. Источник истины — м.193:101-113 (org-карта владельца продукта от 2026-07-30),
-- сверено построчно по файлу, не по памяти. Роли не являются секретом: они уже лежат в git
-- (м.193 и auth/roles.yaml) — запрет на git касается паролей и хешей, не полномочий.
-- `role IS NULL` в каждом WHERE даёт сразу две вещи: идемпотентность (второй прогон не находит
-- строк) и защиту от отката ручных решений — повышение сотрудника до manager, сделанное после
-- первого прогона, повторным применением файла не вернётся к seed-значению.
UPDATE users SET role = 'admin' WHERE role IS NULL AND username = 'admin';
UPDATE users SET role = 'manager' WHERE role IS NULL AND username IN ('kopylov', 'praktika');
-- Catch-all — ПОСЛЕДНИМ и именно employee: любая строка, попавшая в auth.users мимо сида
-- (ручная вставка, восстановление из дампа, будущий аккаунт), получает НАИМЕНЕЕ
-- привилегированную роль. Fail-safe: ошибка в этом месте не должна раздавать admin.
UPDATE users SET role = 'employee' WHERE role IS NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_role_ck' AND conrelid = 'users'::regclass
) THEN
ALTER TABLE users
ADD CONSTRAINT users_role_ck CHECK (role IN ('admin', 'manager', 'employee'));
END IF;
END $$;
-- SET NOT NULL идемпотентен (на уже NOT NULL колонке — no-op) и стоит ПОСЛЕ backfill: на строке
-- с NULL он упал бы, а catch-all выше гарантирует, что таких строк не осталось.
ALTER TABLE users ALTER COLUMN role SET NOT NULL;
-- ---------------------------------------------------------------------------------------------
-- Часть 2: manager_id (org-иерархия)
-- ---------------------------------------------------------------------------------------------
-- FK и CHECK объявлены ОТДЕЛЬНЫМИ шагами, а не inline в ADD COLUMN (как в м.192, где это было
-- частью CREATE TABLE IF NOT EXISTS — «всё или ничего»). Причина: `ADD COLUMN IF NOT EXISTS ...
-- REFERENCES ...` пропускает ВЕСЬ оператор, если колонка уже есть, — на БД, где manager_id
-- когда-то завели руками без FK, миграция отчиталась бы об успехе и оставила связь без
-- ссылочной целостности. Раздельные идемпотентные шаги такого состояния не допускают.
-- Имя FK задано явно тем же, которое сгенерировал бы PostgreSQL для inline-формы, — чтобы схема
-- на проде и схема из чистой сборки не различались именами констрейнтов.
ALTER TABLE users ADD COLUMN IF NOT EXISTS manager_id bigint;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_manager_id_fkey' AND conrelid = 'users'::regclass
) THEN
-- ON DELETE SET NULL (зеркало м.192:43): удаление менеджера не должно каскадом сносить
-- его сотрудников — они остаются в реестре без привязки, и это чинится назначением
-- нового менеджера, а не восстановлением строк из бэкапа.
ALTER TABLE users
ADD CONSTRAINT users_manager_id_fkey
FOREIGN KEY (manager_id) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_role_manager_hierarchy_ck' AND conrelid = 'users'::regclass
) THEN
ALTER TABLE users
ADD CONSTRAINT users_role_manager_hierarchy_ck CHECK (
role NOT IN ('admin', 'manager') OR manager_id IS NULL
);
END IF;
END $$;
-- Запрет self-manager. users_role_manager_hierarchy_ck выше держит только admin/manager; для
-- employee self-FK допускает ссылку строки на саму себя, и `UPDATE users SET manager_id = id`
-- прошёл бы. Через сегодняшний API это недостижимо (team.py:398-406 требует role='manager' у
-- цели, PATCH manager_id вообще не меняет), но 004 делает auth.users ЕДИНСТВЕННЫМ реестром — в
-- него начнёт писать и «Птица», у которой этой валидации нет, а любой будущий WITH RECURSIVE по
-- manager_id на такой строке зациклится. Строчный CHECK ловит самый вероятный случай (опечатка
-- или копипаста собственного id) и стоит ноль.
-- Чего этот констрейнт НЕ ловит: взаимную пару employee↔employee (A.manager_id=B,
-- B.manager_id=A) и ссылку на строку с role<>'manager' — оба требуют чтения ДРУГОЙ строки,
-- строчным CHECK'ом это не выражается (нужен триггер или FK на несуществующий уникальный ключ
-- (id, role)). Инвариант зафиксирован COMMENT'ом к колонке — он живёт в приложении.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_manager_not_self_ck' AND conrelid = 'users'::regclass
) THEN
ALTER TABLE users
ADD CONSTRAINT users_manager_not_self_ck CHECK (
manager_id IS NULL OR manager_id <> id
);
END IF;
END $$;
-- Partial index (зеркало м.192:84-86): у admin/manager и у свободных слотов manager_id = NULL,
-- и эти строки никогда не участвуют в выборке «сотрудники этого менеджера». Индексировать NULL'ы
-- значит платить за большую часть таблицы, которая по этому пути не читается.
CREATE INDEX IF NOT EXISTS users_manager_id_idx
ON users (manager_id)
WHERE manager_id IS NOT NULL;
-- ---------------------------------------------------------------------------------------------
-- Часть 3: access_state вместо is_active
-- ---------------------------------------------------------------------------------------------
-- DEFAULT 'active' здесь, в отличие от role, уместен: «доступ есть» — это состояние, в котором
-- заводят любого нового сотрудника, и молчаливый дефолт не расширяет ничьих полномочий.
ALTER TABLE users ADD COLUMN IF NOT EXISTS access_state text NOT NULL DEFAULT 'active';
-- CHECK ставится СРАЗУ после колонки, до backfill'а: тогда он проверяет и сам backfill —
-- опечатка в значении ниже уронит миграцию, а не просочится в данные.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_access_state_ck' AND conrelid = 'users'::regclass
) THEN
ALTER TABLE users
ADD CONSTRAINT users_access_state_ck CHECK (
access_state IN ('active', 'trial_expired', 'disabled')
);
END IF;
END $$;
-- Backfill из is_active — под проверкой существования колонки, потому что в конце этого же
-- блока она удаляется: повторный прогон файла обязан пройти без ошибок, а прямое обращение к
-- несуществующей колонке — ошибка парсинга, не «0 строк».
-- EXECUTE (динамический SQL), а не обычные UPDATE внутри IF: обычные операторы уцелели бы лишь
-- благодаря ленивой подготовке операторов в PL/pgSQL (невыполненная ветка не разбирается). Это
-- рабочая, но недокументированная в самом файле деталь реализации; EXECUTE делает независимость
-- от отсутствующей колонки явной для читателя.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_attribute
WHERE attrelid = 'users'::regclass
AND attname = 'is_active'
AND NOT attisdropped
) THEN
-- Механическое отображение старой семантики: булев «доступ закрыт» = жёсткая блокировка.
-- `access_state = 'active'` в WHERE — не мёртвое условие: оно фиксирует, что переписывается
-- только значение, доставшееся из DEFAULT, и никогда — уже осмысленно проставленное.
EXECUTE $q$
UPDATE users
SET access_state = 'disabled'
WHERE is_active = false
AND access_state = 'active'
$q$;
-- Точечно: user2 («Брусника», доступ закрыт владельцем 2026-07-30) — не disabled, а
-- trial_expired. Основание: в auth/roles.yaml у него role=expired, то есть исторически он
-- видит trial-экран, а не отказ входа; решение владельца от 2026-07-31 эту семантику
-- сохраняет.
-- Условие `access_state = 'disabled'` — это защита от затирания ручного решения:
-- переводится РОВНО то значение, которое механическая ветка выше только что и вывела.
-- Если к моменту повторного прогона владелец уже открыл «Бруснике» доступ (active) или
-- перевёл её в другое состояние, WHERE не сматчится и решение человека переживёт миграцию.
-- Безусловный UPDATE по username возвращал бы аккаунт в trial_expired после каждого
-- прогона, и разбор «почему у клиента снова экран пробного периода» стоил бы часов при
-- нулевой пользе. Хардкод одного username оправдан: это разовая фиксация конкретного
-- исторического факта, а не правило — общего признака «пробный доступ» в схеме до сих пор
-- не было, выводить его задним числом не из чего.
EXECUTE $q$
UPDATE users
SET access_state = 'trial_expired'
WHERE username = 'user2'
AND access_state = 'disabled'
$q$;
END IF;
END $$;
-- Снятие is_active. Деструктивный шаг — но именно он и есть смысл решения: оставить обе колонки
-- значило бы два источника правды о доступе, расходящихся при первой же правке через UI.
-- Безопасно: на момент этого PR БД `auth` не читается ни одним работающим кодом (Caddy basic_auth
-- + tradein_users по-прежнему обслуживают прод), а данные колонки полностью перенесены выше.
-- DROP обязан жить именно здесь, а не в 003: 003 применён на проде и правке не подлежит.
ALTER TABLE users DROP COLUMN IF EXISTS is_active;
-- ---------------------------------------------------------------------------------------------
-- Часть 4: гранты auth_app под режим единственного реестра (отмена решения 002:22-33)
-- + сужение унаследованного табличного UPDATE до column-level
-- ---------------------------------------------------------------------------------------------
-- 002 намеренно не выдавала INSERT/DELETE на users, и её аргумент был верным для своего момента:
-- в PR-1 не существовало ни кода, ни UI создания аккаунтов, а грант «на будущее» — это открытая
-- операция, которой никто не пользуется и которую никто не тестирует. Аргумент перестаёт
-- применяться ровно сейчас: после полного переезда auth.users — единственный реестр людей, а
-- раздел «Команда» «Меры» (tradein-mvp/backend/app/api/v1/team.py: POST /employees заводит
-- сотрудника, PATCH правит) — единственный интерфейс, которым сотрудника заводят и убирают.
-- Без INSERT переезд физически не состоится: сегодняшний INSERT идёт в tradein_users, а её не
-- станет.
-- DELETE здесь НЕ выдаётся, хотя первая редакция этой миграции его содержала. Причина отказа:
-- DELETE-эндпоинта в team.py нет (только POST /employees и PATCH — проверено), то есть потребителя
-- у права нет ни одного, а 002:22-33 отклоняла ровно такие гранты-на-будущее. Симметричный
-- контраргумент («снять неиспользуемое право дешевле, чем добавлять его в момент релиза») здесь не
-- перевешивает: DELETE по users каскадит на sessions (001:94), то есть цена ошибки в коде выше
-- обычной, а добавить строку GRANT в миграцию того PR, где появится DELETE-хендлер, стоит ровно
-- столько же. Право выдаётся вместе с кодом, который им пользуется, — не раньше.
-- DELETE ≠ закрытие доступа. Закрытие — это access_state ('disabled' / 'trial_expired'):
-- обратимо, сохраняет строку и историю. Именно оно, а не удаление строки, закрывает сегодняшний
-- сценарий «Команды»; удаление понадобилось бы только чтобы убрать ошибочно заведённый слот.
GRANT INSERT ON users TO auth_app;
-- Гранта на последовательность users_id_seq здесь НЕТ — и это не забывчивость.
-- 002:26-27 записала как факт, что «идентичность требует nextval», то есть INSERT из auth_app
-- якобы упадёт с «permission denied for sequence» без USAGE на последовательности. Для
-- `GENERATED ALWAYS AS IDENTITY` (001:53) это неверно: PostgreSQL подставляет не вызов
-- nextval('...'), а узел NextValueExpr, который дёргает nextval_internal(seqid,
-- check_permissions := false) — ACL последовательности не проверяется вовсе. Это документированное
-- отличие identity от serial, и оно проверено живьём на postgres:16, а не выведено из
-- документации: после `REVOKE ALL ON SEQUENCE users_id_seq FROM app` INSERT в identity-таблицу
-- прошёл и вернул id, тогда как в контрольной таблице с bigserial тот же INSERT в тех же
-- условиях упал ровно с «permission denied for sequence».
-- Отсюда два следствия. Первое: грант не нужен — он выдал бы auth_app право звать
-- nextval('users_id_seq') напрямую (жечь идентификаторы) и читать last_value (число заведённых
-- аккаунтов), при том что ни один путь кода этого не делает; это прямо противоречило бы
-- REVOKE ALL ON ALL SEQUENCES из 002:73. Второе: «живая проверка» вида «auth_app сделал INSERT,
-- значит грант рабочий» ничего не доказывает — тот же INSERT проходит и после REVOKE, поэтому
-- проверять надо обратное (REVOKE, затем INSERT).
-- Если users.id когда-нибудь переведут на обычный DEFAULT nextval(...) — грант станет
-- обязательным, и его придётся добавить той же миграцией, что меняет колонку.
-- Сужение UPDATE до column-level. 002:80 выдала ТАБЛИЧНЫЙ `GRANT SELECT, UPDATE ON users`,
-- обосновав его узко («смена пароля самим пользователем и проставление хеша админом»), — но
-- табличный UPDATE автоматически распространяется на любые колонки, добавленные позже. Не сузь
-- мы его здесь, auth_app молча получил бы право писать role и access_state, и периметр 002
-- расширился бы ровно тем, что 004 добавила, без единой строки GRANT.
-- Почему это важно именно для этих двух колонок: любая SQL-инъекция или логическая ошибка в
-- UPDATE-эндпоинте (сегодня такой ровно один — team.py PATCH /employees, COALESCE-список полей
-- по WHERE id = :id) из «испортил профиль» превращалась бы в `SET role='admin' WHERE id=<свой>`
-- или `SET access_state='active' WHERE username='user2'` — тихое повышение до админа и тихое
-- снятие блокировки, без смены пароля, то есть без внешнего признака компрометации. Это ровно
-- тот класс, ради которого 002 и заводила отдельную роль (002:5-6).
-- role в список НЕ включена сознательно: сегодня её не пишет никто (team.py POST вставляет
-- литерал 'employee', PATCH в SET-списке role/manager_id не имеет вовсе). Появится админский
-- путь смены роли — добавится одной строкой новой миграции; это дешевле, чем держать открытым
-- право на эскалацию привилегий «на всякий случай».
-- manager_id по той же причине не включён: назначение сотрудника менеджеру сегодня делается
-- только при создании (INSERT), а не UPDATE'ом.
-- access_state включён — блокировка/разблокировка через «Команду» (сегодняшний
-- `is_active = COALESCE(...)` в PATCH) переезжает именно в эту колонку.
-- REVOKE перед GRANT обязателен и идемпотентен: REVOKE табличной привилегии снимает и
-- колоночные, поэтому повторный прогон файла даёт то же состояние (внутри одной транзакции,
-- то есть без окна «прав нет» для работающего приложения).
REVOKE UPDATE ON users FROM auth_app;
GRANT UPDATE (password_hash, display_name, org_name, email, access_state, updated_at)
ON users TO auth_app;
-- ---------------------------------------------------------------------------------------------
-- COMMENT'ы: переписываем то, что 004 сделала неверным в 001
-- ---------------------------------------------------------------------------------------------
COMMENT ON TABLE users IS
'Единый реестр людей для «Меры» (trade-in) и «Птицы» (Site Finder): идентичность И '
'полномочия. Решение владельца продукта 2026-07-31 — ПОЛНЫЙ переезд: tradein_users '
'удаляется, второго реестра не будет. Прежняя формулировка («роли остаются в продуктовых '
'БД», 001) отменена миграцией 004 — см. её заголовок.';
COMMENT ON COLUMN users.role IS
'Полномочия: admin | manager | employee. Зеркало tradein_users.role (tradein м.192) — код '
'«Меры» должен переехать на эту таблицу без правок в проверках роли. DEFAULT намеренно нет: '
'роль выбирает тот, кто заводит человека; INSERT без роли обязан падать, а не создавать '
'аккаунт с полномочиями «по умолчанию».';
COMMENT ON COLUMN users.manager_id IS
'Self-FK на users(id), ON DELETE SET NULL: удаление менеджера оставляет его сотрудников в '
'реестре без привязки, а не сносит их каскадом. NULL для admin/manager (top-level роли, '
'констрейнт users_role_manager_hierarchy_ck) и для employee без организации. '
'ИНВАРИАНТЫ, КОТОРЫЕ БД НЕ ПРОВЕРЯЕТ (обязан держать КАЖДЫЙ пишущий сюда код — реестр общий '
'для «Меры» и «Птицы»): цель ссылки обязана иметь role = ''manager''; циклы (A→B, B→A) '
'запрещены — рекурсивный обход иерархии на них зациклится. Схемой ловится только ссылка '
'строки на саму себя (users_manager_not_self_ck): остальное требует чтения другой строки и '
'строчным CHECK не выражается. Отсутствие проверки в БД — не разрешение.';
COMMENT ON COLUMN users.access_state IS
'Состояние доступа, три значения — заменило булев is_active (миграция 004). '
'active: вход разрешён. '
'trial_expired: пробный период истёк — при ВЕРНОМ пароле логин отвечает 403 с отдельным '
'кодом и текстом «пробный доступ закончился», сессия не выдаётся (аккаунт видит осмысленный '
'экран, а не «неверный пароль»). '
'disabled: доступ закрыт — generic 401, неотличимо от неверного пароля. '
'Неверный пароль в любом состоянии → generic 401: иначе отдельный ответ для trial_expired '
'стал бы оракулом существования логина. Булев флаг схлопывал бы trial_expired и disabled в '
'одно значение, и trial-экран исчез бы молча. '
'ИНВАРИАНТ ДЛЯ API (в БД не выразим): перевод ПОСЛЕДНЕГО active-админа в любое другое '
'состояние обязан отклоняться на уровне приложения. Констрейнт с role не связан, '
'UPDATE ... SET access_state = ''disabled'' WHERE username = ''admin'' в БД проходит, а после '
'перехода на единую форму входа это self-lockout: не остаётся аккаунта, способного открыть '
'доступ обратно через UI, восстановление — только psql на прод-БД. Сегодня путь закрыт тем, '
'что «Команда» не отдаёт строки с role = ''admin'' никому (team.py); любой новый админский '
'экран, пишущий access_state, обязан проверку восстановить.';
COMMENT ON CONSTRAINT users_role_manager_hierarchy_ck ON users IS
'admin/manager обязаны иметь manager_id IS NULL — это top-level роли, «начальника» у них в '
'этой модели нет (зеркало tradein м.192). Для employee manager_id любой, включая NULL '
'(свободный слот без организации допустим).';
COMMENT ON CONSTRAINT users_manager_not_self_ck ON users IS
'Строка не может быть собственным менеджером (manager_id <> id). Ловит опечатку/копипасту '
'id при ручной правке и у второго потребителя реестра («Птица»), где валидации «Команды» '
'нет. Взаимные пары и ссылку на не-менеджера строчный CHECK не ловит — см. COMMENT к '
'users.manager_id.';
COMMENT ON CONSTRAINT users_access_state_ck ON users IS
'Фиксирует ровно три состояния доступа. Расширение — новой миграцией с ALTER этого '
'констрейнта; тип text + CHECK выбран вместо enum именно ради дешёвого расширения.';
COMMIT;

View file

@ -21,7 +21,7 @@
| **Forgejo repo variables** (`vars.*`) | non-sensitive toggles (`LLM_ENABLED`, `OWN_DEVELOPER_IDS`) | ❌ нет | Forgejo Actions runner | | **Forgejo repo variables** (`vars.*`) | non-sensitive toggles (`LLM_ENABLED`, `OWN_DEVELOPER_IDS`) | ❌ нет | Forgejo Actions runner |
| **GitHub repo secrets** (зеркало для `.github/workflows/`) | deploy SSH key (obsidian-стек) | ❌ нет | GitHub Actions (только obsidian deploy) | | **GitHub repo secrets** (зеркало для `.github/workflows/`) | deploy SSH key (obsidian-стек) | ❌ нет | GitHub Actions (только obsidian deploy) |
| **`/opt/gendesign/.env`** (VPS, root-only, chmod 600) | DB creds, GlitchTip infra-secrets, FDW/reader passwords, прокси, COMPOSE_PROFILES | ❌ `.gitignore` | docker compose (main + obsidian + tradein стеки) | | **`/opt/gendesign/.env`** (VPS, root-only, chmod 600) | DB creds, GlitchTip infra-secrets, FDW/reader passwords, прокси, COMPOSE_PROFILES | ❌ `.gitignore` | docker compose (main + obsidian + tradein стеки) |
| **`/opt/gendesign/backend/.env.runtime`** (VPS, chmod 600) | runtime overlay: `SENTRY_RELEASE`, `GLITCHTIP_DSN`, `OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `OWN_DEVELOPER_IDS`, `GENDESIGN_FDW_PASSWORD`, `COUCHDB_*` | ❌ `.gitignore` | backend/worker/beat/couchdb | | **`/opt/gendesign/backend/.env.runtime`** (VPS, chmod 600) | runtime overlay: `SENTRY_RELEASE`, `GLITCHTIP_DSN`, `OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `OWN_DEVELOPER_IDS`, `GENDESIGN_FDW_PASSWORD`, `AUTH_DB_PASSWORD`, `COUCHDB_*` | ❌ `.gitignore` | backend/worker/beat/couchdb |
| **`/opt/gendesign/tradein-mvp/backend/.env.runtime`** (VPS, chmod 600) | tradein DB creds, Yandex/DaData ключи, прокси-URL, Cian-логин, reader password | ❌ `.gitignore` | tradein стек | | **`/opt/gendesign/tradein-mvp/backend/.env.runtime`** (VPS, chmod 600) | tradein DB creds, Yandex/DaData ключи, прокси-URL, Cian-логин, reader password | ❌ `.gitignore` | tradein стек |
| **`caddy/users.caddy.snippet`** (in git) | bcrypt-хеши basic_auth пилотных юзеров | ✅ да (хеши, не plaintext) | Caddy | | **`caddy/users.caddy.snippet`** (in git) | bcrypt-хеши basic_auth пилотных юзеров | ✅ да (хеши, не plaintext) | Caddy |
| **Obsidian vault `meta/00_credentials.md`** | реестр **значений** всех секретов + audit-log ротаций | ❌ (вне репо) | Anton | | **Obsidian vault `meta/00_credentials.md`** | реестр **значений** всех секретов + audit-log ротаций | ❌ (вне репо) | Anton |
@ -62,6 +62,7 @@
| `POSTGRES_PASSWORD` | `.env` | Пароль роли `gendesign` (PostGIS 16) | **E** (DB password) | | `POSTGRES_PASSWORD` | `.env` | Пароль роли `gendesign` (PostGIS 16) | **E** (DB password) |
| `POSTGRES_USER` / `POSTGRES_DB` | `.env` | Имя роли / БД (не секрет, но в `.env`) | **E** | | `POSTGRES_USER` / `POSTGRES_DB` | `.env` | Имя роли / БД (не секрет, но в `.env`) | **E** |
| `GENDESIGN_FDW_PASSWORD` | `backend/.env.runtime` | Пароль роли `tradein_fdw_reader` (FDW из main → tradein). Применяется через `ops/db-bootstrap/set_tradein_fdw_password.sql` | **E** | | `GENDESIGN_FDW_PASSWORD` | `backend/.env.runtime` | Пароль роли `tradein_fdw_reader` (FDW из main → tradein). Применяется через `ops/db-bootstrap/set_tradein_fdw_password.sql` | **E** |
| `AUTH_DB_PASSWORD` | `backend/.env.runtime` | Пароль роли `auth_app` — БД `auth` на gendesign-postgres (единое хранилище доступов «Меры» и «Птицы»). Применяется через `ops/db-bootstrap/set_auth_app_password.sql` на деплое. Переменная задаётся на VPS вручную; пока не задана — шаг пропускается с warning'ом | **E** |
| `COUCHDB_PASSWORD` / `COUCHDB_USER` | `backend/.env.runtime` | CouchDB (Obsidian LiveSync, `obsidian.gendsgn.ru`) | **E** | | `COUCHDB_PASSWORD` / `COUCHDB_USER` | `backend/.env.runtime` | CouchDB (Obsidian LiveSync, `obsidian.gendsgn.ru`) | **E** |
| `GLITCHTIP_DSN` | `backend/.env.runtime` | Backend GlitchTip DSN (перезаписывается deploy из `GLITCHTIP_BACKEND_DSN`) | **C** | | `GLITCHTIP_DSN` | `backend/.env.runtime` | Backend GlitchTip DSN (перезаписывается deploy из `GLITCHTIP_BACKEND_DSN`) | **C** |
| `GLITCHTIP_DB_PASS` | `.env` | Пароль БД GlitchTip-стека | **E** | | `GLITCHTIP_DB_PASS` | `.env` | Пароль БД GlitchTip-стека | **E** |
@ -76,7 +77,6 @@
|---|---|---| |---|---|---|
| `TRADEIN_POSTGRES_PASSWORD` / `TRADEIN_POSTGRES_USER` | Пароль/юзер БД `tradein` | **E** | | `TRADEIN_POSTGRES_PASSWORD` / `TRADEIN_POSTGRES_USER` | Пароль/юзер БД `tradein` | **E** |
| `TRADEIN_READER_PASSWORD` | Пароль роли `gendesign_reader` (ETL #976, `ops/db-bootstrap/set_gendesign_reader_password.sql`) | **E** | | `TRADEIN_READER_PASSWORD` | Пароль роли `gendesign_reader` (ETL #976, `ops/db-bootstrap/set_gendesign_reader_password.sql`) | **E** |
| `YANDEX_GEOCODER_API_KEY` | Yandex Geocoder (25k req/day) | **D** |
| `DADATA_API_TOKEN` / `DADATA_API_SECRET` | DaData `/clean/address` enrichment | **D** | | `DADATA_API_TOKEN` / `DADATA_API_SECRET` | DaData `/clean/address` enrichment | **D** |
| `SCRAPER_PROXY_URL` (+ legacy `AVITO_PROXY_URL`, `CIAN_PROXY_URL`, `YANDEX_PROXY_URL` и их `*_ROTATE_URL`) | Мобильный прокси для скраперов (содержит user:pass в URL) | **G** (proxy creds) | | `SCRAPER_PROXY_URL` (+ legacy `AVITO_PROXY_URL`, `CIAN_PROXY_URL`, `YANDEX_PROXY_URL` и их `*_ROTATE_URL`) | Мобильный прокси для скраперов (содержит user:pass в URL) | **G** (proxy creds) |
| `CIAN_LOGIN_EMAIL` / `CIAN_LOGIN_PASSWORD` | Cian browser auto-login (#639, Variant B) | **D** | | `CIAN_LOGIN_EMAIL` / `CIAN_LOGIN_PASSWORD` | Cian browser auto-login (#639, Variant B) | **D** |
@ -146,14 +146,14 @@ bcrypt-хеши — односторонние, не plaintext-секреты,
3. Frontend: обновить `GLITCHTIP_FRONTEND_DSN` (build-arg `NEXT_PUBLIC_GLITCHTIP_DSN`) → требует **rebuild frontend образа** (запекается на build-time) → `workflow_dispatch` или push в `frontend/**`. 3. Frontend: обновить `GLITCHTIP_FRONTEND_DSN` (build-arg `NEXT_PUBLIC_GLITCHTIP_DSN`) → требует **rebuild frontend образа** (запекается на build-time) → `workflow_dispatch` или push в `frontend/**`.
4. Vault entry. 4. Vault entry.
### Класс D — 3rd-party API keys (`OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `YANDEX_GEOCODER_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*`) ### Класс D — 3rd-party API keys (`OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*`)
**Downtime:** нет (фичи gracefully degrade при пустом ключе — см. config-комментарии). **Downtime:** нет (фичи gracefully degrade при пустом ключе — см. config-комментарии).
1. Перевыпустить/ротировать ключ в кабинете провайдера (Объектив / OpenAI / Yandex Cloud / DaData / Cian-аккаунт). 1. Перевыпустить/ротировать ключ в кабинете провайдера (Объектив / OpenAI / DaData / Cian-аккаунт).
2. Где живёт: 2. Где живёт:
- `OBJECTIVE_API_KEY`, `OPENAI_API_KEY` — Forgejo secret → deploy пишет в main `.env.runtime`. - `OBJECTIVE_API_KEY`, `OPENAI_API_KEY` — Forgejo secret → deploy пишет в main `.env.runtime`.
- `YANDEX_GEOCODER_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*` — tradein `.env.runtime` (правится **на VPS вручную**, не из CI). - `DADATA_*`, `CIAN_LOGIN_*` — tradein `.env.runtime` (правится **на VPS вручную**, не из CI).
3. Обновить значение `sed`-ом (НЕ перезапись файла) и `up -d --force-recreate --no-deps backend worker beat` (main) / `... backend scraper` (tradein). 3. Обновить значение `sed`-ом (НЕ перезапись файла) и `up -d --force-recreate --no-deps backend worker beat` (main) / `... backend scraper` (tradein).
4. Vault entry. 4. Vault entry.

View file

@ -0,0 +1,67 @@
-- Создание БД `auth` — единого хранилища доступов «Меры» и «Птицы» (идемпотентно).
--
-- Applied by .forgejo/workflows/deploy.yml ПЕРЕД миграциями data/sql/auth/*.sql:
-- docker compose ... exec -T postgres psql -U "$POSTGRES_USER" -d postgres \
-- -v ON_ERROR_STOP=on < ops/db-bootstrap/create_auth_db.sql
-- Подключение обязательно к БД `postgres`: нельзя создать базу, находясь в ней самой.
--
-- ПОЧЕМУ ЭТО НЕ МИГРАЦИЯ:
-- CREATE DATABASE запрещён внутри транзакционного блока, а .claude/rules/sql.md требует
-- от каждого файла в data/sql обёртки BEGIN/COMMIT. Плюс миграции `auth` по определению
-- выполняются уже ВНУТРИ БД `auth` — то есть создать её собой они не могут. Отсюда
-- отдельный bootstrap-шаг, по образцу scripts/bootstrap_glitchtip.sh (там так же
-- заводится вторая БД на этом же сервере).
--
-- ПОЧЕМУ \gexec, А НЕ DO-БЛОК:
-- DO-блок — это функция, она выполняется внутри транзакции, значит CREATE DATABASE в ней
-- недопустим. \gexec строит текст команды на стороне клиента и отправляет её отдельным
-- стейтментом. Если WHERE NOT EXISTS отфильтровал строку, \gexec не получает ничего и
-- молча ничего не делает — это и даёт идемпотентность без ошибки на повторном прогоне.
-- ON_ERROR_STOP=on распространяется и на команды, выполненные через \gexec.
--
-- ВЛАДЕЛЕЦ БД — $POSTGRES_USER (суперюзер кластера), НЕ auth_app. Владелец объекта имеет на
-- него все права в обход GRANT'ов; если бы БД и таблицы принадлежали прикладной роли,
-- точечные гранты в data/sql/auth/002_auth_app_role.sql были бы декорацией. Роль auth_app
-- создаётся миграцией 002 и получает только нужные DML-права.
--
-- TEMPLATE template0 — сознательно, а не template1 (шаблон по умолчанию): template0
-- гарантированно пуст и неизменяем, а в template1 любой может доустановить расширения или
-- объекты, и они молча окажутся в хранилище паролей. На образе postgis:16-3.4 сегодня
-- postgis лежит в template_postgis, а template1 чист (проверено локально на том же образе),
-- но полагаться на это как на инвариант незачем — template0 снимает вопрос навсегда.
-- ENCODING 'UTF8' указан явно (кластер и так UTF8 — вся кириллица gendesign лежит в нём),
-- чтобы кодировка хранилища логинов не зависела от того, с какими аргументами когда-нибудь
-- пересоздадут кластер.
--
-- Пароля в этом файле нет и быть не может: роль создаётся passwordless в миграции 002,
-- пароль ставится отдельным шагом из env (ops/db-bootstrap/set_auth_app_password.sql).
SELECT 'CREATE DATABASE auth TEMPLATE template0 ENCODING ''UTF8'';'
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'auth')
\gexec
-- Единственная преграда для «любая login-роль кластера (glitchtip, tradein_fdw_reader,
-- gendesign_reader) открывает сессию в хранилище паролей»: по умолчанию PostgreSQL выдаёт
-- CONNECT роли PUBLIC при создании БД.
--
-- ДУБЛЬ С data/sql/auth/002_auth_app_role.sql — НАМЕРЕННЫЙ, не копипаста. Инвариант держится
-- в двух местах, потому что у файлов разный жизненный цикл:
-- * здесь (bootstrap) — ради ПЕРЕПРИМЕНЯЕМОСТИ: этот файл гоняется на КАЖДОМ деплое, там же,
-- где создаётся БД. Если `auth` восстановят из дампа или пересоздадут в обход миграций,
-- база появится с дефолтным PUBLIC-CONNECT, а 002 уже числится применённой в
-- _schema_migrations и второй раз не выполнится — REVOKE молча не вернётся.
-- * в 002 — ради САМОДОСТАТОЧНОСТИ миграции: применённая на пустую БД (scratch/staging,
-- ручной psql -f) она обязана давать полный периметр прав без чтения bootstrap-файлов.
-- Удалять любую из двух копий нельзя: каждая закрывает сценарий, который другая не покрывает.
--
-- Выполнимо из подключения к БД `postgres` (мы именно в ней): права на объект DATABASE живут
-- в pg_database.datacl — это общий на кластер каталог, не локальный для БД, в отличие от
-- грантов на таблицы/схемы. Проверено эмпирически на postgis:16-3.4 (REVOKE из сессии в
-- `postgres` по другой БД убирает `=Tc/` из datacl, has_database_privilege('public', …,
-- 'CONNECT') → false). Команда идемпотентна — повторный прогон бесплатен.
REVOKE ALL ON DATABASE auth FROM PUBLIC;
COMMENT ON DATABASE auth IS
'Единое хранилище доступов: «Мера» (trade-in) и «Птица» (Site Finder). Схема — '
'data/sql/auth/*.sql, применяется отдельным циклом миграций в .forgejo/workflows/deploy.yml '
'(таблица _schema_migrations живёт внутри этой же БД).';

View file

@ -0,0 +1,50 @@
-- Set auth_app password from env.
-- Applied by .forgejo/workflows/deploy.yml after auth DB migrations:
-- psql -v pw="$AUTH_DB_PASSWORD" < ops/db-bootstrap/set_auth_app_password.sql
-- Источник переменной: AUTH_DB_PASSWORD из /opt/gendesign/backend/.env.runtime (chmod 600,
-- вне git). Зеркало паттерна ops/db-bootstrap/set_tradein_fdw_password.sql и
-- tradein-mvp/ops/db-bootstrap/set_gendesign_reader_password.sql.
--
-- Idempotent: ALTER если роль существует, NOTICE и продолжает если нет (миграция
-- data/sql/auth/002_auth_app_role.sql могла ещё не примениться на первом деплое).
-- Пароль НИКОГДА не хранится в этом файле или в git — только имя переменной.
--
-- Format %L экранирует пароль как SQL string literal — безопасно даже с кавычками.
--
-- psql variable substitution (:'pw') НЕ интерполируется внутри dollar-quoted блока ($$...$$)
-- — это правило psql, не bug. Поэтому password передаём в DO через сессионный GUC
-- (set_config), который psql интерполирует ВНЕ dollar quote, и читаем внутри через
-- current_setting(). По той же причине файл подаётся через stdin, а НЕ через `psql -c`.
-- Reference incident: deploy 2026-05-24 (post-merge PR #503) упал на
-- "syntax error at or near ':'" именно на этом.
--
-- ⚠️ `set_config(name, value, is_local) -> text` ВОЗВРАЩАЕТ установленное значение. Без
-- `\o /dev/null` psql напечатал бы пароль на stdout → leak в Forgejo Actions deploy logs
-- (retained, visible всем с repo read access). Поэтому оба set_config обёрнуты в
-- `\o /dev/null` / `\o` — глушится только их вывод, NOTICE из DO block (сигнал
-- идемпотентности) остаётся видимым.
--
-- Rollback path: НЕ revert этого файла (вернёт сломанный :'pw' внутри $$). Корректный
-- rollback — unset AUTH_DB_PASSWORD в /opt/gendesign/backend/.env.runtime на VPS, deploy.yml
-- тогда пропустит этот шаг полностью (роль останется без пароля = логин по паролю невозможен).
\o /dev/null
SELECT set_config('app.auth_pw', :'pw', false);
\o
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'auth_app') THEN
EXECUTE format('ALTER ROLE auth_app WITH PASSWORD %L', current_setting('app.auth_pw'));
RAISE NOTICE 'auth_app password set';
ELSE
RAISE NOTICE 'auth_app role missing — migration data/sql/auth/002_auth_app_role.sql not applied yet';
END IF;
END $$;
-- Clear GUC after use (defense-in-depth — не оставляем password в session state даже на
-- short connection). Same \o trick — set_config return value is empty string here, но лишний
-- row в stdout всё равно не нужен.
\o /dev/null
SELECT set_config('app.auth_pw', '', false);
\o

View file

@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска).
#
# Проверяет инварианты периметра (см. корневой Caddyfile):
# 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг).
# 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 —
# политика ПДн, на которую ссылается футер.
# 2. meraocenka.ru/v2 и /trade-in/v2, /trade-in/api/* (B2B-пути) отдают 404 —
# allowlist-by-default, НЕ были случайно проброшены на B2B-дерево
# tradein-frontend. Проверяются обе формы — с basePath-префиксом и без.
# 3. trade-in API (/me, /history, /admin/*) отдаёт 401 анониму — данные B2B
# закрыты. Именно API, а не страница: см. комментарий у проверки ниже.
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (gate Site Finder).
# 5. merahome.ru и meraotsenka.ru отдают 301 на канонический meraocenka.ru.
#
# ВАЖНО: проверки 1 и 2 требуют, чтобы DNS A-record meraocenka.ru → IP VPS
# уже существовал И деплой прошёл (сертификат Let's Encrypt выпущен). Пока
# записи нет — они ожидаемо падают (DNS resolution failure / TLS handshake
# failure), это НЕ регресс периметра gendsgn.ru. Проверки 3 и 4 не зависят от
# DNS нового домена и обязаны быть зелёными всегда.
#
# Запуск вручную:
# bash scripts/smoke-mera-perimeter.sh
# Запуск в CI: .forgejo/workflows/perimeter-smoke.yml (workflow_dispatch + daily cron).
set -uo pipefail
BASE_MERA="${SMOKE_MERA_BASE:-https://meraocenka.ru}"
BASE_MAIN="${SMOKE_MAIN_BASE:-https://gendsgn.ru}"
fail=0
check() {
local desc="$1" url="$2" expected="$3"
local code
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$url" 2>/dev/null)
if [ "$code" = "$expected" ]; then
echo "PASS: $desc ($url -> $code)"
else
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
fail=1
fi
}
echo "== МЕРА B2C perimeter smoke (ЭТАП 1) =="
# 1. Публичный домен отдаёт 200 анонимно.
check "meraocenka.ru root — public 200" "$BASE_MERA/" 200
# 1b. Подстраница лэндинга (политика ПДн) доступна — на неё ссылается футер.
# Путь приезжает с basePath: next/link + basePath=/trade-in эмитит именно
# /trade-in/mera-public/privacy. Если этот handle выпадет из Caddyfile,
# обязательный по 152-ФЗ документ станет недоступен с публичной страницы.
check "meraocenka.ru privacy — public 200" "$BASE_MERA/trade-in/mera-public/privacy" 200
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
# 2b. Те же B2B-пути в basePath-форме — 404. Это регресс-тест именно на
# matcher `handle /trade-in/mera-public/*`: расширь его случайно до
# `/trade-in/*` — и B2B-дерево уедет наружу через публичный домен, а
# проверка 2 (/v2 без префикса) этого НЕ заметит.
check "meraocenka.ru/trade-in/v2 — B2B path must 404" "$BASE_MERA/trade-in/v2" 404
check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API)" "$BASE_MERA/trade-in/api/v1/me" 404
# 2c. Статика проксируется ТОЛЬКО из _next/static/*. Оптимизатор картинок
# /_next/image на лэндинге не нужен (next/image там не импортируется) и
# наружу не открыт — иначе аноним получил бы CPU-нагрузку по запросу.
# Ловит расширение матчера обратно до `/trade-in/_next/*`.
check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404
# 3. B2B-данные trade-in по-прежнему закрыты анониму.
#
# ВНИМАНИЕ: проверять СТРАНИЦУ (/trade-in/v2) больше нельзя — она отдаёт 200.
# После #2555/#2558 trade-in ушёл с Caddy basic_auth на собственный логин:
# страница рендерится анониму, а RouteGuard уже на клиенте уводит на /login.
# Гейт данных переехал на API — там и проверяем, иначе тест зелёный при
# открытом наружу бэкенде.
check "trade-in /api/v1/me — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/me" 401
check "trade-in /api/v1/history — 401 anonymous (чужие оценки)" "$BASE_MAIN/trade-in/api/v1/history" 401
check "trade-in /api/v1/admin/* — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/admin/users" 401
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (auth gate стоит ДО роутинга
# в FastAPI — конкретный путь неважен, любой /api/v1/admin/* перехватывается
# на уровне Caddy до бэкенда).
check "gendsgn.ru/api/v1/admin/* — 401 anonymous" "$BASE_MAIN/api/v1/admin/users" 401
# 5. Домены-спутники ведут на канонический (301, без следования редиректу —
# curl без -L, поэтому ждём именно код редиректа, а не 200 конечной страницы).
# Как и проверки 1-2, требуют DNS + выпущенного сертификата.
check "merahome.ru — 301 to canonical" "https://merahome.ru/" 301
check "meraotsenka.ru — 301 to canonical" "https://meraotsenka.ru/" 301
echo "========================================"
if [ "$fail" -eq 0 ]; then
echo "ALL CHECKS PASSED"
else
echo "SOME CHECKS FAILED — see FAIL lines above"
fi
exit "$fail"

View file

@ -6,12 +6,6 @@ DATABASE_URL=postgresql+psycopg://tradein:tradein@postgres:5432/tradein
CORS_ORIGINS=["http://localhost:8080","http://localhost:3000"] CORS_ORIGINS=["http://localhost:8080","http://localhost:3000"]
ENVIRONMENT=dev ENVIRONMENT=dev
# Yandex Geocoder API key (25k req/day free tier).
# Required for backfill scripts (scripts/backfill_house_coords.py + audit_address_mismatch.py).
# Empty = Nominatim fallback для backend геокодинга; backfill scripts требуют этот ключ
# и упадут с SystemExit без него.
YANDEX_GEOCODER_API_KEY=
# DaData /clean/address — обогащение target адреса в estimate flow (PR Q1). # DaData /clean/address — обогащение target адреса в estimate flow (PR Q1).
# Возвращает canonical-форму, kadastr_num, ФИАС, координаты, ближайшее метро. # Возвращает canonical-форму, kadastr_num, ФИАС, координаты, ближайшее метро.
# Demo tier: 100 req/день — хватит для тестов и low-traffic prod. # Demo tier: 100 req/день — хватит для тестов и low-traffic prod.

View file

@ -57,8 +57,7 @@ import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment
shell-скриптом deploy через `source .env.runtime` перед `compose up`. shell-скриптом deploy через `source .env.runtime` перед `compose up`.
2. `/opt/gendesign/tradein-mvp/backend/.env.runtime` — переменные внутри 2. `/opt/gendesign/tradein-mvp/backend/.env.runtime` — переменные внутри
контейнера `tradein-backend` (читаются через `env_file:` в compose). Сюда контейнера `tradein-backend` (читаются через `env_file:` в compose). Сюда
попадают `YANDEX_GEOCODER_API_KEY`, `COOKIE_ENCRYPTION_KEY` попадают `COOKIE_ENCRYPTION_KEY` и остальные application-секреты внутри
всё, что нужно scripts/backfill_house_coords.py и application code внутри
контейнера. контейнера.
```bash ```bash
@ -66,7 +65,6 @@ import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment
TRADEIN_POSTGRES_USER=tradein TRADEIN_POSTGRES_USER=tradein
TRADEIN_POSTGRES_PASSWORD=<сгенерировать openssl rand -hex 32> TRADEIN_POSTGRES_PASSWORD=<сгенерировать openssl rand -hex 32>
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
YANDEX_GEOCODER_API_KEY= # пусто пока, Nominatim fallback работает
# Encryption key for Cian session cookies (pgp_sym_encrypt / Stage 9 Calculator). # Encryption key for Cian session cookies (pgp_sym_encrypt / Stage 9 Calculator).
# Empty = Valuation Calculator scraper disabled + /api/v1/cookies/upload returns 503. # Empty = Valuation Calculator scraper disabled + /api/v1/cookies/upload returns 503.
@ -77,10 +75,9 @@ COOKIE_ENCRYPTION_KEY=<64-char hex>
```bash ```bash
# /opt/gendesign/tradein-mvp/backend/.env.runtime — те же ключи которые # /opt/gendesign/tradein-mvp/backend/.env.runtime — те же ключи которые
# читаются ВНУТРИ container'а (scripts/backfill_house_coords.py, app/*). # читаются ВНУТРИ container'а (app/*, scripts/*.py).
# Может быть симлинком на ../.env.runtime если переменные совпадают: # Может быть симлинком на ../.env.runtime если переменные совпадают:
# ln -s ../.env.runtime /opt/gendesign/tradein-mvp/backend/.env.runtime # ln -s ../.env.runtime /opt/gendesign/tradein-mvp/backend/.env.runtime
YANDEX_GEOCODER_API_KEY=<key или пусто>
COOKIE_ENCRYPTION_KEY=<64-char hex> COOKIE_ENCRYPTION_KEY=<64-char hex>
GENDESIGN_FDW_PASSWORD=<password или пусто> GENDESIGN_FDW_PASSWORD=<password или пусто>
GLITCHTIP_DSN=<dsn или пусто> GLITCHTIP_DSN=<dsn или пусто>
@ -200,7 +197,6 @@ cat > tradein-mvp/.env.runtime <<EOF
TRADEIN_POSTGRES_USER=tradein TRADEIN_POSTGRES_USER=tradein
TRADEIN_POSTGRES_PASSWORD=$(openssl rand -hex 32) TRADEIN_POSTGRES_PASSWORD=$(openssl rand -hex 32)
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
YANDEX_GEOCODER_API_KEY=
EOF EOF
chmod 600 tradein-mvp/.env.runtime chmod 600 tradein-mvp/.env.runtime

View file

@ -70,8 +70,11 @@ from app.core.db import SessionLocal, get_db
from app.schemas.trade_in import ScheduleConfig, ScheduleConfigUpdate from app.schemas.trade_in import ScheduleConfig, ScheduleConfigUpdate
from app.services import cian_session as cian_session_svc from app.services import cian_session as cian_session_svc
from app.services import domclick_session as domclick_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 import scrape_runs as runs_mod
from app.services.geocoder import geocode from app.services.estimator import LISTINGS_FRESH_DAYS
from app.services.geocoder import geocode, known_city_hint
from app.services.proxy_pool import clear_source_bans
from app.services.scheduler import has_running_run from app.services.scheduler import has_running_run
from app.services.scraper_adapters import ( from app.services.scraper_adapters import (
RealEnrichmentJobs, RealEnrichmentJobs,
@ -197,7 +200,9 @@ async def scrape_around(
for source in payload.sources: for source in payload.sources:
scraper_ctx: AvitoScraper | CianScraper | YandexRealtyScraper scraper_ctx: AvitoScraper | CianScraper | YandexRealtyScraper
if source == "avito": if source == "avito":
scraper_ctx = AvitoScraper(config, delay_provider=get_scraper_delay) scraper_ctx = AvitoScraper(
config, delay_provider=get_scraper_delay, proxy_provider=proxy_provider
)
elif source == "cian": elif source == "cian":
scraper_ctx = CianScraper( scraper_ctx = CianScraper(
config, delay_provider=get_scraper_delay, proxy_provider=proxy_provider config, delay_provider=get_scraper_delay, proxy_provider=proxy_provider
@ -227,6 +232,8 @@ async def scrape_around(
) )
else: else:
lots = await scraper.fetch_around(payload.lat, payload.lon, payload.radius_m) lots = await scraper.fetch_around(payload.lat, payload.lon, payload.radius_m)
# run_id нет и не будет (#2701): ручной admin-скрейп строки в scrape_runs не
# заводит — снимок пишется вне прогона, поле честно остаётся NULL.
inserted, updated = save_listings( inserted, updated = save_listings(
db, lots, matcher=matcher, region_code=DEFAULT_REGION_CODE db, lots, matcher=matcher, region_code=DEFAULT_REGION_CODE
) )
@ -246,7 +253,8 @@ def _clean_address_for_geocode(addr: str) -> str:
"""Чистим address для геокодера. """Чистим address для геокодера.
Cian отдаёт «улица Латвийская, 56/3 · р-н Чкаловский» суффикс ' · ...' Cian отдаёт «улица Латвийская, 56/3 · р-н Чкаловский» суффикс ' · ...'
мешает Nominatim. Берём часть до ' · '. N1 отдаёт «Репина, 75/2 стр.» ок. мешает Nominatim. Берём часть до ' · '. Остальные источники такого суффикса
не используют адрес остаётся без изменений.
""" """
main = addr.split(" · ")[0].strip() main = addr.split(" · ")[0].strip()
return main or addr return main or addr
@ -260,7 +268,7 @@ async def geocode_missing(
) -> dict: ) -> dict:
"""Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address). """Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
target=listings (по умолч.) объявления Cian/N1; target=deals сделки Росреестра. target=listings (по умолч.) объявления; target=deals сделки Росреестра.
Чанк-обработка с бюджетом по времени (~240с, заведомо меньше cron Чанк-обработка с бюджетом по времени (~240с, заведомо меньше cron
`curl -m 320`): за вызов геокодим сколько успеваем, остаток уходит в `curl -m 320`): за вызов геокодим сколько успеваем, остаток уходит в
`remaining`, cron вызывает в цикле пока `remaining` > 0. `remaining`, cron вызывает в цикле пока `remaining` > 0.
@ -269,17 +277,13 @@ async def geocode_missing(
адреса не выбираются повторно 7 дней cron-loop завершается, не зацикливается. адреса не выбираются повторно 7 дней cron-loop завершается, не зацикливается.
geom обновляется автоматически триггером. geom обновляется автоматически триггером.
""" """
# Доп. фильтр для listings — у Avito/N1 встречаются плейсхолдер-адреса. # Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса.
extra_filter = ( extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else ""
"AND address NOT LIKE '%(Avito)%' AND address NOT LIKE '%(N1)%'"
if target == "listings"
else ""
)
rows = ( rows = (
db.execute( db.execute(
text( text(
f""" f"""
SELECT id, address SELECT id, address, city
FROM {target} FROM {target}
WHERE lat IS NULL WHERE lat IS NULL
AND COALESCE(address, '') != '' AND COALESCE(address, '') != ''
@ -313,7 +317,20 @@ async def geocode_missing(
) )
break break
clean = _clean_address_for_geocode(row["address"]) clean = _clean_address_for_geocode(row["address"])
result = await geocode(clean, db) # city (#2594 шаг 2/3) — известен вызывающему коду через listings.city
# (миграция 196) / deals.city (миграция 177), проставляется из контекста
# развёртки/импорта. Прокидываем как city_hint, а не полагаемся на то, что
# геокодер угадает город по тексту address (голый "ул. Победы, 30" без
# города в тексте иначе уходит в Екатеринбург).
#
# known_city_hint (#2603) — гейт по словарю городов области: при
# target="deals" сюда приходит росреестровое поле, в хвосте которого
# лежат не-города («Бессонова», «Билейский рыбопитомник»), а мусорный
# хинт закрывает EKB-локальные тиры и уезжает префиксом в запрос
# провайдеру, т.е. вреднее отсутствия хинта. Общий хелпер, тот же, что у
# scripts/geocode_deals_nominatim.py и tasks/geocode_missing.py.
city = known_city_hint(row.get("city"))
result = await geocode(clean, db, city_hint=city)
if result is None: if result is None:
# Помечаем что пробовали — иначе ретрай на каждом cron. # Помечаем что пробовали — иначе ретрай на каждом cron.
db.execute( db.execute(
@ -1577,8 +1594,22 @@ def update_schedule(
"""UPDATE existing schedule (create если не существует, через INSERT ON CONFLICT).""" """UPDATE existing schedule (create если не существует, через INSERT ON CONFLICT)."""
from app.services.scheduler import compute_next_run_at from app.services.scheduler import compute_next_run_at
# Compute new next_run_at если window изменился — recompute, иначе keep existing # #2674: такт берётся из default_params — ровно как его читает планировщик
next_at = compute_next_run_at(payload.window_start_hour, payload.window_end_hour) # (_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 = ( row = (
db.execute( db.execute(
@ -1592,7 +1623,22 @@ def update_schedule(
window_start_hour = EXCLUDED.window_start_hour, window_start_hour = EXCLUDED.window_start_hour,
window_end_hour = EXCLUDED.window_end_hour, window_end_hour = EXCLUDED.window_end_hour,
default_params = EXCLUDED.default_params, 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() updated_at = NOW()
RETURNING id, source, enabled, window_start_hour, window_end_hour, RETURNING id, source, enabled, window_start_hour, window_end_hour,
default_params, last_run_id, last_run_at, next_run_at, updated_at default_params, last_run_id, last_run_at, next_run_at, updated_at
@ -1605,6 +1651,7 @@ def update_schedule(
"we": payload.window_end_hour, "we": payload.window_end_hour,
"params": json.dumps(payload.default_params, ensure_ascii=False), "params": json.dumps(payload.default_params, ensure_ascii=False),
"next_at": next_at, "next_at": next_at,
"explicit": payload.next_run_at is not None,
}, },
) )
.mappings() .mappings()
@ -2169,16 +2216,33 @@ async def scrape_house_imv_backfill(
) )
# ── Единая scrapers-страница: unified runs + health + rotate-ip (epic) ──────── # ── Единая scrapers-страница: unified runs + health (epic) ───────────────────
# rotate-ip (changeip mobileproxy) удалён #2616 шаг 3 — мёртвая подписка (#2613).
class UnifiedScrapeRunRow(BaseModel): 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 run_id: int
source: str source: str
run_type: str | None = None
status: str status: str
# #2674: чинить фильтр без этого флага было бы регрессом. Пока таблица была
# пуста на всех вкладках, кнопка отмены не рендерилась ни разу; теперь оператор
# видит все 53 источника — и без флага мог бы «отменить» задачу, которая отмену
# не опрашивает (см. scrape_runs.honors_cancel): статус соврал бы, а
# has_running_run перестал бы держать single-run guard.
cancellable: bool = False
# #2686: диагноз для status='banned' — 'platform' (площадка заблокировала) или
# 'infra' (не отдал наш браузерный сайдкар). Без него оператор видит только
# «забанен» и делает вывод «площадка нас палит» на 80% наших же отказов.
ban_kind: str | None = None
params: dict | None = None params: dict | None = None
counters: dict | None = None counters: dict | None = None
total_seen: int | None = None total_seen: int | None = None
@ -2194,6 +2258,12 @@ class UnifiedScrapeRunsResponse(BaseModel):
rows: list[UnifiedScrapeRunRow] rows: list[UnifiedScrapeRunRow]
class ScrapeRunSourcesResponse(BaseModel):
"""Список source'ов для фильтра истории прогонов — из данных, не из литерала."""
sources: list[str]
class BrowserHealth(BaseModel): class BrowserHealth(BaseModel):
reachable: bool reachable: bool
browsers: dict[str, bool] = Field(default_factory=dict) browsers: dict[str, bool] = Field(default_factory=dict)
@ -2213,33 +2283,23 @@ class ScraperHealthResponse(BaseModel):
providers: list[ProviderHealth] providers: list[ProviderHealth]
class RotateIpResponse(BaseModel):
ok: bool
new_ip: str | None = None
reason: str | None = None
_ROTATABLE_SOURCES = ("avito", "cian", "yandex") _ROTATABLE_SOURCES = ("avito", "cian", "yandex")
def _provider_proxy_url(source: str) -> str | None: def _provider_proxy_url(source: str) -> str | None:
"""Effective proxy URL для source (учитывает property-fallback в settings).""" """Effective proxy URL для source (учитывает property-fallback в settings).
#2616 шаг 2: avito/cian/yandex все три сходятся на settings.scraper_proxy_url
(per-provider AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты мёртвая
mobileproxy-подписка, #2613).
"""
return { return {
"avito": settings.avito_proxy_url, "avito": settings.scraper_proxy_url,
"cian": settings.cian_proxy_url, "cian": settings.cian_proxy_url,
"yandex": settings.yandex_proxy_url, "yandex": settings.yandex_proxy_url,
}.get(source) }.get(source)
def _provider_rotate_url(source: str) -> str | None:
"""changeip-URL для source (None → auto-rotate прокси без ручной ротации)."""
return {
"avito": settings.avito_proxy_rotate_url,
"cian": settings.cian_proxy_rotate_url,
"yandex": settings.yandex_proxy_rotate_url,
}.get(source)
def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | None]: def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | None]:
"""Распарсить host/port из proxy URL (схема http(s)://user:pass@host:port).""" """Распарсить host/port из proxy URL (схема http(s)://user:pass@host:port)."""
if not proxy_url: if not proxy_url:
@ -2257,7 +2317,11 @@ def list_scrape_runs_unified(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
source: Annotated[str | None, Query()] = None, source: Annotated[str | None, Query()] = None,
status: Annotated[ 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, ] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50, limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0, offset: Annotated[int, Query(ge=0)] = 0,
@ -2269,7 +2333,7 @@ def list_scrape_runs_unified(
Query: Query:
source опц. фильтр по source (avito_city_sweep / cian_city_sweep / ...). 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. limit default 50, max 200.
offset default 0. offset default 0.
""" """
@ -2284,8 +2348,9 @@ def list_scrape_runs_unified(
UnifiedScrapeRunRow( UnifiedScrapeRunRow(
run_id=r["run_id"], run_id=r["run_id"],
source=r["source"], source=r["source"],
run_type=r.get("run_type"),
status=r["status"], status=r["status"],
cancellable=runs_mod.honors_cancel(str(r["source"])),
ban_kind=r.get("ban_kind"),
params=r.get("params"), params=r.get("params"),
counters=r.get("counters"), counters=r.get("counters"),
total_seen=r.get("total_seen"), total_seen=r.get("total_seen"),
@ -2300,6 +2365,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: async def _probe_browser_health() -> BrowserHealth:
"""GET tradein-browser /health (timeout 5с). reachable=False при ошибке.""" """GET tradein-browser /health (timeout 5с). reachable=False при ошибке."""
url = f"{settings.browser_http_endpoint.rstrip('/')}/health" url = f"{settings.browser_http_endpoint.rstrip('/')}/health"
@ -2338,8 +2418,10 @@ async def scraper_health() -> ScraperHealthResponse:
- fetch_mode: settings.scraper_fetch_mode (curl_cffi / browser). - fetch_mode: settings.scraper_fetch_mode (curl_cffi / browser).
- browser: GET tradein-browser /health (reachable + per-browser ready-флаги). - browser: GET tradein-browser /health (reachable + per-browser ready-флаги).
- providers: для avito/cian/yandex proxy host/port, rotate_supported, - providers: для avito/cian/yandex proxy host/port, rotate_supported
best-effort current_ip (параллельный пробинг через прокси на ipify). (#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с. Все пробинги параллельны (asyncio.gather) и time-boxed суммарно 10с.
""" """
@ -2359,7 +2441,7 @@ async def scraper_health() -> ScraperHealthResponse:
source=source, source=source,
proxy_host=host, proxy_host=host,
proxy_port=port, proxy_port=port,
rotate_supported=bool(_provider_rotate_url(source)), rotate_supported=False,
current_ip=ip_by_source[source], current_ip=ip_by_source[source],
) )
) )
@ -2371,46 +2453,6 @@ async def scraper_health() -> ScraperHealthResponse:
) )
@router.post("/scraper/{source}/rotate-ip", response_model=RotateIpResponse)
async def rotate_proxy_ip(
source: Literal["avito", "cian", "yandex"],
) -> RotateIpResponse:
"""Сменить мобильный exit-IP провайдера через changeip-ссылку (mobileproxy).
Зеркалит логику AvitoScraper._rotate_ip (GET rotate_url + &format=json), но БЕЗ
settle-sleep API сразу возвращает ответ changeip. Если rotate_url не задан
прокси с авто-ротацией (свежий IP на новое соединение), ручная ротация не нужна.
"""
rotate_url = _provider_rotate_url(source)
if not rotate_url:
return RotateIpResponse(ok=False, reason="no rotate url (auto-rotate proxy)")
sep = "&" if "?" in rotate_url else "?"
try:
async with httpx.AsyncClient(timeout=20.0) as client:
resp = await client.get(f"{rotate_url}{sep}format=json")
resp.raise_for_status()
try:
data = resp.json()
except Exception:
data = {}
except Exception:
# НЕ отдавать str(exc) клиенту (аудит-фикс, #security-audit): httpx-исключения
# несут полный request URL, а rotate_url — mobileproxy changeip-ссылка с API-
# ключом провайдера в query-string (?...&proxy_key=...). str(exc) с этим URL в
# HTTP-ответе — прямая утечка секрета вызывающему клиенту. Причина сбоя остаётся
# в логах (exc_info=True) для диагностики; наружу — только нейтральный reason.
logger.warning("rotate-ip: changeip failed source=%s", source, exc_info=True)
return RotateIpResponse(ok=False, reason="changeip request failed")
# changeip отдаёт новый IP в одном из полей (формат провайдер-зависимый).
new_ip = None
if isinstance(data, dict):
new_ip = data.get("new_ip") or data.get("ip") or data.get("proxy_ip")
logger.info("rotate-ip: source=%s new_ip=%s", source, new_ip)
return RotateIpResponse(ok=True, new_ip=str(new_ip) if new_ip else None)
# ── Pacing live-регулятор (GET/PUT /scraper/pacing) ────────────────────────── # ── Pacing live-регулятор (GET/PUT /scraper/pacing) ──────────────────────────
@ -2506,6 +2548,12 @@ async def update_scraper_pacing(
class SourceCoverage(BaseModel): class SourceCoverage(BaseModel):
source: str source: str
active_count: int 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) fields: dict[str, float] # field_name -> fill% (0..100, round 1)
@ -2520,6 +2568,9 @@ class HousesCoverage(BaseModel):
class DataQualityResponse(BaseModel): class DataQualityResponse(BaseModel):
sources: list[SourceCoverage] sources: list[SourceCoverage]
houses: HousesCoverage houses: HousesCoverage
# Порог «не виделись N дней» для stale_count — отдаём в ответе, чтобы UI
# подписывал число, а не хардкодил порог у себя вторым определением.
stale_days: int
# Поля listings для fill%-аудита. Каждый кортеж: (имя_поля, SQL-выражение IS NOT NULL). # Поля listings для fill%-аудита. Каждый кортеж: (имя_поля, SQL-выражение IS NOT NULL).
@ -2549,6 +2600,10 @@ def get_data_quality(
living_area_m2, ceiling_height (cian), ceiling_height_m (avito), metro_stations. living_area_m2, ceiling_height (cian), ceiling_height_m (avito), metro_stations.
houses: total, avito_validated_at%, rating_score%, house_type%. houses: total, avito_validated_at%, rating_score%, house_type%.
house_reviews: общий count. house_reviews: общий count.
#2660: рядом с active_count отдаётся stale_count — сколько из «активных» не
виделись LISTINGS_FRESH_DAYS дней (last_seen_at). Порог отдаётся в ответе
(stale_days), чтобы UI не заводил второе определение.
""" """
# Строим single-pass SELECT для listings полей через FILTER-агрегаты. # Строим single-pass SELECT для listings полей через FILTER-агрегаты.
# Структура: COUNT(*) FILTER (WHERE <expr>) / NULLIF(COUNT(*), 0) * 100 # Структура: COUNT(*) FILTER (WHERE <expr>) / NULLIF(COUNT(*), 0) * 100
@ -2556,10 +2611,17 @@ def get_data_quality(
filter_exprs = ", ".join( filter_exprs = ", ".join(
f"COUNT(*) FILTER (WHERE {expr}) AS f_{name}" for name, expr in _DQ_LISTING_FIELDS 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""" sql_listings = text(f"""
SELECT SELECT
source, source,
COUNT(*) AS active_count, COUNT(*) AS active_count,
COUNT(*) FILTER (
WHERE last_seen_at <= NOW() - (:fresh_days || ' days')::interval
) AS stale_count,
{filter_exprs} {filter_exprs}
FROM listings FROM listings
WHERE is_active = true WHERE is_active = true
@ -2567,7 +2629,7 @@ def get_data_quality(
ORDER BY source 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] = [] sources: list[SourceCoverage] = []
for row in rows: for row in rows:
@ -2581,6 +2643,7 @@ def get_data_quality(
SourceCoverage( SourceCoverage(
source=row["source"], source=row["source"],
active_count=int(row["active_count"]), active_count=int(row["active_count"]),
stale_count=int(row["stale_count"] or 0),
fields=fields, fields=fields,
) )
) )
@ -2608,7 +2671,7 @@ def get_data_quality(
reviews_count=reviews_count, 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) ─────────────────── # ── Proxy pool: хранилище + bulk-загрузка / список (#2161) ───────────────────
@ -2679,6 +2742,52 @@ class ProxyBulkResponse(BaseModel):
updated: int updated: int
class ProxySourceBan(BaseModel):
"""Активный бан узла КОНКРЕТНОЙ площадкой (#2600 п.2, scrape_proxy_source_bans)."""
source: str
banned_until: str
ban_count: int
def _fetch_source_bans(db: Session, proxy_ids: list[int]) -> dict[int, list[ProxySourceBan]]:
"""Активные (banned_until > now()) баны по источникам для указанных узлов.
Без этого оператор видит `enabled=true` и не понимает, почему узел не выдаётся
конкретному источнику (#2600 п.2 — бан теперь по паре «узел × источник», а не
глобальное выключение). Истёкшие строки не показываем: они ни на что не влияют,
живут ещё SOURCE_BAN_PURGE_DAYS только как память об эскалации.
"""
if not proxy_ids:
return {}
rows = (
db.execute(
text(
"""
SELECT proxy_id, source, banned_until, ban_count
FROM scrape_proxy_source_bans
WHERE banned_until > now()
AND proxy_id = ANY(CAST(:ids AS bigint[]))
ORDER BY proxy_id, source
"""
),
{"ids": proxy_ids},
)
.mappings()
.all()
)
bans: dict[int, list[ProxySourceBan]] = {}
for r in rows:
bans.setdefault(int(r["proxy_id"]), []).append(
ProxySourceBan(
source=r["source"],
banned_until=r["banned_until"].isoformat(),
ban_count=r["ban_count"],
)
)
return bans
class ProxyRow(BaseModel): class ProxyRow(BaseModel):
id: int id: int
label: str | None label: str | None
@ -2687,6 +2796,7 @@ class ProxyRow(BaseModel):
provider_affinity: str provider_affinity: str
rotate_url: str | None # маскированный rotate_url: str | None # маскированный
enabled: bool enabled: bool
disabled_reason: str | None # #2610: NULL = не выключен вручную (авто-воскрешаем)
consecutive_fails: int consecutive_fails: int
exit_ip: str | None exit_ip: str | None
latency_ms: int | None latency_ms: int | None
@ -2699,6 +2809,8 @@ class ProxyRow(BaseModel):
expires_at: str | None expires_at: str | None
created_at: str | None created_at: str | None
updated_at: str | None updated_at: str | None
# #2600 п.2: активные баны площадками. Пустой список = узел выдаётся всем источникам.
source_bans: list[ProxySourceBan] = Field(default_factory=list)
@router.post("/proxies/bulk", response_model=ProxyBulkResponse) @router.post("/proxies/bulk", response_model=ProxyBulkResponse)
@ -2711,8 +2823,18 @@ def bulk_upsert_proxies(
Тело: {"proxies": [{"url", "provider_affinity", "kind"?, "rotate_url"?, Тело: {"proxies": [{"url", "provider_affinity", "kind"?, "rotate_url"?,
"label"?, "geo"?, "operator"?}, ...]}. "label"?, "geo"?, "operator"?}, ...]}.
Существующий url DO UPDATE (affinity/kind/rotate_url + enabled=true, Существующий url DO UPDATE (affinity/kind/rotate_url + enabled,
label/geo/operator обновляются если переданы). Новый INSERT. label/geo/operator обновляются если переданы). Новый INSERT (enabled=true,
disabled_reason=NULL новый прокси не может быть "выключен вручную").
enabled на UPDATE-ветке НЕ безусловный (#2610): если у существующей строки
disabled_reason НЕ NULL (оператор снял узел с ротации вручную), bulk-upsert
(например повторный прогон загрузчика с тем же url) не должен тихо вернуть
его в строй тот же класс бага, что чинили в mark_health. enabled=true
ставится, только если disabled_reason IS NULL; сам disabled_reason bulk
не трогает (эта ручка не умеет ни ставить, ни снимать ручной флаг это
PATCH /proxies/{id}, см. patch_proxy).
Валидация provider_affinity/kind по whitelist на уровне Pydantic 422. Валидация provider_affinity/kind по whitelist на уровне Pydantic 422.
Возвращает {inserted, updated}. Дубли по url ВНУТРИ одного запроса Возвращает {inserted, updated}. Дубли по url ВНУТРИ одного запроса
@ -2738,7 +2860,10 @@ def bulk_upsert_proxies(
label = COALESCE(EXCLUDED.label, scrape_proxies.label), label = COALESCE(EXCLUDED.label, scrape_proxies.label),
geo = COALESCE(EXCLUDED.geo, scrape_proxies.geo), geo = COALESCE(EXCLUDED.geo, scrape_proxies.geo),
operator = COALESCE(EXCLUDED.operator, scrape_proxies.operator), operator = COALESCE(EXCLUDED.operator, scrape_proxies.operator),
enabled = true, enabled = CASE
WHEN scrape_proxies.disabled_reason IS NULL THEN true
ELSE scrape_proxies.enabled
END,
updated_at = now() updated_at = now()
RETURNING (xmax = 0) AS was_inserted RETURNING (xmax = 0) AS was_inserted
""" """
@ -2771,6 +2896,9 @@ def list_proxies(
"""Список прокси со статусами. Пароли в url/rotate_url маскируются. """Список прокси со статусами. Пароли в url/rotate_url маскируются.
Фильтры: provider (=provider_affinity), enabled. Без фильтров все. Фильтры: provider (=provider_affinity), enabled. Без фильтров все.
source_bans активные баны узла площадками (#2600 п.2): узел может быть
enabled=true и при этом не выдаваться конкретному источнику.
""" """
clauses: list[str] = [] clauses: list[str] = []
params: dict[str, Any] = {} params: dict[str, Any] = {}
@ -2787,8 +2915,9 @@ def list_proxies(
text( text(
f""" f"""
SELECT id, label, url, kind, provider_affinity, rotate_url, enabled, SELECT id, label, url, kind, provider_affinity, rotate_url, enabled,
consecutive_fails, exit_ip, latency_ms, last_check_at, last_ok_at, disabled_reason, consecutive_fails, exit_ip, latency_ms,
leased_by, leased_at, geo, operator, expires_at, created_at, updated_at last_check_at, last_ok_at, leased_by, leased_at, geo, operator,
expires_at, created_at, updated_at
FROM scrape_proxies FROM scrape_proxies
{where} {where}
ORDER BY provider_affinity, id ORDER BY provider_affinity, id
@ -2804,6 +2933,8 @@ def list_proxies(
def _iso(v: Any) -> str | None: def _iso(v: Any) -> str | None:
return v.isoformat() if v is not None else None return v.isoformat() if v is not None else None
bans = _fetch_source_bans(db, [int(r["id"]) for r in rows])
return [ return [
ProxyRow( ProxyRow(
id=r["id"], id=r["id"],
@ -2813,6 +2944,7 @@ def list_proxies(
provider_affinity=r["provider_affinity"], provider_affinity=r["provider_affinity"],
rotate_url=_mask_proxy_url(r["rotate_url"]), rotate_url=_mask_proxy_url(r["rotate_url"]),
enabled=r["enabled"], enabled=r["enabled"],
disabled_reason=r["disabled_reason"],
consecutive_fails=r["consecutive_fails"], consecutive_fails=r["consecutive_fails"],
exit_ip=r["exit_ip"], exit_ip=r["exit_ip"],
latency_ms=r["latency_ms"], latency_ms=r["latency_ms"],
@ -2825,6 +2957,7 @@ def list_proxies(
expires_at=_iso(r["expires_at"]), expires_at=_iso(r["expires_at"]),
created_at=_iso(r["created_at"]), created_at=_iso(r["created_at"]),
updated_at=_iso(r["updated_at"]), updated_at=_iso(r["updated_at"]),
source_bans=bans.get(int(r["id"]), []),
) )
for r in rows for r in rows
] ]
@ -2832,6 +2965,18 @@ def list_proxies(
class ProxyPatch(BaseModel): class ProxyPatch(BaseModel):
enabled: bool enabled: bool
reason: str | None = Field(
default=None,
max_length=500,
description=(
"Причина ручного выключения (#2610). Используется только когда enabled=false; "
"при отсутствии подставляется дефолтный текст. Игнорируется при enabled=true — "
"включение всегда сбрасывает disabled_reason в NULL."
),
)
_DEFAULT_MANUAL_DISABLE_REASON = "manually disabled via admin API"
@router.patch("/proxies/{proxy_id}", response_model=ProxyRow) @router.patch("/proxies/{proxy_id}", response_model=ProxyRow)
@ -2840,20 +2985,40 @@ def patch_proxy(
payload: ProxyPatch, payload: ProxyPatch,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
) -> ProxyRow: ) -> ProxyRow:
"""Enable/disable одного прокси по id. 404 если не найден.""" """Enable/disable одного прокси по id. 404 если не найден.
#2610: разводит "ручное выключение оператором" от "авто-выключение пулом".
enabled=false disabled_reason ставится (payload.reason либо дефолтный текст)
mark_health(ok=True) больше не воскресит узел молча первой успешной ipify-пробой.
enabled=true disabled_reason ОБЯЗАТЕЛЬНО сбрасывается в NULL иначе узел,
однажды выключенный руками, никогда больше не участвовал бы в авто-восстановлении
(см. proxy_pool.mark_health).
"""
row = ( row = (
db.execute( db.execute(
text( text(
""" """
UPDATE scrape_proxies UPDATE scrape_proxies
SET enabled = :enabled, updated_at = now() SET enabled = :enabled,
disabled_reason = CASE
WHEN CAST(:enabled AS boolean) THEN NULL
ELSE COALESCE(CAST(:reason AS text), disabled_reason,
CAST(:default_reason AS text))
END,
updated_at = now()
WHERE id = :id WHERE id = :id
RETURNING id, label, url, kind, provider_affinity, rotate_url, enabled, RETURNING id, label, url, kind, provider_affinity, rotate_url, enabled,
consecutive_fails, exit_ip, latency_ms, last_check_at, last_ok_at, disabled_reason, consecutive_fails, exit_ip, latency_ms,
leased_by, leased_at, geo, operator, expires_at, created_at, updated_at last_check_at, last_ok_at, leased_by, leased_at, geo, operator,
expires_at, created_at, updated_at
""" """
), ),
{"enabled": payload.enabled, "id": proxy_id}, {
"enabled": payload.enabled,
"reason": payload.reason,
"default_reason": _DEFAULT_MANUAL_DISABLE_REASON,
"id": proxy_id,
},
) )
.mappings() .mappings()
.fetchone() .fetchone()
@ -2861,6 +3026,18 @@ def patch_proxy(
if row is None: if row is None:
raise HTTPException(status_code=404, detail=f"proxy id={proxy_id} not found") raise HTTPException(status_code=404, detail=f"proxy id={proxy_id} not found")
db.commit() db.commit()
if payload.enabled:
# Ручное включение = чистый лист, как и обнуление disabled_reason выше (#2610).
# Иначе узел вернулся бы enabled=true, но по-прежнему невыдаваемым источникам с
# активным баном — и оператор не имел бы способа снять ложный бан (#2600 п.2).
clear_source_bans(db, proxy_id, reason="manual enable via admin API")
if not payload.enabled:
logger.info(
"proxy_pool: proxy id=%d manually disabled via admin API (reason=%r) — "
"auto-revive suspended until re-enabled (#2610)",
proxy_id,
row["disabled_reason"],
)
def _iso(v: Any) -> str | None: def _iso(v: Any) -> str | None:
return v.isoformat() if v is not None else None return v.isoformat() if v is not None else None
@ -2873,6 +3050,7 @@ def patch_proxy(
provider_affinity=row["provider_affinity"], provider_affinity=row["provider_affinity"],
rotate_url=_mask_proxy_url(row["rotate_url"]), rotate_url=_mask_proxy_url(row["rotate_url"]),
enabled=row["enabled"], enabled=row["enabled"],
disabled_reason=row["disabled_reason"],
consecutive_fails=row["consecutive_fails"], consecutive_fails=row["consecutive_fails"],
exit_ip=row["exit_ip"], exit_ip=row["exit_ip"],
latency_ms=row["latency_ms"], latency_ms=row["latency_ms"],
@ -2885,4 +3063,46 @@ def patch_proxy(
expires_at=_iso(row["expires_at"]), expires_at=_iso(row["expires_at"]),
created_at=_iso(row["created_at"]), created_at=_iso(row["created_at"]),
updated_at=_iso(row["updated_at"]), updated_at=_iso(row["updated_at"]),
source_bans=_fetch_source_bans(db, [int(row["id"])]).get(int(row["id"]), []),
)
# ── Proxy pool: ручная ротация exit-IP по proxy_id (#2600 п.5) ───────────────
#
# Раньше отдельно от /scraper/{source}/rotate-ip (env-прокси mobileproxy,
# changeip-ссылка) — тот эндпоинт удалён вместе с мёртвой подпиской (#2616 шаг 3).
# Этот эндпоинт — единственная живая ручная ротация, по proxy_id из пула
# scrape_proxies (сейчас это ASocks-порты с суточным лимитом 3/сутки), см.
# app.services.proxy_rotation.rotate_proxy.
class ProxyRotateResponse(BaseModel):
ok: bool
reason: str | None = None
new_ip: str | None = None
rotations_remaining_today: int
@router.post("/proxies/{proxy_id}/rotate", response_model=ProxyRotateResponse)
async def rotate_pool_proxy(
proxy_id: int,
db: Annotated[Session, Depends(get_db)],
) -> ProxyRotateResponse:
"""Ручная ротация exit-IP одного прокси пула (#2600 п.5).
Делегирует в app.services.proxy_rotation.rotate_proxy читает rotate_url
прокси из scrape_proxies, требует ASOCKS_API_TOKEN (settings.asocks_api_token),
проверяет суточный лимит (3/сутки, scrape_proxy_rotations) ДО обращения к API.
ok=False ожидаемая бизнес-ситуация (нет rotate_url / нет токена / лимит /
провайдер отказал), НЕ HTTPException; reason ВСЕГДА нейтральный, без токена.
ПОКА без автотриггера по бану (issue #2600 п.2: сигнал бана до пула не
доходит страница-заглушка отдаёт 200) только этот ручной вызов.
"""
result = await proxy_rotation_svc.rotate_proxy(db, proxy_id)
return ProxyRotateResponse(
ok=result.ok,
reason=result.reason,
new_ip=result.new_ip,
rotations_remaining_today=result.rotations_remaining_today,
) )

View file

@ -0,0 +1,354 @@
"""POST /api/v1/auth/login + /logout — DB-backed session auth (#2552, эпик #2549).
Переходный механизм, параллельный legacy Caddy trusted-header auth (roles.yaml).
См. `app.core.rbac.rbac_guard` (dual-mode resolver) и `app.services.auth_session`
(session CRUD). Mounted at `/api/v1/auth`; через Caddy `uri strip_prefix /trade-in`
это `/trade-in/api/v1/auth/*` снаружи.
Security:
- Неверные creds (неизвестный username / доступ закрыт / password_hash NULL /
неверный пароль) ОДИНАКОВЫЙ 401 с generic сообщением не раскрываем,
существует ли username (user-enumeration защита).
- Состояние доступа проверяется ТОЛЬКО ПОСЛЕ проверки пароля, и осмысленный
ответ (403 «пробный доступ закончился») получает исключительно тот, кто
пароль уже доказал. Ветвление ДО пароля превратило бы отдельный статус в
оракул существования логина: перебором можно было бы перечислить аккаунты,
не зная ни одного пароля (миграция data/sql/auth/004, WHY-2).
- #2552 post-review Medium 2: `verify_password` ВСЕГДА вызывается ровно
один раз для несуществующего username / NULL password_hash сверяем
против статичного dummy-хеша (`_DUMMY_PASSWORD_HASH`, сгенерирован один
раз на импорте модуля), результат игнорируется. Без этого короткое
замыкание (`user is None сразу 401`) давало наблюдаемую разницу во
времени ответа (~1мс без bcrypt vs ~100-300мс с ним) классический
timing-oracle для user-enumeration, даже при одинаковом detail-сообщении.
- Rate-limit по (username, IP) ЖЁСТЧЕ общего `RateLimitMiddleware`
(`/api/*`), т.к. login типичная brute-force поверхность. Использует
`SlidingWindowLimiter` (тот же примитив, что и общий rate-limit). Ключ
length-prefixed (`len(username):username:ip`) без этого произвольный
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 той же функции.
- Поверх него ГЛОБАЛЬНЫЙ счётчик неудач на ИМЯ, без IP в ключе (#2571):
лимит по паре (username, IP) распределённый перебор обходит целиком, просто
меняя адрес. Превышение порога не блокирует вход, а замедляет ответ
(`_throttle_delay_s`) см. развёрнутое обоснование там же.
- Raw-пароль НИКОГДА не логируется и не попадает в user_events payload
только username/ip/user_agent/path/method и (для неудач) состояние
счётчика попыток: сколько их за окно и какая задержка применена.
"""
from __future__ import annotations
import asyncio
import logging
import secrets
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.password import PasswordVerifyOverloadedError, hash_password, verify_password_bounded
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
from app.services.user_events import schedule_event
logger = logging.getLogger(__name__)
router = APIRouter()
# Отдельный, более узкий бюджет чем общий per-user/per-IP `/api/*` лимит
# (см. app.core.ratelimit.SlidingWindowLimiter docstring — designed именно для
# такого случая). Ключ = username+IP: не даёт распределённому brute-force по
# ОДНОМУ аккаунту с разных IP уйти от лимита целиком (per-IP было бы недостаточно),
# и не блокирует ВЕСЬ IP из-за перебора чужих логинов одним же клиентом.
_LOGIN_LIMITER = SlidingWindowLimiter(
limit=settings.login_rate_limit,
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, когда юзер не найден/деактивирован/без пароля, чтобы
# `verify_password` (доминирующая по времени операция, ~100-300мс) всегда
# отрабатывала полный bcrypt-компар, независимо от того, существует ли аккаунт.
_DUMMY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
_INVALID_CREDENTIALS_DETAIL = "неверный логин или пароль"
# Единственный ответ логина, который НЕ generic 401: пароль верный, но пробный
# период истёк. `code` — машиночитаемый контракт для фронта (текст можно менять,
# ветку по нему — нет). Потребитель: `loginErrorMessage` в
# tradein-mvp/frontend/src/app/login/page.tsx — читает `detail.code` из
# `HTTPError.body` (frontend/src/lib/api.ts отдаёт тело ответа как есть) и
# показывает экран про пробный период вместо generic «Проверьте подключение».
# Меняешь значение здесь — меняй и там.
_ACCESS_EXPIRED_CODE = "access_expired"
_ACCESS_EXPIRED_MESSAGE = "Пробный доступ закончился"
class LoginRequest(BaseModel):
# 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
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))
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,
request: Request,
response: Response,
db: Annotated[Session, Depends(get_identity_db)],
) -> LoginResponse:
ip = _client_ip(request)
user_agent = request.headers.get("user-agent")
rate_key = f"{len(body.username)}:{body.username}:{ip}"
retry_after = _LOGIN_LIMITER.check(rate_key)
if retry_after is not None:
raise HTTPException(
status_code=429,
detail="слишком много попыток входа, попробуйте позже",
headers={"Retry-After": str(int(retry_after) + 1)},
)
user = get_user_by_username(db, body.username)
hash_to_check = (
user["password_hash"]
if user is not None and user["password_hash"] is not None
else _DUMMY_PASSWORD_HASH
)
# ВСЕГДА вызывается — dummy-хеш при отсутствующем юзере/NULL password_hash
# держит время ответа одинаковым независимо от существования аккаунта.
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): слоты проверки заняты, ждать нельзя —
# ждущий держит соединение к БД. Отказ ОДИНАКОВ для любого имени и
# случается ДО сверки, поэтому оракулом существования учётки не служит и
# бюджет неудач по имени не тратит (это не попытка входа: пароль не
# проверялся). Retry-After 1с — порядок времени одной проверки, не окно
# соседнего `_LOGIN_LIMITER`.
logger.warning("login rejected: password verify saturated ip=%s", ip)
raise HTTPException(
status_code=429,
detail="слишком много попыток входа, попробуйте позже",
headers={"Retry-After": "1"},
) from None
# Пароль проверен ВЫШЕ и безусловно — только теперь смотрим на состояние
# доступа. Порядок несущий, а не стилистический: см. модульный docstring.
if user is None or not password_ok:
raise await _reject_invalid_credentials(db, body.username, ip, user_agent)
access_state = user["access_state"]
if access_state is AccessState.TRIAL_EXPIRED:
# Пароль верный, сессия НЕ создаётся. Единственный не-generic ответ:
# аккаунт существует и владелец это уже доказал паролем, так что
# осмысленный текст ничего не раскрывает постороннему.
# В режиме identity_store="tradein" эта ветка недостижима: булев
# is_active даёт только active/disabled (identity_store.to_access_state).
schedule_event(
event_type="login_blocked_expired",
username=user["username"],
ip=ip,
user_agent=user_agent,
path="/api/v1/auth/login",
method="POST",
)
raise HTTPException(
status_code=403,
detail={"code": _ACCESS_EXPIRED_CODE, "message": _ACCESS_EXPIRED_MESSAGE},
)
if not access_state.can_sign_in:
# disabled (и любое нераспознанное состояние — to_access_state fail-closed)
# → ТОТ ЖЕ 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)
response.set_cookie(
key=settings.session_cookie_name,
value=token,
max_age=settings.session_ttl_hours * 3600,
httponly=True,
secure=True,
samesite="lax",
path="/",
)
schedule_event(
event_type="login_success",
username=user["username"],
ip=ip,
user_agent=user_agent,
path="/api/v1/auth/login",
method="POST",
)
return LoginResponse(ok=True)
@router.post("/logout")
async def logout(
request: Request,
response: Response,
db: Annotated[Session, Depends(get_identity_db)],
) -> dict[str, bool]:
token = request.cookies.get(settings.session_cookie_name)
if token:
revoke_session(db, token)
response.delete_cookie(key=settings.session_cookie_name, path="/")
return {"ok": True}

View file

@ -21,14 +21,27 @@ router = APIRouter()
async def lookup( async def lookup(
address: Annotated[str, Query(min_length=3, max_length=500)], address: Annotated[str, Query(min_length=3, max_length=500)],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
city_hint: Annotated[
str | None,
Query(
max_length=100,
description=(
"Город, если известен вызывающему (например выбран пользователем "
"на предыдущем шаге UI). #2576: без него геокодер БОЛЬШЕ НЕ "
"подставляет 'Екатеринбург' молча — ответ может помечаться "
"city_ambiguous=true."
),
),
] = None,
) -> GeocodeResult: ) -> GeocodeResult:
"""Геокодинг адреса → lat/lon. """Геокодинг адреса → lat/lon.
Примеры: Примеры:
/api/v1/geocode/lookup?address=ул.+Малышева+30+Екатеринбург /api/v1/geocode/lookup?address=ул.+Малышева+30+Екатеринбург
/api/v1/geocode/lookup?address=Куйбышева+50+Екатеринбург /api/v1/geocode/lookup?address=Куйбышева+50+Екатеринбург
/api/v1/geocode/lookup?address=Ленина+1&city_hint=Нижний+Тагил
""" """
result = await geocode(address, db) result = await geocode(address, db, city_hint=city_hint)
if result is None: if result is None:
raise HTTPException(status_code=404, detail=f"Address not found: {address}") raise HTTPException(status_code=404, detail=f"Address not found: {address}")
return result return result
@ -55,6 +68,16 @@ async def suggest_addresses(
q: Annotated[str, Query(min_length=2, max_length=200, description="Запрос для автокомплита")], q: Annotated[str, Query(min_length=2, max_length=200, description="Запрос для автокомплита")],
limit: Annotated[int, Query(ge=1, le=15)] = 8, limit: Annotated[int, Query(ge=1, le=15)] = 8,
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment] db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
city_hint: Annotated[
str | None,
Query(
max_length=100,
description=(
"Город, если известен вызывающему (#2576) — без него подсказки "
"БОЛЬШЕ НЕ ограничиваются молчаливо Екатеринбургом."
),
),
] = None,
) -> SuggestResponse: ) -> SuggestResponse:
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик, """Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
остаётся быстрым fast-path). остаётся быстрым fast-path).
@ -66,8 +89,9 @@ async def suggest_addresses(
Пример: Пример:
/api/v1/geocode/suggest?q=Малышева /api/v1/geocode/suggest?q=Малышева
/api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет /api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет
/api/v1/geocode/suggest?q=Ленина+1&city_hint=Нижний+Тагил
""" """
items = await suggest(q, db=db, limit=limit) items = await suggest(q, db=db, limit=limit, city_hint=city_hint)
return SuggestResponse( return SuggestResponse(
items=[ items=[
SuggestItem( SuggestItem(
@ -100,11 +124,11 @@ class ReverseResponse(BaseModel):
precision: str = Field( precision: str = Field(
..., ...,
description=( description=(
"Yandex-style: exact/number/street/range/near/locality/other/cadastral. " "exact/number/street/range/near/locality/other/cadastral. "
"Фронт двигает marker только если exact/number/cadastral." "Фронт двигает marker только если exact/number/cadastral."
), ),
) )
provider: str = Field(..., description="cadastral | yandex | nominatim") provider: str = Field(..., description="cadastral | nominatim")
@router.get("/reverse", response_model=ReverseResponse) @router.get("/reverse", response_model=ReverseResponse)

View file

@ -7,16 +7,31 @@ Mounted at /api/v1/me; через Caddy `uri strip_prefix /trade-in` это ст
Caddy basic_auth пропускает `X-Authenticated-User: <username>` через Caddy basic_auth пропускает `X-Authenticated-User: <username>` через
`header_up` в каждом reverse_proxy. Frontend дёргает /me чтобы понять `header_up` в каждом reverse_proxy. Frontend дёргает /me чтобы понять
кому что показывать. кому что показывать.
#2552: session-first. Валидная DB-session cookie (см. app.services.auth_session)
отдаёт scope из реестра людей (role/display_name/org/email) БЕЗ похода в
roles.yaml. Без cookie (или невалидная/истёкшая) legacy X-Authenticated-User
путь, БЕЗ ИЗМЕНЕНИЙ (regression недопустим существующие тесты держат его
бит-в-бит).
Сессия БД берётся у `identity_store.get_identity_db` (реестр), а не у
`app.core.db.get_db` (продуктовая БД): при `IDENTITY_STORE=auth` люди и сессии
живут в другой БД. В дефолтном режиме это ТОТ ЖЕ объект `Session`, что отдал бы
`get_db`, поведение прода не меняется.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Annotated from typing import Annotated, Any
from fastapi import APIRouter, Header, HTTPException from fastapi import APIRouter, Depends, Header, HTTPException, Request
from sqlalchemy.orm import Session
from app.core.auth import UserScope, get_user_scope from app.core.auth import UserScope, get_user_scope
from app.core.config import settings
from app.services.auth_session import get_db_role_scope, get_session_user
from app.services.identity_store import get_identity_db
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -25,13 +40,45 @@ router = APIRouter()
@router.get("/me") @router.get("/me")
async def me( async def me(
request: Request,
db: Annotated[Session, Depends(get_identity_db)],
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> UserScope: ) -> UserScope | dict[str, Any]:
"""Return the current user's RBAC scope (role + allowed/deny paths).""" """Return the current user's RBAC scope (role + allowed/deny paths).
Return type is a union (не только `UserScope`) `UserScope.role` это
`Literal["admin","pilot","analyst","expired"]` (legacy roles.yaml names),
а DB-роли (реестр: tradein_users.role / auth.users.role)
`"admin"/"manager"/"employee"`. FastAPI
строит response-схему из return-аннотации; жёсткий `UserScope` завернул бы
"employee"/"manager" в ResponseValidationError. Итоговая JSON-форма
ОДИНАКОВАЯ (те же 8 ключей) для обеих веток.
"""
token = request.cookies.get(settings.session_cookie_name)
if token:
try:
session_user = get_session_user(db, token)
except Exception:
logger.exception("me: session lookup failed")
session_user = None
if session_user is not None:
role = session_user["role"]
allowed_paths, deny_paths = get_db_role_scope(role)
return {
"username": session_user["username"],
"role": role,
"allowed_paths": allowed_paths,
"deny_paths": deny_paths,
"brand": None,
"display_name": session_user["display_name"],
"org": session_user["org_name"],
"email": session_user["email"],
}
if not x_authenticated_user: if not x_authenticated_user:
raise HTTPException( raise HTTPException(
status_code=401, status_code=401,
detail="no authenticated user (Caddy basic_auth required)", detail="no authenticated user (valid session required)",
) )
try: try:
return get_user_scope(x_authenticated_user) return get_user_scope(x_authenticated_user)

View file

@ -29,20 +29,48 @@ support-моста (`app.services.tgbot.bridge`, data/sql/186_tg_support.sql).
`username` thread_id для отправки не нужен вообще, поэтому эту БД-операцию `username` thread_id для отправки не нужен вообще, поэтому эту БД-операцию
можно безопасно отложить до после успешного sendMessage. Бонус: неудачная можно безопасно отложить до после успешного sendMessage. Бонус: неудачная
отправка больше не создаёт тред. отправка больше не создаёт тред.
Анонимная ветка (`/support/anon/*`, инцидент 2026-07-31)
-------------------------------------------------------
Ровно те же 4 действия, но БЕЗ авторизации доступны с экрана входа. Причина:
после cutover'а на свою авторизацию (#2558) единственным каналом в поддержку был
чат ЗА логином, а самая частая причина писать в поддержку как раз «не могу
войти». 2026-07-31 «Практика» весь день билась в форму (5 login_failed, 0
успешных) и достучаться из продукта не могла ничем.
Идентичность анонима opaque-токен в httpOnly-куке (`_ANON_COOKIE_NAME`),
тред живёт в тех же `web_support_threads` под ключом `anon:<token>`. Двоеточие
делает коллизию с реальным логином структурно невозможной: `tradein_users`
допускает только `^[A-Za-z0-9._-]{3,64}$` (CHECK из миграции 193 + Pydantic),
двоеточия там быть не может аноним НИКОГДА не попадёт в чужой тред и не
«станет» существующим юзером.
Изоляция тредов та же, что у авторизованной ветки, и по той же причине:
thread_id не принимается снаружи ни в каком виде, тред резолвится
ИСКЛЮЧИТЕЛЬНО из куки. Кука здесь bearer-токен своего треда, поэтому
httpOnly+Secure+SameSite=Lax (как session-cookie) и `token_urlsafe(18)`
(144 бита) вместо чего-то угадываемого.
В Telegram-топик уходит НЕ сам токен, а `anon-<6 hex от sha256(токен)>`
(`_anon_display_id`): оператору нужен стабильный ярлык треда, а не bearer
зеркало топика читают люди и пересылают дальше.
""" """
from __future__ import annotations from __future__ import annotations
import hashlib
import logging import logging
import re
import secrets
from typing import Annotated, Literal from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import settings from app.core.config import settings
from app.core.db import get_db from app.core.db import get_db
from app.core.ratelimit import SlidingWindowLimiter from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.services.tgbot import web_support_storage as storage from app.services.tgbot import web_support_storage as storage
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
from app.services.tgbot.client import TelegramApiError, TelegramClient from app.services.tgbot.client import TelegramApiError, TelegramClient
@ -262,3 +290,204 @@ def mark_support_read(
storage.mark_read(db, thread_id=thread_id) storage.mark_read(db, thread_id=thread_id)
db.commit() db.commit()
return StatusOut() return StatusOut()
# ---------------------------------------------------------------------------
# Анонимная ветка — поддержка без входа (см. блок в докстринге модуля)
# ---------------------------------------------------------------------------
_ANON_COOKIE_NAME = "tradein_support_anon"
# 30 дней: тред должен пережить «напишу вечером — отвечут утром», но не жить вечно.
_ANON_COOKIE_MAX_AGE_S = 30 * 24 * 3600
# Двоеточие → структурная невозможность коллизии с реальным логином (докстринг).
_ANON_THREAD_PREFIX = "anon:"
# Форма того, что МЫ выдаём (`token_urlsafe(18)` → 24 символа из [A-Za-z0-9_-]).
# Кука клиент-контролируема: без этой проверки в ключ треда (а значит в SQL-параметр
# и в лог) уехала бы произвольная строка из браузера. Не матчится — считаем куку
# отсутствующей и выдаём новую, а не пытаемся «починить» присланное.
_ANON_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}\Z")
# Публичная ручка записи в общий Telegram-топик — поверхность для спама, которой у
# авторизованной ветки нет. Два независимых бюджета:
# 1) per-token (`_send_limiter`, 12/мин — тот же объект, ключи не пересекаются:
# анонимные начинаются с "anon:", что невозможно для username);
# 2) per-IP — именно он ловит обход ротацией куки (сбросил куку → новый токен →
# бюджет (1) снова пуст). Окно широкое и щедрое для живого диалога: реальный
# сценарий — «не могу войти, помогите», несколько сообщений подряд.
_ANON_IP_RATE_LIMIT = 10
_ANON_IP_RATE_WINDOW_S = 600.0
_anon_ip_limiter = SlidingWindowLimiter(limit=_ANON_IP_RATE_LIMIT, window_s=_ANON_IP_RATE_WINDOW_S)
def _anon_display_id(token: str) -> str:
"""Стабильный НЕсекретный ярлык треда для оператора — см. докстринг модуля.
sha256, а не префикс токена: префикс это часть bearer'а, а зеркало уходит
в Telegram-топик, который читают люди и пересылают дальше.
"""
return f"anon-{hashlib.sha256(token.encode('utf-8')).hexdigest()[:6]}"
def _read_anon_token(request: Request) -> str | None:
"""Токен из куки, если он валидной формы; иначе None (кука считается отсутствующей)."""
raw = request.cookies.get(_ANON_COOKIE_NAME)
if raw is None or not _ANON_TOKEN_RE.match(raw):
return None
return raw
def _anon_thread_key(token: str) -> str:
return f"{_ANON_THREAD_PREFIX}{token}"
def _set_anon_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=_ANON_COOKIE_NAME,
value=token,
max_age=_ANON_COOKIE_MAX_AGE_S,
httponly=True,
secure=True,
samesite="lax",
path="/",
)
@router.post("/support/anon/messages", response_model=SupportMessageOut)
async def send_anon_support_message(
payload: SupportMessageInput,
request: Request,
response: Response,
db: Annotated[Session, Depends(get_db)],
) -> SupportMessageOut:
"""Сообщение в поддержку БЕЗ входа. Порядок операций — как в авторизованной
ветке (H1 в докстринге модуля): БД трогаем только после успешного sendMessage.
Кука выставляется тоже только на успехе иначе первая же неудачная попытка
(бот не настроен / Telegram лёг) закрепляла бы за посетителем пустой тред.
"""
if not _bot_configured():
raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_TEXT)
token = _read_anon_token(request)
is_new_token = token is None
if token is None:
token = secrets.token_urlsafe(18)
thread_key = _anon_thread_key(token)
ip = _client_ip(request)
# Оба бюджета — non-destructive peek (review L3): неудачная отправка не
# должна стоить посетителю попытки. `.record()` только на успех, ниже.
for retry_after in (_send_limiter.retry_after(thread_key), _anon_ip_limiter.retry_after(ip)):
if retry_after is not None:
raise HTTPException(
status_code=429,
detail="Слишком много сообщений. Попробуйте позже.",
headers={"Retry-After": str(int(retry_after) + 1)},
)
display_id = _anon_display_id(token)
client = TelegramClient(settings.telegram_bot_token)
try:
mirrored = await client.send_message(
chat_id=settings.telegram_support_chat_id,
text=_format_anon_mirror_text(display_id, payload.text),
message_thread_id=settings.telegram_support_topic_id or None,
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
)
except TelegramApiError:
# Ни текст сообщения (ПДн), ни токен (bearer треда) в лог не попадают.
logger.exception(
"web support (anon): не удалось отправить зеркало в топик (%s)", display_id
)
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
_send_limiter.record(thread_key)
_anon_ip_limiter.record(ip)
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
if topic_message_id is None:
logger.warning(
"web support (anon): Telegram sendMessage не вернул message_id (%s) — "
"ответ оператора на это сообщение не будет смаршрутизирован",
display_id,
)
thread_id = storage.get_or_create_thread(db, thread_key)
row = storage.record_inbound(
db,
thread_id=thread_id,
text_body=payload.text,
topic_message_id=topic_message_id,
support_chat_id=settings.telegram_support_chat_id,
)
db.commit()
if is_new_token:
_set_anon_cookie(response, token)
logger.info("web support (anon): message sent %s thread_id=%d", display_id, thread_id)
return SupportMessageOut(**row)
def _format_anon_mirror_text(display_id: str, message_text: str) -> str:
"""Помечает зеркало как пришедшее с сайта ОТ НЕЗАЛОГИНЕННОГО посетителя.
Оператору это ключевой контекст: у такого обращения нет аккаунта, по которому
можно посмотреть историю, и самая вероятная причина написать как раз
невозможность войти (инцидент 2026-07-31).
"""
return f"[С САЙТА · БЕЗ ВХОДА] {display_id}:\n{message_text}"
@router.get("/support/anon/messages", response_model=list[SupportMessageOut])
def list_anon_support_messages(
request: Request,
db: Annotated[Session, Depends(get_db)],
since: Annotated[int, Query(ge=0)] = 0,
) -> list[SupportMessageOut]:
"""Свой тред по куке. Нет куки / нет треда → пустой список, НЕ 401: виджет
поллит эту ручку и до первого сообщения, 401 там был бы ложной ошибкой.
Sync `def` (review M3) см. `list_support_messages`.
"""
token = _read_anon_token(request)
if token is None:
return []
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
if thread_id is None:
return []
rows = storage.list_messages(
db, thread_id=thread_id, since_id=since, limit=_LIST_MESSAGES_LIMIT
)
return [SupportMessageOut(**r) for r in rows]
@router.get("/support/anon/unread", response_model=UnreadOut)
def get_anon_support_unread(
request: Request,
db: Annotated[Session, Depends(get_db)],
) -> UnreadOut:
"""Sync `def` (review M3) — см. `list_support_messages`."""
token = _read_anon_token(request)
if token is None:
return UnreadOut(unread=0)
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
if thread_id is None:
return UnreadOut(unread=0)
return UnreadOut(unread=storage.count_unread(db, thread_id=thread_id))
@router.post("/support/anon/read", response_model=StatusOut)
def mark_anon_support_read(
request: Request,
db: Annotated[Session, Depends(get_db)],
) -> StatusOut:
"""Sync `def` (review M3) — см. `list_support_messages`."""
token = _read_anon_token(request)
if token is None:
return StatusOut()
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
if thread_id is not None:
storage.mark_read(db, thread_id=thread_id)
db.commit()
return StatusOut()

View file

@ -0,0 +1,818 @@
"""Team-management API — CRUD сотрудников, квоты, история (#2554, эпик #2549).
Mounted at `/api/v1/team`; через Caddy `uri strip_prefix /trade-in` это
`/trade-in/api/v1/team/*` снаружи. `app.services.auth_session.DB_ROLE_PATHS`
уже закладывает `/api/v1/team/**` в scope роли `manager` (и `admin` через `/**`)
для `rbac_guard` (см. `app.core.rbac`) этот роутер добавляет ВТОРОЙ,
более узкий барьер именно на identity:
- `current_team_actor` резолвит юзера ТОЛЬКО из session-cookie
(`app.services.auth_session.get_session_user`). Legacy
`X-Authenticated-User` (Caddy trusted-header, dual-mode) НЕ принимается
здесь team-API новый, не участвует в переходном dual-mode auth. Без
валидной cookie 401, даже если `rbac_guard` пропустил запрос по
legacy-заголовку (напр. admin через roles.yaml).
- Роль должна быть `admin` или `manager` иначе 403.
Org-изоляция (главный инвариант фичи): manager видит/меняет ТОЛЬКО своих
employee (`<реестр>.manager_id = actor.user_id`). Чужой/несуществующий
employee_id 404 (НЕ 403) не подтверждаем/не опровергаем существование
чужого сотрудника перед manager'ом. См. `_authorize_employee`.
ДВЕ СЕССИИ БД, и это не дублирование:
- `identity_db` (`Depends(get_identity_db)`) реестр людей: строка сотрудника
и его сессии. При `IDENTITY_STORE=auth` это ДРУГАЯ БД (`auth`).
- `db` (`Depends(get_db)`) продуктовые таблицы «Меры», которые в общий
реестр не переезжают: `account_quota_overrides`, `account_estimate_usage`,
`user_events`, `trade_in_estimates`.
В дефолтном режиме (`IDENTITY_STORE=tradein`) это ОДИН И ТОТ ЖЕ объект `Session`
(см. `identity_store.get_identity_db`), поэтому всё по-прежнему коммитится одной
транзакцией прод не меняется. В режиме `auth` транзакции физически две:
порядок коммитов выбран так, чтобы при сбое второго коммита оставалось менее
вредное состояние (см. комментарии у `db.commit()`), а `db is not identity_db`
рантайм-признак «БД разные».
Гранты роли `auth_app` (data/sql/auth/004, Часть 4) этот роутер соблюдает без
обходов: он ПИШЕТ только `password_hash, display_name, org_name, email,
access_state, updated_at` (ровно column-level GRANT UPDATE), вставляет строку
целиком (табличный GRANT INSERT) и НИКОГДА не пишет `role`/`manager_id`
UPDATE'ом и не делает DELETE по `users`.
DELETE по `sessions` реестра штатный и грантом предусмотрен (data/sql/auth/002,
GRANT DELETE на sessions): блокировка и смена пароля обязаны рвать живые сессии
немедленно, это `revoke_user_sessions` из `app.services.auth_session`, вызываемый
из `update_employee`. То есть периметр DELETE у этого роутера ровно `sessions`
и ничего больше; грант DELETE на sessions не лишний.
Кого именно можно менять через этот роутер (`_MANAGEABLE_ROLES_BY_ACTOR`):
- actor manager только `role='employee'` И только своих (как было).
- actor admin `role IN ('employee','manager')`.
Почему admin'у отдали и менеджеров (инцидент 2026-07-31): после cutover'а на
DB-auth (#2558) аккаунты `kopylov`/`praktika` сидят с `role='manager'`, а этот
роутер жёстко фильтровал `role='employee'` сбросить менеджеру пароль или
заблокировать его было НЕЧЕМ, кроме ручного psql на проде. Роль manager вводилась
как «владелец своей организации», а не как «неприкасаемый аккаунт».
`role='admin'` НЕ входит ни в один набор, и это несущий инвариант, а не
экономия: он один держит невозможность self-lockout'а. Актёр этого роутера —
всегда admin или manager (`current_team_actor`); manager до admin-строки не
дотянется по своей ветке фильтра, а admin не дотянется до admin-строки вообще
в том числе до собственной. Поэтому ни один путь ниже (block, смена пароля +
`revoke_user_sessions`) не может вырубить самого действующего админа или
разжаловать другого. Раздача/отзыв роли admin остаётся операцией уровня
миграции/psql сознательно вне API.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Annotated, Any
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import text
from sqlalchemy.engine import RowMapping
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from sqlalchemy.sql.elements import TextClause
from app.core.auth import get_role
from app.core.config import settings
from app.core.db import get_db
from app.core.password import hash_password
from app.schemas.team import (
EmployeeCreateRequest,
EmployeeHistoryEntry,
EmployeeOut,
EmployeeUpdateRequest,
QuotaStatusOut,
)
from app.services import account_quota
from app.services.auth_session import get_session_user, revoke_user_sessions
from app.services.identity_store import (
AccessState,
IdentitySchema,
access_state_param,
get_identity_db,
identity_schema,
to_access_state,
)
from app.services.user_events import schedule_event
logger = logging.getLogger(__name__)
router = APIRouter()
@dataclass
class TeamActor:
"""Резолвленный из session-cookie актёр team-API — admin или manager."""
user_id: int
username: str
role: str # "admin" | "manager"
async def current_team_actor(
request: Request,
identity_db: Annotated[Session, Depends(get_identity_db)],
) -> TeamActor:
"""Dependency: session-only identity, роль admin|manager, иначе 401/403.
Намеренно НЕ читает `X-Authenticated-User` см. модульный docstring.
Сессия резолвится в БД РЕЕСТРА (см. про две сессии в модульном docstring).
"""
token = request.cookies.get(settings.session_cookie_name)
if not token:
raise HTTPException(status_code=401, detail="valid session required")
try:
session_user = get_session_user(identity_db, token)
except Exception:
logger.exception("team: session lookup failed")
raise HTTPException(status_code=401, detail="valid session required") from None
if session_user is None:
raise HTTPException(status_code=401, detail="valid session required")
role = session_user["role"]
if role not in ("admin", "manager"):
raise HTTPException(status_code=403, detail="admin or manager role required")
return TeamActor(
user_id=session_user["user_id"],
username=session_user["username"],
role=role,
)
def _origin_host_allowed(candidate: str) -> bool:
"""True если scheme://netloc *candidate* совпадает с одним из `settings.cors_origins`.
`cors_origins` уже является источником правды для «какие origin'ы это наш
фронт» (см. CORSMiddleware в app/main.py, ENV CORS_ORIGINS) переиспользуем
его вместо нового хардкода."""
try:
parsed = urlparse(candidate)
except ValueError:
return False
if not parsed.scheme or not parsed.netloc:
return False
origin = f"{parsed.scheme}://{parsed.netloc}"
return origin in settings.cors_origins
def _require_same_origin(request: Request) -> None:
"""CSRF defense-in-depth (issue #2554 DoD) для state-changing team-роутов
(POST/PATCH): `Origin` (или `Referer` как fallback) обязан матчить один из
`settings.cors_origins`, иначе 403.
Оба заголовка отсутствуют ПРОПУСКАЕМ (не 403). Причина: это единственный
надёжный сигнал non-browser клиента в этом стеке curl-смоуки внутри
контейнера (см. `.claude/rules/tradein.md` "Тестировать HTTP только ВНУТРИ
контейнера", `docker exec tradein-backend curl ...`) не шлют ни один из этих
заголовков, а реальный браузер (fetch/XHR/form) ВСЕГДА прикладывает Origin
на unsafe-методах (POST/PATCH) так что "оба отсутствуют" практически
невозможно для настоящего кросс-сайтового CSRF через браузер. Session-cookie
уже стоит на `SameSite=Lax` (см. `app.api.v1.auth.login`) это первый рубеж
против CSRF, Origin-check второй.
"""
candidate = request.headers.get("origin") or request.headers.get("referer")
if candidate is None:
return
if not _origin_host_allowed(candidate):
logger.warning(
"team: Origin/Referer mismatch %r on %s — possible CSRF", candidate, request.url.path
)
raise HTTPException(status_code=403, detail="origin not allowed")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Имена таблицы и колонки состояния доступа приходят из `identity_schema()` —
# фиксированный словарь в `app.services.identity_store`, единственный источник
# этих имён (в SQL-строку не попадает ничего пришедшего снаружи; значения
# по-прежнему биндятся параметрами).
#
# `AS access_state` в КАЖДОМ SELECT'е — не косметика: колонка называется
# по-разному в двух схемах, и без алиаса вызывающий код читал бы то `is_active`,
# то `access_state`, то есть завёл бы то самое второе представление состояния,
# которого быть не должно. Дальше значение всегда идёт через `to_access_state()`.
def _employee_columns(schema: IdentitySchema) -> str:
return (
"id, username, role, display_name, org_name, email, "
f"{schema.access_state_column} AS access_state, manager_id, created_at"
)
# Два статических варианта — НЕ динамическая сборка WHERE (та же мотивация, что
# у `_list_employees_sql` ниже: значения и так биндятся параметрами, но
# статические ветки не провоцируют будущие правки в сторону конкатенации SQL).
# Роль 'admin' не встречается ни в одной ветке — см. модульный docstring.
def _fetch_employee_sql(actor_role: str) -> TextClause:
schema = identity_schema()
cols = _employee_columns(schema)
if actor_role == "admin":
return text(
f"SELECT {cols} FROM {schema.users_table} "
"WHERE id = :id AND role IN ('employee', 'manager')"
)
return text(f"SELECT {cols} FROM {schema.users_table} WHERE id = :id AND role = 'employee'")
def _fetch_employee_row(
identity_db: Session, employee_id: int, actor: TeamActor
) -> RowMapping | None:
"""Строка управляемого юзера в пределах прав *actor* — иначе None (→ 404).
Фильтр по роли делается ЗДЕСЬ, в SQL, а не в `_authorize_employee` ниже:
для manager'а строка менеджера/админа не должна даже доехать до
вызывающего кода. `None` для обоих случаев («нет такого id» и «этот id
тебе не по зубам») тот же принцип, что и 404-вместо-403 в
`_authorize_employee`: не палим существование чужой строки.
"""
sql = _fetch_employee_sql(actor.role)
return identity_db.execute(sql, {"id": employee_id}).mappings().fetchone()
def _authorize_employee(actor: TeamActor, row: RowMapping | None) -> RowMapping:
"""404 (НЕ 403) если сотрудник не найден ИЛИ принадлежит другому manager'у.
Org-изоляция: manager может видеть/менять только `manager_id == actor.user_id`.
404 вместо 403 не палим существование чужого employee_id.
Для admin'а доп. проверки нет: набор строк, до которых он вообще может
дотянуться, уже ограничен ролью в `_fetch_employee_row` (employee|manager,
без admin). У менеджерских строк `manager_id` штатно NULL сравнивать его
с чем-либо здесь нечего.
"""
if row is None:
raise HTTPException(status_code=404, detail="employee not found")
if actor.role == "manager" and row["manager_id"] != actor.user_id:
raise HTTPException(status_code=404, detail="employee not found")
return row
def _upsert_quota_override(
db: Session, username: str, monthly_limit: int, actor_username: str
) -> None:
"""Upsert персонального лимита. Явная установка monthly_limit — сигнал "хочу
numeric-квоту", поэтому ВСЕГДА сбрасывает `unlimited=false` (иначе лимит может
молча не применяться прежний unlimited-грант выигрывал бы у нового limit).
`note` НЕ затирается, если уже задан (`COALESCE`): не перезаписываем
человеко-читаемую причину прошлого гранта (напр. "пилот, грант ...") молча
сгенерированной строкой; note проставляется только при первом upsert записи.
"""
db.execute(
text(
"""
INSERT INTO account_quota_overrides (username, monthly_limit, unlimited, note)
VALUES (:username, CAST(:monthly_limit AS integer), false, :note)
ON CONFLICT (username) DO UPDATE SET
monthly_limit = EXCLUDED.monthly_limit,
unlimited = false,
note = COALESCE(account_quota_overrides.note, EXCLUDED.note),
updated_at = now()
"""
),
{
"username": username,
"monthly_limit": monthly_limit,
"note": f"team-api: set by {actor_username}",
},
)
def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str, Any]]:
"""Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса
вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563).
Семантика ИДЕНТИЧНА `account_quota.is_unlimited`/`user_limit`/`get_status`
(follow-up review PR #2563 п.2 — предыдущая версия расходилась: батч ВСЕГДА
читал `account_quota_overrides.unlimited`, а `is_unlimited` ТОЛЬКО для
username, присутствующего в roles.yaml):
- username НЕ в roles.yaml (`get_role` KeyError) unlimited=False ВСЕГДА,
`account_quota_overrides.unlimited` даже не проверяется (roles.yaml
источник правды "кто вообще может быть unlimited", override "у кого
именно из известных roles.yaml-юзеров"). Сегодня недостижимо для DB-only
сотрудников team-API (`_upsert_quota_override` всегда пишет
`unlimited=false`), но станет достижимым при ручном UPDATE
`account_quota_overrides` или расширении roles.yaml расхождение с
реальным enforcement (`check_and_raise`/`increment`, тот же `is_unlimited`)
было бы честной ложью в списке: "без лимита", который движок всё равно
считает.
- username в roles.yaml и role == admin unlimited=True (без похода в БД).
- username в roles.yaml, role != admin unlimited = override.unlimited.
limit = override.monthly_limit (читается для ЛЮБОГО username, без gate по
roles.yaml так же ведёт себя `account_quota.user_limit`), иначе глобальный
`account_quota.MONTHLY_LIMIT`.
"""
if not usernames:
return {}
overrides = (
db.execute(
text(
"""
SELECT username, monthly_limit, unlimited
FROM account_quota_overrides
WHERE username = ANY(CAST(:usernames AS text[]))
"""
),
{"usernames": usernames},
)
.mappings()
.all()
)
override_by_username = {r["username"]: r for r in overrides}
period = account_quota.current_period()
usage_rows = (
db.execute(
text(
"""
SELECT username, used
FROM account_estimate_usage
WHERE username = ANY(CAST(:usernames AS text[])) AND period_month = :period
"""
),
{"usernames": usernames, "period": period},
)
.mappings()
.all()
)
used_by_username = {r["username"]: r["used"] for r in usage_rows}
result: dict[str, dict[str, Any]] = {}
for username in usernames:
override = override_by_username.get(username)
try:
role = get_role(username)
except KeyError:
role = None
if role == "admin":
unlimited = True
elif role is not None:
unlimited = bool(override is not None and override["unlimited"])
else:
# username не в roles.yaml — is_unlimited() короткое замыкание на
# False, override НЕ проверяется (см. докстринг выше).
unlimited = False
limit = (
int(override["monthly_limit"])
if override is not None and override["monthly_limit"] is not None
else account_quota.MONTHLY_LIMIT
)
used = used_by_username.get(username, 0)
if unlimited:
result[username] = {
"limit": limit,
"used": used,
"remaining": limit,
"unlimited": True,
}
else:
remaining = max(0, limit - max(0, used))
result[username] = {
"limit": limit,
"used": used,
"remaining": remaining,
"unlimited": False,
}
return result
def _employee_out(row: RowMapping, quota: dict[str, Any]) -> EmployeeOut:
"""Строка реестра → ответ API.
`is_active` в контракте API остаётся булевым (форма ответа не меняется
фронт «Команды» не трогаем этим PR), и считается он ровно как «пустят ли
входить»: `trial_expired` показывается как заблокированный. Отдельное
отображение пробного периода в «Команде» вопрос UI-PR'а, не этого.
"""
return EmployeeOut(
id=row["id"],
username=row["username"],
role=row["role"],
display_name=row["display_name"],
org_name=row["org_name"],
email=row["email"],
is_active=to_access_state(row["access_state"]).can_sign_in,
manager_id=row["manager_id"],
created_at=row["created_at"],
quota=QuotaStatusOut(**quota),
)
# ---------------------------------------------------------------------------
# POST /employees
# ---------------------------------------------------------------------------
@router.post("/employees", response_model=EmployeeOut, status_code=201)
async def create_employee(
body: EmployeeCreateRequest,
actor: Annotated[TeamActor, Depends(current_team_actor)],
db: Annotated[Session, Depends(get_db)],
identity_db: Annotated[Session, Depends(get_identity_db)],
_origin_check: Annotated[None, Depends(_require_same_origin)],
) -> EmployeeOut:
"""Создать сотрудника. Роль всегда `employee`.
manager_id: для actor.role == manager принудительно свой id (любое
значение из тела ИГНОРИРУЕТСЯ, org-изоляция инвариант #2554). Для
actor.role == admin опционально из тела, валидируется что указанный id
существует и role='manager' (иначе 422).
`identity_db` реестр (строка сотрудника), `db` продуктовая квота;
в дефолтном режиме это одна и та же сессия и одна транзакция.
"""
schema = identity_schema()
existing = identity_db.execute(
text(f"SELECT id FROM {schema.users_table} WHERE username = :u"),
{"u": body.username},
).fetchone()
if existing is not None:
raise HTTPException(status_code=409, detail="username already exists")
try:
password_hash = hash_password(body.password)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from None
manager_id: int | None
if actor.role == "manager":
# Инвариант org-изоляции: manager не может создать сотрудника под
# чужим manager_id — любое значение из тела игнорируется молча.
manager_id = actor.user_id
else:
manager_id = body.manager_id
if manager_id is not None:
mgr = identity_db.execute(
text(f"SELECT id FROM {schema.users_table} WHERE id = :id AND role = 'manager'"),
{"id": manager_id},
).fetchone()
if mgr is None:
raise HTTPException(
status_code=422,
detail="manager_id does not reference an existing manager",
)
try:
row = (
identity_db.execute(
text(
f"""
INSERT INTO {schema.users_table}
(username, password_hash, role, manager_id, display_name, org_name,
email, {schema.access_state_column})
VALUES
(:username, :password_hash, 'employee', :manager_id, :display_name,
:org_name, :email, :access_state)
RETURNING {_employee_columns(schema)}
"""
),
{
"username": body.username,
"password_hash": password_hash,
"manager_id": manager_id,
"display_name": body.display_name,
"org_name": body.org_name,
"email": body.email,
# Новый сотрудник заводится с открытым доступом — как и
# раньше (`is_active = true` литералом). Литерала здесь
# больше нет: тип колонки разный, знает о нём identity_store.
"access_state": access_state_param(AccessState.ACTIVE),
},
)
.mappings()
.fetchone()
)
except IntegrityError:
# TOCTOU: два конкурентных POST с одинаковым username между pre-check
# выше и этим INSERT — UNIQUE-констрейнт на username в реестре ловит.
identity_db.rollback()
raise HTTPException(status_code=409, detail="username already exists") from None
assert row is not None # RETURNING на успешный INSERT всегда отдаёт строку
if body.monthly_limit is not None:
_upsert_quota_override(db, body.username, body.monthly_limit, actor.username)
# Реестр коммитится ПЕРВЫМ. В дефолтном режиме это один коммит на одну
# транзакцию (identity_db is db) — ровно как было. В режиме `auth` БД две,
# и порядок выбран по цене сбоя: не доехавшая квота — это сотрудник с
# глобальным лимитом (чинится повторным PATCH), тогда как не доехавшая
# строка сотрудника при уже сохранённой квоте — висящий override на
# несуществующего человека.
identity_db.commit()
if db is not identity_db:
db.commit()
schedule_event(
event_type="employee_created",
username=actor.username,
payload={
"employee_id": row["id"],
"employee_username": row["username"],
"manager_id": manager_id,
},
)
quota = account_quota.get_status(db, body.username)
return _employee_out(row, quota)
# ---------------------------------------------------------------------------
# PATCH /employees/{id}
# ---------------------------------------------------------------------------
@router.patch("/employees/{employee_id}", response_model=EmployeeOut)
async def update_employee(
employee_id: int,
body: EmployeeUpdateRequest,
actor: Annotated[TeamActor, Depends(current_team_actor)],
db: Annotated[Session, Depends(get_db)],
identity_db: Annotated[Session, Depends(get_identity_db)],
_origin_check: Annotated[None, Depends(_require_same_origin)],
) -> EmployeeOut:
"""Частичное обновление сотрудника — block/unblock, лимит, профиль, пароль.
manager может патчить ТОЛЬКО своих (manager_id == actor.user_id), иначе 404.
При is_active=False ИЛИ смене пароля (new_password) обязательно revoke всех
сессий (HIGH, deep-review PR #2563): без этого блокировка/reset не подействуют
до истечения TTL текущей сессии сотрудника хуже того, sliding-refresh
(`app.services.auth_session.get_session_user`) продлевает `expires_at` на
КАЖДОМ запросе, так что скомпрометированная/чужая сессия живёт неограниченно
долго, а не «до TTL». `revoke_user_sessions` сам называет смену пароля своим
use-case см. его докстринг.
`is_active` в теле остаётся булевым (контракт API не меняется): true
`active`, false `disabled`. Перевести аккаунт В `trial_expired` этим
роутом нельзя это состояние проставляется миграцией/владельцем, а
выразить его булевым полем нечем; is_active=true на таком аккаунте открывает
доступ (снимает пробное ограничение), is_active=false закрывает жёстко.
"""
row = _fetch_employee_row(identity_db, employee_id, actor)
row = _authorize_employee(actor, row)
new_password_hash: str | None = None
if body.new_password is not None:
try:
new_password_hash = hash_password(body.new_password)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from None
schema = identity_schema()
# Пишутся РОВНО те колонки, на которые у auth_app есть column-level GRANT
# UPDATE (data/sql/auth/004, Часть 4): password_hash, display_name, org_name,
# email, access_state, updated_at. role и manager_id этим роутом не
# обновляются — не «пока не понадобилось», а сознательно: право на их запись
# роли приложения не выдано, и добавлять его в обход миграции нельзя.
#
# CAST обязателен из-за NULL-параметра (поле не пришло в PATCH → COALESCE
# оставляет текущее значение): у нетипизированного NULL Postgres не может
# вывести тип. Имя SQL-типа — из фиксированного словаря identity_store.
identity_db.execute(
text(
f"""
UPDATE {schema.users_table}
SET display_name = COALESCE(:display_name, display_name),
org_name = COALESCE(:org_name, org_name),
email = COALESCE(:email, email),
{schema.access_state_column} = COALESCE(
CAST(:access_state AS {schema.access_state_sql_type}),
{schema.access_state_column}
),
password_hash = COALESCE(:password_hash, password_hash),
updated_at = now()
WHERE id = :id
"""
),
{
"display_name": body.display_name,
"org_name": body.org_name,
"email": body.email,
"access_state": (
None
if body.is_active is None
else access_state_param(
AccessState.ACTIVE if body.is_active else AccessState.DISABLED
)
),
"password_hash": new_password_hash,
"id": employee_id,
},
)
if body.monthly_limit is not None:
_upsert_quota_override(db, row["username"], body.monthly_limit, actor.username)
if body.is_active is False or body.new_password is not None:
# Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions
# коммитит сам (см. app.services.auth_session), это флашит и наш
# предшествующий UPDATE (а в дефолтном режиме, где сессия одна, — и
# quota-upsert). Сессии живут в БД реестра, вместе с пользователем,
# поэтому рвём их через `identity_db`: с чужой сессией здесь блокировка
# и смена пароля перестали бы действовать немедленно. Self-lockout
# невозможен: _fetch_employee_row не отдаёт строки с role='admin'
# НИКОМУ, а manager'у — ещё и только role='employee'; т.е. actor
# (admin|manager) никогда не может патчить сам себя через этот роут.
revoke_user_sessions(identity_db, employee_id)
# Порядок и смысл — как в create_employee: реестр первым, продуктовая БД
# отдельным коммитом только если она физически другая.
identity_db.commit()
if db is not identity_db:
db.commit()
changed_profile_fields = [
f
for f, v in (
("display_name", body.display_name),
("org_name", body.org_name),
("email", body.email),
)
if v is not None
]
if changed_profile_fields:
schedule_event(
event_type="employee_updated",
username=actor.username,
payload={
"employee_id": employee_id,
"employee_username": row["username"],
"fields": changed_profile_fields,
},
)
if body.new_password is not None:
schedule_event(
event_type="employee_password_reset",
username=actor.username,
payload={"employee_id": employee_id, "employee_username": row["username"]},
)
if body.is_active is not None:
schedule_event(
event_type="employee_blocked" if body.is_active is False else "employee_unblocked",
username=actor.username,
payload={"employee_id": employee_id, "employee_username": row["username"]},
)
if body.monthly_limit is not None:
schedule_event(
event_type="quota_changed",
username=actor.username,
payload={
"employee_id": employee_id,
"employee_username": row["username"],
"monthly_limit": body.monthly_limit,
},
)
updated_row = _fetch_employee_row(identity_db, employee_id, actor)
assert updated_row is not None # только что успешно обновили эту же строку
quota = account_quota.get_status(db, updated_row["username"])
return _employee_out(updated_row, quota)
# ---------------------------------------------------------------------------
# GET /employees
# ---------------------------------------------------------------------------
# Два статических варианта WHERE (НЕ f-string/динамическая сборка — Medium/
# "заодно" review PR #2563: значения биндятся параметрами и без того безопасны,
# но статические ветки не провоцируют будущие правки в сторону конкатенации SQL).
#
# ORDER BY created_at DESC, id DESC — тай-брейкер по `id` ОБЯЗАТЕЛЕН (follow-up
# review PR #2563 п.1): `created_at DEFAULT now()` — время ТРАНЗАКЦИИ, а bulk-seed
# (#2557) вставляет много юзеров одной транзакцией → идентичный timestamp у N строк.
# Без тай-брейкера порядок между страницами (LIMIT/OFFSET) на PostgreSQL для
# строк-«близнецов» не гарантирован — сотрудники пропадали/дублировались бы при
# постраничном листании. `id` монотонно растёт (BIGINT IDENTITY) — детерминированный
# tie-break без доп. индекса (созданные позже = бОльший id, тот же порядок что и
# намерение DESC-сортировки по времени).
#
# Admin-ветка (`by_manager=False`): сюда попадают И менеджеры (см. модульный
# docstring — иначе admin не видит в UI строку, которой должен уметь сбросить
# пароль). `role='admin'` по-прежнему невидим и неуправляем. Сортировка по
# (created_at, id) общая для обеих веток — намеренно: seed (#2557) вставил всех
# одной транзакцией, так что группировка «сначала менеджеры» дала бы ложное
# ощущение иерархии там, где её в данных нет; роль показывается колонкой
# (`EmployeeOut.role`).
def _list_employees_sql(*, by_manager: bool) -> TextClause:
schema = identity_schema()
cols = _employee_columns(schema)
tail = "ORDER BY created_at DESC, id DESC LIMIT :limit OFFSET :offset"
if by_manager:
return text(
f"SELECT {cols} FROM {schema.users_table} "
f"WHERE role = 'employee' AND manager_id = :manager_id {tail}"
)
return text(
f"SELECT {cols} FROM {schema.users_table} WHERE role IN ('employee', 'manager') {tail}"
)
@router.get("/employees", response_model=list[EmployeeOut])
async def list_employees(
actor: Annotated[TeamActor, Depends(current_team_actor)],
db: Annotated[Session, Depends(get_db)],
identity_db: Annotated[Session, Depends(get_identity_db)],
manager_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> list[EmployeeOut]:
"""Список сотрудников. manager видит только своих; admin — всех, опц. ?manager_id=.
Сотрудники читаются из реестра (`identity_db`), квоты из продуктовой БД
(`db`): `account_quota_overrides`/`account_estimate_usage` в общий реестр не
переезжают. Квота ОДИН батч-запрос на всю страницу (`_batch_quota_status`),
не N+1 (Medium2, review PR #2563: было 2N+3 SQL-запросов на N сотрудников).
"""
if actor.role == "manager":
rows = (
identity_db.execute(
_list_employees_sql(by_manager=True),
{"manager_id": actor.user_id, "limit": limit, "offset": offset},
)
.mappings()
.all()
)
elif manager_id is not None:
rows = (
identity_db.execute(
_list_employees_sql(by_manager=True),
{"manager_id": manager_id, "limit": limit, "offset": offset},
)
.mappings()
.all()
)
else:
rows = (
identity_db.execute(
_list_employees_sql(by_manager=False), {"limit": limit, "offset": offset}
)
.mappings()
.all()
)
quota_by_username = _batch_quota_status(db, [row["username"] for row in rows])
return [_employee_out(row, quota_by_username[row["username"]]) for row in rows]
# ---------------------------------------------------------------------------
# GET /employees/{id}/history
# ---------------------------------------------------------------------------
@router.get("/employees/{employee_id}/history", response_model=list[EmployeeHistoryEntry])
async def employee_history(
employee_id: int,
actor: Annotated[TeamActor, Depends(current_team_actor)],
db: Annotated[Session, Depends(get_db)],
identity_db: Annotated[Session, Depends(get_identity_db)],
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> list[EmployeeHistoryEntry]:
"""История оценок сотрудника (адрес/дата/результат) — из `user_events`,
LEFT JOIN `trade_in_estimates` за фактическим результатом.
Та же org-проверка что и в PATCH: чужой employee_id 404. Проверка идёт по
реестру (`identity_db`), сама история продуктовые таблицы (`db`).
"""
row = _fetch_employee_row(identity_db, employee_id, actor)
row = _authorize_employee(actor, row)
rows = (
db.execute(
text(
"""
SELECT
CAST(ue.estimate_id AS text) AS estimate_id,
ue.payload ->> 'address' AS address,
ue.payload ->> 'area_m2' AS area_m2,
ue.payload ->> 'rooms' AS rooms,
te.median_price,
te.confidence,
te.n_analogs,
ue.created_at
FROM user_events ue
LEFT JOIN trade_in_estimates te ON te.id = ue.estimate_id
WHERE ue.username = :username AND ue.event_type = 'estimate_request'
ORDER BY ue.created_at DESC
LIMIT :limit OFFSET :offset
"""
),
{"username": row["username"], "limit": limit, "offset": offset},
)
.mappings()
.all()
)
return [EmployeeHistoryEntry.model_validate(dict(r)) for r in rows]

View file

@ -63,7 +63,7 @@ def _assert_estimate_access(created_by: str | None, x_authenticated_user: str |
if not x_authenticated_user: if not x_authenticated_user:
raise HTTPException( raise HTTPException(
status_code=401, status_code=401,
detail="no authenticated user (Caddy basic_auth required)", detail="no authenticated user (valid session required)",
) )
from app.core.auth import get_role from app.core.auth import get_role
@ -702,7 +702,7 @@ def estimate_history(
if not x_authenticated_user: if not x_authenticated_user:
raise HTTPException( raise HTTPException(
status_code=401, status_code=401,
detail="no authenticated user (Caddy basic_auth required)", detail="no authenticated user (valid session required)",
) )
from app.core.auth import get_role from app.core.auth import get_role
@ -764,7 +764,15 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
trade_in_estimates с непустым address; NULL при отсутствии адресов. trade_in_estimates с непустым address; NULL при отсутствии адресов.
NB: это честный best-effort по persisted оценкам, а не hit-rate реального 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 = ( row = (
db.execute( db.execute(
text( text(
@ -774,6 +782,10 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
(SELECT count(*) FROM geocode_cache WHERE expires_at > NOW()) (SELECT count(*) FROM geocode_cache WHERE expires_at > NOW())
AS geocode_cache_fresh, AS geocode_cache_fresh,
(SELECT count(*) FROM listings WHERE is_active) AS listings_active, (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 max(scraped_at) FROM listings) AS listings_last_scraped,
(SELECT count(*) FROM deals) AS deals, (SELECT count(*) FROM deals) AS deals,
(SELECT count(*) FROM gendesign_cad_buildings) AS cad_buildings, (SELECT count(*) FROM gendesign_cad_buildings) AS cad_buildings,
@ -790,12 +802,15 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
WHERE address IS NOT NULL AND address <> '' WHERE address IS NOT NULL AND address <> ''
) t) AS repeat_address_pct ) t) AS repeat_address_pct
""" """
) ),
{"fresh_days": LISTINGS_FRESH_DAYS},
) )
.mappings() .mappings()
.fetchone() .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 ─────────────────────────────── # ── Stage 4a: house info + IMV benchmark для UI ───────────────────────────────
@ -1820,6 +1835,108 @@ def get_street_deals(
# ── Sales vs Listings (PR K — Foundation Phase 1 of issue #564) ────────────── # ── 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% показывался.
#
# ПОТОЛОК ГЕЙТА (знать до следующей правки — здесь НЕ чинится):
# 1. Пейринг по УЛИЦЕ, а не по дому — корень всего перечисленного (ADR #721).
# Гейт по различным объявлениям честный промежуточный шаг, а не решение:
# он убирает числа, которые не являются наблюдением, но оставшиеся всё ещё
# сравнивают сделку в одном доме с объявлением в другом.
# 2. Поштучный discount_pct в таблице пар НЕ гасится, когда сводное число
# погашено (#2672 п.3, фронт): под погашенной медианой видны строки +76%,
# +73% против той же одной цены предложения. Отдельная задача.
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) @router.get("/sales-vs-listings", response_model=SalesVsListingsResponse)
def get_sales_vs_listings( def get_sales_vs_listings(
@ -1845,7 +1962,7 @@ def get_sales_vs_listings(
Per-street view: Росреестр open dataset агрегирует адреса до улицы. Per-street view: Росреестр open dataset агрегирует адреса до улицы.
""" """
from app.services.estimator import _percentile, extract_street_name from app.services.estimator import _percentile, _resolve_target_city, extract_street_name
def _empty(reason_street: str | None = None) -> SalesVsListingsResponse: def _empty(reason_street: str | None = None) -> SalesVsListingsResponse:
return SalesVsListingsResponse( return SalesVsListingsResponse(
@ -1866,6 +1983,15 @@ def get_sales_vs_listings(
logger.warning("sales-vs-listings: cannot extract street from %r", address) logger.warning("sales-vs-listings: cannot extract street from %r", address)
return _empty() return _empty()
# #2583 H4 city-scope (зеркало /street-deals #C1, trade_in.py:1717): без него
# street_pattern матчит одноимённые улицы ЛЮБОГО города обл.66 на ОБЕИХ сторонах
# JOIN (deals.address / listings.address хранят "<Город>, <Улица>") — прод-аудит
# показал 49% явно чужого города + 50% NULL-city listings для проверенных стритов,
# медианный discount_pct уезжал в -60%+ на смеси рынков. target_city резолвится тем
# же словарём (~30 городов обл.66), что и street-deals; None (адрес вне словаря,
# известная H1) → фильтр не применяется на TVF-стороне (см. миграцию 205).
target_city = _resolve_target_city(address)
rows = ( rows = (
db.execute( db.execute(
text( text(
@ -1882,7 +2008,8 @@ def get_sales_vs_listings(
CAST(:rooms AS integer), CAST(:rooms AS integer),
CAST(:window_days AS integer), CAST(:window_days AS integer),
CAST(:area_tolerance AS numeric), CAST(:area_tolerance AS numeric),
CAST(:period_months AS integer) CAST(:period_months AS integer),
CAST(:target_city AS text)
) )
""" """
), ),
@ -1893,6 +2020,7 @@ def get_sales_vs_listings(
"window_days": window_days, "window_days": window_days,
"area_tolerance": area_tolerance, "area_tolerance": area_tolerance,
"period_months": period_months, "period_months": period_months,
"target_city": target_city,
}, },
) )
.mappings() .mappings()
@ -1948,12 +2076,69 @@ def get_sales_vs_listings(
discounts = sorted(p.discount_pct for p in pairs if p.discount_pct is not None) 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 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}
)
# #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 n_distinct_listings < SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS:
# Числа стоят В КОНЦЕ клауз намеренно: «различных объявлений всего 1»
# грамматично при любом значении, «на 1 различных объявлений» — нет.
median_discount_explanation = (
f"Медианный торг не показываем: сделок {len(discounts)}, а разных "
f"объявлений для сравнения всего {n_distinct_listings} — такой процент "
f"говорит о цене одной конкретной квартиры, а не о торге на улице."
)
elif not (
SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT
<= median_discount
<= SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT
):
# Типографский минус (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
logger.info( 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, street_name,
total_deals, total_deals,
deals_with_listings, deals_with_listings,
n_distinct_listings,
linkage_rate_pct, linkage_rate_pct,
f"{median_discount:+.2f}%" if median_discount is not None else "n/a", f"{median_discount:+.2f}%" if median_discount is not None else "n/a",
) )
@ -1967,6 +2152,7 @@ def get_sales_vs_listings(
deals_with_listings=deals_with_listings, deals_with_listings=deals_with_listings,
linkage_rate_pct=linkage_rate_pct, linkage_rate_pct=linkage_rate_pct,
median_discount_pct=median_discount, median_discount_pct=median_discount,
median_discount_explanation=median_discount_explanation,
# street_sales_vs_listings матчит по УЛИЦЕ (не по дому, #721 ADR) → # street_sales_vs_listings матчит по УЛИЦЕ (не по дому, #721 ADR) →
# даже при deals_with_listings>0 это street-level, не house. house_linked НЕ emit'им. # даже при deals_with_listings>0 это street-level, не house. house_linked НЕ emit'им.
data_quality="street_only" if total_deals > 0 else "no_data", data_quality="street_only" if total_deals > 0 else "no_data",

View file

@ -0,0 +1,162 @@
"""Engine + session-factory для БД `auth` — общего реестра людей (эпик «единый вход»).
Отдельный модуль, а не ещё пара строк в `app.core.db`, ровно по одной причине:
`app.core.db` создаёт engine НА ИМПОРТЕ (`create_engine(settings.database_url)` в
теле модуля). Сделай мы так же для БД `auth` приложение начало бы падать на
старте везде, где реестр не сконфигурирован, а не сконфигурирован он сейчас
ВЕЗДЕ: на проде роль `auth_app` ещё без пароля, в тестах этой БД нет вовсе.
Здесь engine создаётся ЛЕНИВО, при первом реальном обращении.
Контракт ( после мержа прод обязан работать ТОЧНО как сейчас):
* `settings.identity_store == "tradein"` (дефолт) в этот модуль не заходит
никто: `app.services.identity_store` берёт сессию из `app.core.db`. Пустая
конфигурация БД `auth` при этом не ошибка ни на импорте, ни в рантайме; ни
одно соединение с БД `auth` не открывается.
* `settings.identity_store == "auth"` + не сконфигурированный реестр первое
же обращение поднимает `AuthDatabaseNotConfiguredError` с внятным текстом.
Именно исключение, а НЕ тихий откат на tradein-таблицы и не пустой результат:
молчаливая деградация auth-пути означала бы «пользователь не найден» вместо
«конфигурация сломана», то есть массовый отказ входа под видом неверных
паролей либо, в обратную сторону, анонимный доступ.
Сам DSN этот модуль НЕ выбирает и НЕ склеивает берёт готовый у
`settings.resolved_auth_database_url` (явный `AUTH_DATABASE_URL`, иначе сборка из
`AUTH_DB_PASSWORD` + частей хоста/порта/базы/пользователя, иначе пусто).
В DSN пароль роли `auth_app`. Он не логируется и не попадает в текст
исключений НИ В ОДНОЙ ветке этого модуля: сообщения ниже константы, а ошибку
разбора URL от SQLAlchemy (её текст содержит исходную строку) мы перехватываем и
заменяем своей, обрывая цепочку `from None`, чтобы исходник не всплыл в
traceback. Добавляешь сюда `logger`/`raise ... {dsn}` не добавляй.
`create_engine` сам по себе к серверу не ходит (connection pool ленивый), так что
даже после первого обращения реальный коннект открывается только на первом
запросе но ошибку конфигурации мы обязаны отдать раньше, чем это станет
похоже на сетевую проблему.
"""
from __future__ import annotations
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from sqlalchemy import Engine, create_engine
from sqlalchemy.exc import ArgumentError
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings
class AuthDatabaseNotConfiguredError(RuntimeError):
"""`IDENTITY_STORE=auth`, а DSN БД `auth` не задан/не разобрался."""
_NOT_CONFIGURED_MSG = (
"IDENTITY_STORE=auth, но реестр людей (БД `auth`) не сконфигурирован: пусты и "
"AUTH_DB_PASSWORD, и AUTH_DATABASE_URL — подключаться не к чему. Задай в "
".env.runtime AUTH_DB_PASSWORD (пароль роли auth_app; остальные части DSN — "
"AUTH_DB_HOST/AUTH_DB_PORT/AUTH_DB_NAME/AUTH_DB_USER — имеют прод-дефолты), "
"либо целиком AUTH_DATABASE_URL, либо верни IDENTITY_STORE=tradein (старое "
"поведение на tradein_users/tradein_sessions)."
)
# Текст для нечитаемого DSN. БЕЗ подстановки самого DSN — там пароль; исходную
# ошибку SQLAlchemy (она цитирует строку целиком) гасим `from None`.
_MALFORMED_DSN_MSG = (
"DSN БД `auth` не разобрался SQLAlchemy. Проверь AUTH_DATABASE_URL (если задан "
"явно) либо части AUTH_DB_HOST/AUTH_DB_PORT/AUTH_DB_NAME/AUTH_DB_USER. Схема "
"обязана быть postgresql+psycopg:// (psycopg v3). Сам DSN сюда намеренно НЕ "
"подставлен: в нём пароль роли auth_app."
)
# Кеш engine/factory + защита от гонки: rbac_guard резолвит сессию на каждом
# non-public запросе, а uvicorn обслуживает их из нескольких потоков (sync-роуты
# уходят в threadpool). Без лока два одновременных первых запроса создали бы два
# engine — то есть два независимых пула коннектов, один из которых потеряется.
_LOCK = threading.Lock()
_engine: Engine | None = None
_session_factory: sessionmaker[Session] | None = None
def _build() -> tuple[Engine, sessionmaker[Session]]:
"""Создаёт engine + session-factory по текущему DSN. Нет DSN → явная ошибка.
DSN резолвит `settings` (явный AUTH_DATABASE_URL или сборка из AUTH_DB_*)
здесь только «пусто или нет» и создание engine.
"""
dsn = settings.resolved_auth_database_url
if not dsn:
raise AuthDatabaseNotConfiguredError(_NOT_CONFIGURED_MSG)
try:
engine = create_engine(dsn, pool_pre_ping=True, future=True)
except (ArgumentError, ValueError):
# ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до
# `int(port)` и падает с `invalid literal for int() with base 10: 'w'`,
# где 'w' — КУСОК ПАРОЛЯ, съехавший на позицию порта. `from None`
# обязателен: он гасит цепочку, иначе исходная ошибка (а с ней и этот
# кусок) печатается в traceback как «During handling of...».
raise AuthDatabaseNotConfiguredError(_MALFORMED_DSN_MSG) from None
factory = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
return engine, factory
def _ensure_built() -> tuple[Engine, sessionmaker[Session]]:
global _engine, _session_factory
if _engine is not None and _session_factory is not None:
return _engine, _session_factory
with _LOCK:
if _engine is None or _session_factory is None:
_engine, _session_factory = _build()
return _engine, _session_factory
def get_auth_engine() -> Engine:
"""Engine БД `auth` (создаётся при первом вызове).
Raises:
AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни
AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался.
"""
engine, _ = _ensure_built()
return engine
def get_auth_session_factory() -> sessionmaker[Session]:
"""Session-factory БД `auth` (создаётся при первом вызове).
Raises:
AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни
AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался.
"""
_, factory = _ensure_built()
return factory
@contextmanager
def auth_session() -> Iterator[Session]:
"""Сессия к БД `auth`, закрывается на выходе из блока.
Прямой вызов из роутов/сервисов НЕ предполагается ходи через
`app.services.identity_store.identity_session()`, он один знает, какая БД
сейчас является реестром.
"""
factory = get_auth_session_factory()
with factory() as db:
yield db
def reset_auth_db() -> None:
"""Сбрасывает закешированные engine/factory (смена DSN в рантайме, тесты).
Старый engine `dispose()`-ится вне лока: закрытие пула может блокировать, а
держать в это время лок незачем ссылки на него уже сняты.
"""
global _engine, _session_factory
with _LOCK:
stale = _engine
_engine = None
_session_factory = None
if stale is not None:
stale.dispose()

View file

@ -1,10 +1,35 @@
"""Минимальный settings для standalone trade-in MVP.""" """Минимальный settings для standalone trade-in MVP."""
from typing import Literal from typing import Literal
from urllib.parse import quote
from pydantic import Field from pydantic import Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# ── Дефолтные части DSN БД `auth` (общий реестр людей, эпик «единый вход») ──────
# Вынесены константами, потому что используются ДВАЖДЫ: как `Field(default=...)`
# и как запасное значение, если переменная окружения задана пустой строкой
# (`AUTH_DB_HOST=` в .env.runtime не должен давать DSN вида `...@:5432/auth`).
#
# ⚠️ ХОСТ — главная ловушка. Внутри стека «Меры» имя `postgres` резолвится в ЕЁ
# СОБСТВЕННЫЙ контейнер: tradein-mvp/docker-compose.prod.yml объявляет сервис
# `postgres` (container_name `tradein-postgres`, сети `tradein-net` +
# `gendesign_shared`) и собирает им продуктовый DATABASE_URL —
# `postgresql+psycopg://...@postgres:5432/tradein`. БД `auth` живёт НЕ там, а на
# постгресе главного стека: корневой docker-compose.prod.yml вешает своему
# сервису `postgres` в сети `shared` (external, name `gendesign_shared`) алиас
# `gendesign-postgres`. tradein-backend к `gendesign_shared` подписан, поэтому
# `gendesign-postgres:5432` из него резолвится, а `postgres:5432` увело бы в
# чужую (свою же продуктовую) БД — там ни роли auth_app, ни таблиц реестра.
# Порт 5432 — ВНУТРИСЕТЕВОЙ порт контейнера; публикация `127.0.0.1:5432:5432` в
# корневом compose существует только ради SSH-туннеля с хоста и к этому пути
# отношения не имеет.
_AUTH_DB_DEFAULT_HOST = "gendesign-postgres"
_AUTH_DB_DEFAULT_PORT = 5432
_AUTH_DB_DEFAULT_NAME = "auth"
# Роль приложения из data/sql/auth/002_auth_app_role.sql (least privilege).
_AUTH_DB_DEFAULT_USER = "auth_app"
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@ -49,11 +74,226 @@ class Settings(BaseSettings):
default="", validation_alias="TRADEIN_INTERNAL_AUTH_SECRET" default="", validation_alias="TRADEIN_INTERNAL_AUTH_SECRET"
) )
# Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/ # ── #2550: DB-auth foundation (bcrypt password hashing + session cookie) ────
# backfill_house_coords.py + audit_address_mismatch.py + main backend # Подготовительные поля для #2549 (эпик). Enforcement непустого session_secret
# OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F). # (fail-fast при пустом значении в prod) добавится в #2552 — здесь дефолт
yandex_geocoder_api_key: str | None = None # 25K req/day free после регистрации # намеренно пустой, чтобы прод-контейнер не падал на старте до того как
yandex_suggest_key: str | None = None # для frontend autocomplete (proxy через backend) # секрет проставлен в .env.runtime. ENV: SESSION_SECRET.
session_secret: str = Field(default="", validation_alias="SESSION_SECRET")
# Имя cookie для DB-based сессии (отдельно от Caddy basic_auth / trusted-header).
session_cookie_name: str = Field(
default="tradein_session", validation_alias="SESSION_COOKIE_NAME"
)
# TTL сессии в часах. Дефолт 720ч (30 дней).
session_ttl_hours: int = Field(default=720, validation_alias="SESSION_TTL_HOURS")
# "dual" — переходный режим (Caddy trusted-header ИЛИ DB-сессия оба валидны);
# "db_only" — только DB-сессия (Caddy basic_auth убран). Переключение — #2552+.
auth_mode: Literal["dual", "db_only"] = Field(default="dual", validation_alias="AUTH_MODE")
# Rate-limit на /login: не более login_rate_limit попыток за
# login_rate_limit_window_s секунд на ключ (обычно IP или username).
login_rate_limit: int = Field(default=5, validation_alias="LOGIN_RATE_LIMIT")
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) и
# «Птицы» (Site Finder); схема — data/sql/auth/001-004.
#
# ПУСТО ПО УМОЛЧАНИЮ, И ЭТО НЕ ОШИБКА. На проде пароль роли auth_app ещё не
# заведён (переменной AUTH_DATABASE_URL там нет), данные (хеши/роли/живые
# сессии) в `auth` ещё не скопированы. Пока identity_store="tradein" (дефолт)
# к этой БД не обращается ни одна строка кода: engine не создаётся,
# соединение не открывается, пустой DSN на старте ничего не роняет — см.
# app.core.auth_db (ленивое создание engine). ENV: AUTH_DATABASE_URL.
#
# Задавать его РУКАМИ больше не обязательно — см. `resolved_auth_database_url`
# ниже: при пустом AUTH_DATABASE_URL и заданном AUTH_DB_PASSWORD DSN собирается
# из частей. Явное значение, если оно есть, по-прежнему выигрывает.
auth_database_url: str = Field(default="", validation_alias="AUTH_DATABASE_URL")
# ── Части DSN БД `auth` — чтобы пароль жил в ОДНОМ месте ────────────────────
# Пароль роли auth_app уже лежит в .env.runtime отдельной переменной
# AUTH_DB_PASSWORD: её читает .forgejo/workflows/deploy.yml, чтобы выполнить
# ALTER ROLE (ops/db-bootstrap/set_auth_app_password.sql). Требовать вдобавок
# целиковый AUTH_DATABASE_URL значило бы держать ОДИН секрет в ДВУХ местах:
# сменили пароль роли, забыли переписать DSN — и вход ложится молча и целиком
# (аутентификация к БД `auth` отваливается для всех сразу).
#
# ⚠️ ops-нюанс: deploy.yml делает ALTER ROLE, читая AUTH_DB_PASSWORD из
# backend/.env.runtime ГЛАВНОГО стека, а этот контейнер читает
# tradein-mvp/backend/.env.runtime (env_file в tradein-mvp/docker-compose.prod.yml).
# Файлы разные — переменная должна быть в обоих. Зато их значение сравнимо
# глазами, чего нельзя сказать про пароль, замурованный внутрь DSN.
#
# Пусто по умолчанию — как и AUTH_DATABASE_URL: в дефолтном режиме
# IDENTITY_STORE=tradein ничего из этого не читается. ENV: AUTH_DB_PASSWORD.
#
# SecretStr, а не str: это единственное поле-секрет, добавленное здесь, и
# обёртка бесплатно закрывает канал утечки, которого не видно глазами —
# `repr(settings)` и `settings.model_dump()` печатают обычные str-поля
# ДОСЛОВНО. Сегодня их никто не рендерит (grep по app: ни дампа env, ни
# `/debug`; sentry_sdk в app/main.py идёт с include_local_variables=False),
# но появиться такой рендер может в любой момент и тихо — с SecretStr он
# напечатает `SecretStr('**********')`. Значение достаётся ровно в одном
# месте — `.get_secret_value()` в резолвере ниже.
# ⚠️ Соседние секреты (database_url, telegram_bot_token, …) остались str —
# это предсуществующее положение, а не «здесь безопасно, а там нет».
auth_db_password: SecretStr = Field(default=SecretStr(""), validation_alias="AUTH_DB_PASSWORD")
# Остальные части — с дефолтами, верными для прод-стека (см. константы выше).
# Переопределяются через ENV для dev/локального запуска (напр. AUTH_DB_HOST=
# localhost + AUTH_DB_PORT=15432 поверх SSH-туннеля).
# ENV: AUTH_DB_HOST, AUTH_DB_PORT, AUTH_DB_NAME, AUTH_DB_USER.
auth_db_host: str = Field(default=_AUTH_DB_DEFAULT_HOST, validation_alias="AUTH_DB_HOST")
auth_db_port: int = Field(default=_AUTH_DB_DEFAULT_PORT, validation_alias="AUTH_DB_PORT")
auth_db_name: str = Field(default=_AUTH_DB_DEFAULT_NAME, validation_alias="AUTH_DB_NAME")
auth_db_user: str = Field(default=_AUTH_DB_DEFAULT_USER, validation_alias="AUTH_DB_USER")
@field_validator("auth_db_port", mode="before")
@classmethod
def _blank_port_means_default(cls, value: object) -> object:
"""`AUTH_DB_PORT=` (пустая строка) → прод-дефолт, а не падение на импорте.
Симметрия с host/name/user, у которых пустое значение переменной падает
обратно на дефолт в резолвере. Для порта того же добиться нельзя: он
типизирован `int` и валидируется pydantic'ом ДО всякой нашей логики, а
`settings = Settings()` выполняется на уровне модуля то есть
`AUTH_DB_PORT=` в .env.runtime роняло бы ValidationError на импорте
конфига и уводило контейнер в restart-loop. Причём В ЛЮБОМ режиме,
включая дефолтный IDENTITY_STORE=tradein, где к БД `auth` не идёт ни
одного обращения ровно тот инвариант «дефолт не трогаем», который
держит остальной код.
Сценарий не гипотетический: ops копирует блок AUTH_DB_* в .env.runtime и
заполняет только пароль остальные строки остаются пустыми намеренно.
`mode="before"` потому что вмешаться надо ДО приведения к int.
Непустой мусор (`AUTH_DB_PORT=abc`) по-прежнему валится, и правильно:
это опечатка со смыслом, а не «оставил пустым».
"""
if isinstance(value, str) and not value.strip():
return _AUTH_DB_DEFAULT_PORT
return value
@property
def resolved_auth_database_url(self) -> str:
"""DSN БД `auth` — единственный источник правды для `app.core.auth_db`.
Приоритет:
1. `AUTH_DATABASE_URL`, если задан выигрывает всегда. Обратная
совместимость (так настроено «до») плюс аварийный обход: если DSN
понадобился нестандартный (другой хост, sslmode, пул-байпас), его
можно вписать целиком, не трогая код.
2. Иначе, если задан `AUTH_DB_PASSWORD` DSN собирается из частей.
3. Иначе пустая строка, то есть «не сконфигурировано». Это НЕ ошибка
сама по себе: при `IDENTITY_STORE=tradein` (дефолт) сюда не заходит
никто. Ошибку явную, а не тихий фолбэк поднимает `app.core.auth_db`
и только когда реестр реально понадобился.
Возвращаемое значение СОДЕРЖИТ ПАРОЛЬ: не логировать, не класть в текст
исключений, не отдавать наружу (`/health`, `/debug`, метрики).
Пароль экранируется `quote(..., safe="")`: спецсимвол (`@`, `:`, `/`, `?`,
`#`, `%`) внутри пароля иначе порвал бы URL по своей грамматике — `@`
сдвинул бы границу host, `/` открыл бы path. Разбор дал бы либо ошибку,
либо, что хуже, МОЛЧА другой хост/базу. По той же причине экранируется
имя пользователя.
А вот имя БД и хост НЕ экранируются, и это не забывчивость: SQLAlchemy
раскодирует обратно только userinfo (user/password), а path отдаёт как
есть. Прогони мы имя БД через `quote`, в сервер уехало бы литеральное
`c%2Fd` вместо `c/d` (проверено round-trip'ом в тестах). Хосту
%-кодирование тоже только мешает оно поломало бы IPv6-скобки.
"""
explicit = self.auth_database_url.strip()
if explicit:
return explicit
# `.strip()` только для ПРОВЕРКИ «задан ли»: пробельная строка в .env — это
# опечатка, а не пароль. В сам DSN идёт значение КАК ЕСТЬ (не стриппится):
# ведущий/хвостовой пробел может быть частью настоящего пароля.
# Единственная точка распаковки SecretStr во всём коде — см. поле выше.
password = self.auth_db_password.get_secret_value()
if not password.strip():
return ""
user = quote(self.auth_db_user.strip() or _AUTH_DB_DEFAULT_USER, safe="")
secret = quote(password, safe="")
host = self.auth_db_host.strip() or _AUTH_DB_DEFAULT_HOST
port = self.auth_db_port
name = self.auth_db_name.strip() or _AUTH_DB_DEFAULT_NAME
# Схема — ровно та же, что у продуктового DATABASE_URL (psycopg v3;
# `postgresql://` без суффикса увёл бы SQLAlchemy на psycopg2, которого в
# зависимостях нет).
return f"postgresql+psycopg://{user}:{secret}@{host}:{port}/{name}"
# Где живут identity (люди + сессии):
# "tradein" (ДЕФОЛТ) — БД tradein, таблицы tradein_users/tradein_sessions
# (ровно сегодняшний прод, поведение не меняется);
# "auth" — БД auth, таблицы users/sessions (единый реестр).
# Переключать ТОЛЬКО после того, как на проде заведён пароль auth_app и
# перенесены данные. Дефолт = старое поведение: включить новый путь можно
# исключительно явной сменой этого флага. Единственный потребитель —
# app.services.identity_store. ENV: IDENTITY_STORE.
identity_store: Literal["tradein", "auth"] = Field(
default="tradein", validation_alias="IDENTITY_STORE"
)
# для User-Agent в Nominatim (Nominatim Usage Policy) # для User-Agent в Nominatim (Nominatim Usage Policy)
contact_email: str = "erginrajpopxbe@outlook.com" contact_email: str = "erginrajpopxbe@outlook.com"
@ -466,63 +706,67 @@ class Settings(BaseSettings):
"www.domclick.ru", "www.domclick.ru",
} }
# ── Scraper mobile proxy (#806) ────────────────────────────────────────── # ── Scraper mobile proxy (#806, #2616 шаг 2) ─────────────────────────────
# Мобильный прокси (RU, mobileproxy.space) используется ВСЕМИ scraper-сессиями: # Мобильный резидентный прокси (ASocks) используется ВСЕМИ scraper-сессиями:
# Avito (#623) + Cian (#806). Datacenter-IP блокируется обоими сайтами. # Avito (#623) + Cian (#806) + Yandex. Datacenter-IP блокируется всеми тремя.
# Пусто = прямое подключение (dev/staging без прокси). # Пусто = прямое подключение (dev/staging без прокси).
# #
# Приоритет ENV-переменных (precedence): # #2616 шаг 2: per-provider legacy-переменные (AVITO_PROXY_URL/CIAN_PROXY_URL/
# 1. SCRAPER_PROXY_URL — новый общий ENV; когда задан — используется первым. # YANDEX_PROXY_URL и их *_ROTATE_URL, changeip mobileproxy) удалены — указывали
# 2. AVITO_PROXY_URL — legacy ENV; fallback, чтобы prod-серверы с уже # на закрытые аккаунты (407/connection refused, проверено вживую #2613).
# настроенным AVITO_PROXY_URL работали без изменений .env.runtime (#806). # SCRAPER_PROXY_URL — единственный живой источник, общий для всех провайдеров.
# property `scraper_proxy_url` реализует эту логику; используй его везде.
# validation_alias привязывает поле к env SCRAPER_PROXY_URL (без него # validation_alias привязывает поле к env SCRAPER_PROXY_URL (без него
# pydantic-settings читал бы SCRAPER_PROXY_URL_ENV по имени поля — #806 fixup). # pydantic-settings читал бы SCRAPER_PROXY_URL_ENV по имени поля — #806 fixup).
scraper_proxy_url_env: str | None = Field(default=None, validation_alias="SCRAPER_PROXY_URL") scraper_proxy_url_env: str | None = Field(default=None, validation_alias="SCRAPER_PROXY_URL")
avito_proxy_url: str | None = None # ENV: AVITO_PROXY_URL (legacy fallback)
@property @property
def scraper_proxy_url(self) -> str | None: def scraper_proxy_url(self) -> str | None:
"""Единый прокси URL для всех scraper-сессий (Avito + Cian). """Единый прокси URL для всех scraper-сессий (Avito + Cian + Yandex).
Приоритет: SCRAPER_PROXY_URL > AVITO_PROXY_URL > None (прямое подключение). Прямая проекция SCRAPER_PROXY_URL (#2616 шаг 2: legacy AVITO_PROXY_URL
Prod-серверы с существующим AVITO_PROXY_URL работают без изменений env. fallback снят мёртвая mobileproxy-переменная).
""" """
return self.scraper_proxy_url_env or self.avito_proxy_url return self.scraper_proxy_url_env
# changeip-ссылка mobileproxy: GET меняет мобильный IP за ~9с. Дёргается при # ── Ban-recovery budget knobs (changeip-механизм снят #2616 шаг 2) ────────
# детекте бана Avito перед повтором. Пусто = ротация выключена (raise сразу). # Раньше эти поля тюнили retry/settle для GET-changeip mobileproxy
# ENV: AVITO_PROXY_ROTATE_URL. # (AVITO_PROXY_ROTATE_URL и т.д., см. историю выше) — сама ссылка удалена
avito_proxy_rotate_url: str | None = None # (закрытый аккаунт), поэтому IP-ротация сейчас всегда no-op (_rotate_ip /
# Сколько раз сменить IP при блоке прежде чем сдаться (на одну страницу). # _rotate_proxy_ip возвращают False без сетевого похода). Поля оставлены:
# #1731: 2→4 — больше шансов восстановиться mid-sweep после проактивной # `*_proxy_max_rotations` продолжают гейтить бюджет попыток в ban-rotation
# ротации на старте (Datadome ban recovery). # state machine (scraper_kit.orchestration.pipeline._try_rotate_within_budget)
# — те же 0 попыток "успеха", что и раньше при мёртвом changeip, просто без
# затрат на HTTP; `avito_proxy_rotate_settle_s` — верхняя граница
# asyncio.wait_for в app.tasks.avito_detail_backfill (страховка от зависания).
# Живая ротация IP — ASOCKS_API_TOKEN / app.services.proxy_rotation (#2611).
avito_proxy_max_rotations: int = 4 avito_proxy_max_rotations: int = 4
# Settle-sleep после changeip-вызова: мобильный модем поднимает новый IP.
# ~9с по умолчанию (эмпирика mobileproxy.space). ENV: AVITO_PROXY_ROTATE_SETTLE_S.
avito_proxy_rotate_settle_s: float = 9.0 avito_proxy_rotate_settle_s: float = 9.0
# #1950: retry-параметры changeip-GET (_rotate_proxy_ip). Вместо одношотного 30s-timeout
# делаем proxy_rotate_attempts попыток по proxy_rotate_attempt_timeout_s каждая.
# Короткий timeout (8s) означает, что зависший changeip не блокирует весь run на 30s.
# ENV: PROXY_ROTATE_ATTEMPT_TIMEOUT_S / PROXY_ROTATE_ATTEMPTS.
proxy_rotate_attempt_timeout_s: float = 8.0 proxy_rotate_attempt_timeout_s: float = 8.0
proxy_rotate_attempts: int = 3 proxy_rotate_attempts: int = 3
# ── ASocks pool-proxy rotation (#2600) ───────────────────────────────────
# Bearer-токен веб-кабинета ASocks для POST .../unlimited-proxy/{portId}/refresh-ip
# (app.services.proxy_rotation). Документированный публичный API (GET
# /v2/proxy/refresh/{portId}?apiKey=) для безлимитных портов не работает —
# подтверждено владельцем аккаунта; единственный рабочий путь — эта ручка
# веб-кабинета с сессионным токеном. Токен разово протухнет (осознанное
# решение владельца) — тогда provider вернёт 401, proxy_rotation.rotate_proxy
# логирует error + шлёт Sentry/GlitchTip alert. Пусто = ротация для всех
# прокси недоступна (rotate_proxy возвращает внятный отказ, не падает).
# ENV: ASOCKS_API_TOKEN. НИКОГДА не логировать / не возвращать в HTTP-ответе.
asocks_api_token: str = Field(default="", validation_alias="ASOCKS_API_TOKEN")
# #1950: если SERP уже сохранил лоты (ins+upd > 0) и упали только detail/houses, # #1950: если SERP уже сохранил лоты (ins+upd > 0) и упали только detail/houses,
# ставим 'done' а не 'banned' — partial intake сохранён, 'banned' лишний. # ставим 'done' а не 'banned' — partial intake сохранён, 'banned' лишний.
# False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED. # False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED.
avito_serp_ok_not_banned: bool = True avito_serp_ok_not_banned: bool = True
# ── Cian dedicated mobile proxy (separate egress from Avito) ────────────── # ── Cian proxy budget (#2616 шаг 2: dedicated CIAN_PROXY_URL/ROTATE_URL снят) ──
# Cian и Avito делят один мобильный IP при общем scraper_proxy_url → конкуренция # Раньше Cian мог получить СВОЙ мобильный прокси отдельно от Avito (контеншен на
# за единственный egress → взаимные таймауты/баны при параллельных прогонах. # общем egress); CIAN_PROXY_URL указывал на закрытый аккаунт — удалён,
# Отдельный прокси для Cian устраняет contention. Если не задан — fallback на # cian_proxy_url ниже теперь = scraper_proxy_url. cian_proxy_max_rotations
# общий scraper_proxy_url (backward-compat). ENV: CIAN_PROXY_URL. # остаётся: гейтит бюджет в ban-rotation state machine наравне с avito/yandex
cian_proxy_url_env: str | None = Field(default=None, validation_alias="CIAN_PROXY_URL") # (см. комментарий у avito_proxy_max_rotations выше — сама ротация no-op).
# changeip-ссылка для Cian-прокси (ротация IP при бане/таймауте). Если не задан —
# fallback на avito_proxy_rotate_url. ENV: CIAN_PROXY_ROTATE_URL.
cian_proxy_rotate_url: str | None = None
# Максимум IP-ротаций для Cian на один sweep-прогон. Аналог avito_proxy_max_rotations.
# ENV: CIAN_PROXY_MAX_ROTATIONS. # ENV: CIAN_PROXY_MAX_ROTATIONS.
cian_proxy_max_rotations: int = 4 cian_proxy_max_rotations: int = 4
@ -542,25 +786,21 @@ class Settings(BaseSettings):
@property @property
def cian_proxy_url(self) -> str | None: def cian_proxy_url(self) -> str | None:
"""Прокси для Cian-скраперов. CIAN_PROXY_URL > scraper_proxy_url (fallback).""" """Прокси для Cian-скраперов (#2616 шаг 2: = scraper_proxy_url, per-provider
return self.cian_proxy_url_env or self.scraper_proxy_url override снят свойство оставлено для scraper_kit.contracts.ScraperConfig
совместимости)."""
return self.scraper_proxy_url
# ── Yandex dedicated mobile proxy (separate egress from Avito/Cian) ──────── # ── Yandex proxy budget (#2616 шаг 2: dedicated YANDEX_PROXY_URL/ROTATE_URL снят) ──
# Отдельный прокси для Yandex устраняет contention при параллельных прогонах. # Симметрично Cian выше — YANDEX_PROXY_URL указывал на закрытый аккаунт.
# Если не задан — fallback на общий scraper_proxy_url (backward-compat). # yandex_proxy_max_rotations остаётся для ban-rotation budget-гейта.
# ENV: YANDEX_PROXY_URL.
yandex_proxy_url_env: str | None = Field(default=None, validation_alias="YANDEX_PROXY_URL")
# changeip-ссылка для Yandex-прокси (ротация IP при капче/таймауте). Если не задан —
# fallback на avito_proxy_rotate_url. ENV: YANDEX_PROXY_ROTATE_URL.
yandex_proxy_rotate_url: str | None = None
# Максимум IP-ротаций для Yandex на один sweep-прогон. Аналог avito_proxy_max_rotations.
# ENV: YANDEX_PROXY_MAX_ROTATIONS. # ENV: YANDEX_PROXY_MAX_ROTATIONS.
yandex_proxy_max_rotations: int = 4 yandex_proxy_max_rotations: int = 4
@property @property
def yandex_proxy_url(self) -> str | None: def yandex_proxy_url(self) -> str | None:
"""Прокси для Yandex-скраперов. YANDEX_PROXY_URL > scraper_proxy_url (fallback).""" """Прокси для Yandex-скраперов (#2616 шаг 2: = scraper_proxy_url)."""
return self.yandex_proxy_url_env or self.scraper_proxy_url return self.scraper_proxy_url
# full_load повторный прогон в день пропускает листинги уже обновлённые сегодня # full_load повторный прогон в день пропускает листинги уже обновлённые сегодня
# (last_seen_at MSK) — экономит upsert + price-trigger churn; False = всегда # (last_seen_at MSK) — экономит upsert + price-trigger churn; False = всегда

View file

@ -8,6 +8,7 @@ This helper:
- applies idempotent CREATE or ALTER mapping on every backend startup so - applies idempotent CREATE or ALTER mapping on every backend startup so
password rotation through .env.runtime is picked up after restart. password rotation through .env.runtime is picked up after restart.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
@ -37,7 +38,7 @@ def ensure_fdw_user_mapping(db: Session) -> None:
logger.warning( logger.warning(
"GENDESIGN_FDW_PASSWORD not set — skipping FDW user mapping " "GENDESIGN_FDW_PASSWORD not set — skipping FDW user mapping "
"(gendesign_cad_buildings queries will fail; cadastral lookups will " "(gendesign_cad_buildings queries will fail; cadastral lookups will "
"fall back to Yandex/Nominatim)" "fall back to Nominatim)"
) )
return return
@ -62,16 +63,20 @@ def ensure_fdw_user_mapping(db: Session) -> None:
).first() ).first()
if exists is None: if exists is None:
db.execute(text( db.execute(
text(
f"CREATE USER MAPPING FOR CURRENT_USER SERVER gendesign_remote " f"CREATE USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
f"OPTIONS (user 'tradein_fdw_reader', password '{password}')" f"OPTIONS (user 'tradein_fdw_reader', password '{password}')"
)) )
)
logger.info("created FDW user mapping for gendesign_remote") logger.info("created FDW user mapping for gendesign_remote")
else: else:
db.execute(text( db.execute(
text(
f"ALTER USER MAPPING FOR CURRENT_USER SERVER gendesign_remote " f"ALTER USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
f"OPTIONS (SET password '{password}')" f"OPTIONS (SET password '{password}')"
)) )
)
logger.info("refreshed FDW user mapping password for gendesign_remote") logger.info("refreshed FDW user mapping password for gendesign_remote")
try: try:

View file

@ -0,0 +1,254 @@
"""Bcrypt password hashing для DB-auth (#2550 — foundation, эпик #2549).
bcrypt тихо обрезает пароли длиннее 72 байт (UTF-8) это silent-truncation
дыра (два разных пароля с общим 72-байтовым префиксом хешируются одинаково).
`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
_BCRYPT_ROUNDS = 12
def hash_password(plain: str) -> str:
"""Хеширует пароль через bcrypt (rounds=12).
Raises:
ValueError: пустой пароль или пароль длиннее 72 байт в UTF-8
(bcrypt тихо обрезает недопустимо, см. модульный docstring).
"""
if not plain:
raise ValueError("password must not be empty")
encoded = plain.encode("utf-8")
if len(encoded) > _BCRYPT_MAX_BYTES:
raise ValueError(
f"password too long: {len(encoded)} bytes (bcrypt max {_BCRYPT_MAX_BYTES})"
)
salt = bcrypt.gensalt(rounds=_BCRYPT_ROUNDS)
hashed = bcrypt.hashpw(encoded, salt)
return hashed.decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
"""Сверяет пароль с bcrypt-хешем.
Пустой пароль или пароль длиннее 72 байт в UTF-8 False (не raise
verify это false/true проверка на этапе логина, а не валидация ввода).
"""
if not plain or not hashed:
return False
encoded = plain.encode("utf-8")
if len(encoded) > _BCRYPT_MAX_BYTES:
return False
try:
return bcrypt.checkpw(encoded, hashed.encode("utf-8"))
except (ValueError, TypeError) as e:
# 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)
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
if _verify_inflight >= settings.login_password_verify_max_inflight:
raise PasswordVerifyOverloadedError
if _verify_inflight_by_key.get(key, 0) >= _per_key_slot_cap():
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)

View file

@ -114,8 +114,13 @@ class SlidingWindowLimiter:
return self._window_s - (now - bucket[0]) return self._window_s - (now - bucket[0])
return None return None
def record(self, key: str) -> None: def record(self, key: str) -> int:
"""Регистрирует одну успешную попытку под *key*.""" """Регистрирует одну попытку под *key* и возвращает их число в окне ПОСЛЕ неё.
Счётчик нужен вызывающим, которым мало булева «за лимитом / нет»: login
(#2571) по нему считает НАСКОЛЬКО перебран порог и растит задержку ответа
пропорционально. Значение можно игнорировать `check()` так и делает.
"""
now = time.monotonic() now = time.monotonic()
bucket = self._hits[key] bucket = self._hits[key]
self._prune(bucket, now) self._prune(bucket, now)
@ -125,6 +130,7 @@ class SlidingWindowLimiter:
if len(self._hits) > 10000: if len(self._hits) > 10000:
for k in [k for k, v in self._hits.items() if not v]: for k in [k for k, v in self._hits.items() if not v]:
del self._hits[k] del self._hits[k]
return len(bucket)
def check(self, key: str) -> float | None: def check(self, key: str) -> float | None:
"""Комбинированная проверка+регистрация (peek+record за один вызов) — """Комбинированная проверка+регистрация (peek+record за один вызов) —

View file

@ -7,12 +7,22 @@ manually". The copy drifted: it was missing the #2213
``X-Internal-Auth-Secret`` defense-in-depth check that the real guard has, ``X-Internal-Auth-Secret`` defense-in-depth check that the real guard has,
so a regression in that check would NOT have failed CI. so a regression in that check would NOT have failed CI.
This module holds the real guard with no DB/lifespan/scheduler side effects This module holds the real guard. Historically it had "no DB/lifespan/scheduler
(only ``app.core.auth`` + ``app.core.config``, both side-effect-free at side effects" beyond ``app.core.auth``/``app.core.config`` (both side-effect-free
import time beyond requiring ``DATABASE_URL`` in the environment for at import time). #2552 (dual-mode DB-session auth) adds a conditional per-request
``Settings()``). ``app/main.py`` and the test apps both import THIS module, DB round trip via ``app.services.identity_store.identity_session`` но ТОЛЬКО
so tests exercise the exact production code path instead of a copy that can когда запрос реально несёт session-cookie
silently fall out of sync. (``request.cookies.get(settings.session_cookie_name)``); без cookie (весь
существующий тестовый трафик, legacy Caddy trusted-header запросы) ветка не
выполняется ноль новых DB-побочных эффектов для старых путей. ``app/main.py``
and the test apps both import THIS module, so tests exercise the exact
production code path instead of a copy that can silently fall out of sync.
Сессия открывается через ``identity_session()``, а не через
``app.core.db.SessionLocal`` напрямую: guard middleware, FastAPI-DI здесь нет,
а реестр людей при ``IDENTITY_STORE=auth`` лежит в другой БД. В дефолтном режиме
``identity_session()`` открывает ровно ``app.core.db.SessionLocal()`` тот же
коннект-пул и то же поведение, что до эпика «единый вход».
""" """
from __future__ import annotations from __future__ import annotations
@ -21,12 +31,15 @@ import logging
import re import re
import secrets import secrets
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any
from fastapi import Request from fastapi import Request
from fastapi.responses import JSONResponse, Response from fastapi.responses import JSONResponse, Response
from app.core.auth import get_role, is_path_allowed from app.core.auth import get_role, is_path_allowed
from app.core.config import settings from app.core.config import settings
from app.services.auth_session import get_db_role_scope, get_session_user
from app.services.identity_store import identity_session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -40,7 +53,37 @@ logger = logging.getLogger(__name__)
# Public paths без auth (/health, /docs, /openapi.json) пропускаем — # Public paths без auth (/health, /docs, /openapi.json) пропускаем —
# X-Authenticated-User там не приходит из Caddy. # X-Authenticated-User там не приходит из Caddy.
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/") _ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"}) # #2552: /api/v1/auth/login + /logout — по определению вызываются ДО того, как
# клиент аутентифицирован (login) или могут вызываться с уже протухшей/отсутствующей
# сессией (logout — должен уметь чистить stale cookie без валидной auth). Свой
# rate-limit у /login отдельный (app.api.v1.auth._LOGIN_LIMITER), RateLimitMiddleware
# на /api/* всё равно применяется — это ослабляет ТОЛЬКО rbac_guard'овский
# auth-required gate, не остальные защиты.
#
# Инцидент 2026-07-31: /api/v1/trade-in/support/anon/* — по той же логике. Единственным
# каналом в поддержку был чат ЗА логином, а типовая причина писать в поддержку —
# «не могу войти» (в тот день так и вышло: «Практика» билась в форму весь день и
# достучаться из продукта не могла). Ветка НЕ трогает авторизованные
# /api/v1/trade-in/support/* — те по-прежнему требуют identity; у анонимной свой,
# заведомо более узкий бюджет (per-token + per-IP, см. app.api.v1.support) и своя
# идентичность из httpOnly-куки, которая структурно не может совпасть с чьим-то
# логином.
_PUBLIC_PATHS = frozenset(
{
"/health",
"/docs",
"/redoc",
"/openapi.json",
"/api/v1/auth/login",
"/api/v1/auth/logout",
# NB: префикс — /api/v1/trade-in (app/main.py include_router), а Caddy
# срезает ВНЕШНИЙ /trade-in ещё раньше. Т.е. снаружи это
# /trade-in/api/v1/trade-in/support/anon/*, сюда приходит вот такое.
"/api/v1/trade-in/support/anon/messages",
"/api/v1/trade-in/support/anon/unread",
"/api/v1/trade-in/support/anon/read",
}
)
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед # #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для # tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
# scope-проверки восстанавливаем внешний путь. # scope-проверки восстанавливаем внешний путь.
@ -51,6 +94,77 @@ _EXTERNAL_PREFIX = "/trade-in"
_RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand") _RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand")
def _db_glob_match(pattern: str, path: str) -> bool:
"""Мини-матчер для фиксированного набора DB-role паттернов
(``app.services.auth_session.DB_ROLE_PATHS`` только формы ``/**`` и
``<prefix>/**``, не нужна полная semantics ``app.core.auth._glob_to_regex``
тот модуль private и MIRROR'ится вручную с основным бэкендом, лишний
импорт private-символа оттуда увеличивал бы drift-риск)."""
if pattern == "/**":
return True
if pattern.endswith("/**"):
prefix = pattern[: -len("/**")]
return path == prefix or path.startswith(prefix + "/")
return path == pattern
def _db_role_path_allowed(role: str, path: str) -> bool:
paths, deny = get_db_role_scope(role)
if any(_db_glob_match(p, path) for p in deny):
return False
return any(_db_glob_match(p, path) for p in paths)
def _propagate_authenticated_user(request: Request, username: str) -> None:
"""Инжектит ``X-Authenticated-User`` в ASGI scope — ПЕРЕЗАПИСЫВАЯ, а не
только добавляя при отсутствии, чтобы ``RateLimitMiddleware``/
``RequestAuditMiddleware`` (оба читают сырой заголовок напрямую,
#2213/#2550) и downstream route-хендлеры (читающие его через FastAPI
``Header()``) видели РЕЗОЛВЛЕННОГО ИЗ СЕССИИ юзера без правок в каждом
из этих мест по отдельности (минимально инвазивный способ).
#2552 post-review fix (CRITICAL): раньше это была skip-if-present
мутация (``if request.headers.get(...): return``) сессия резолвилась
ПЕРВОЙ (см. rbac_guard), но клиент-контролируемый ``X-Authenticated-User``
(который Caddy шлёт на КАЖДЫЙ прод-запрос) выигрывал у неё для ВСЕГО
downstream-трафика: атакующий с валидной cookie юзера ``alice`` мог
подделать заголовок ``X-Authenticated-User: victim`` и получить доступ к
данным victim в ~15 роутах, читающих заголовок напрямую
(``_assert_estimate_access*``, ``account_quota``, ``/trade-in/history``,
``support.py``) работало в ОБОИХ auth_mode (dual и db_only), т.к. эти
хендлеры не знают про rbac_guard'овский ``from_session`` флаг, только про
сырой заголовок. Session-identity ДОЛЖНА быть источником истины, если
сессия резолвлена полная перезапись, не skip.
Механизм: ``request.scope`` ОДИН и тот же dict-объект, прокинутый по
ссылке через весь ASGI call chain (Starlette не копирует scope между
слоями middleware). Мутация ``scope["headers"]`` ЗДЕСЬ видна:
- downstream call_next() цепочке (ExceptionMiddleware Router
endpoint) т.к. rbac_guard мутирует scope ДО вызова call_next();
- ``RequestAuditMiddleware`` он внешний относительно rbac_guard
(см. app/main.py: последний ``add_middleware`` оборачивает
предыдущие) и читает ``request.headers`` уже ПОСЛЕ ``call_next()``
отработал весь внутренний стек, включая эту мутацию.
ASGI header-имена всегда lowercase bytes (см. ASGI spec), поэтому
фильтр по ``b"x-authenticated-user"`` ловит заголовок независимо от
регистра, в котором его прислал клиент (Starlette уже нормализует).
Известное ограничение: ``RateLimitMiddleware`` тоже внешний относительно
rbac_guard, но читает заголовок ДО вызова call_next() (до того, как этот
guard успевает отработать) для ЭТОГО конкретного запроса сессионный
юзер лимитируется по IP, а не по username (per-user множитель не
применяется). Не регрессия (IP-лимит применялся бы и раньше до
добавления session-auth такие запросы вообще были 401), просто более
строгий бюджет специфично для session-cookie-запросов; при необходимости
точного per-user квотинга для DB-юзеров переносить резолв сессии выше
RateLimit в app/main.py отдельным issue.
"""
request.scope["headers"] = [
(k, v) for k, v in request.scope.get("headers", []) if k != b"x-authenticated-user"
] + [(b"x-authenticated-user", username.encode("latin-1", "replace"))]
async def rbac_guard( async def rbac_guard(
request: Request, request: Request,
call_next: Callable[[Request], Awaitable[Response]], call_next: Callable[[Request], Awaitable[Response]],
@ -59,11 +173,56 @@ async def rbac_guard(
if path in _PUBLIC_PATHS: if path in _PUBLIC_PATHS:
return await call_next(request) return await call_next(request)
username: str | None = None
role: str | None = None
from_session = False
# #2552: session-cookie резолвится ПЕРВЫМ. Если cookie нет вообще —
# request.cookies.get() возвращает None без единого похода в БД (ноль
# side-effects для всего существующего трафика без cookie).
token = request.cookies.get(settings.session_cookie_name)
if token:
session_user: dict[str, Any] | None = None
try:
with identity_session() as db:
session_user = get_session_user(db, token)
except Exception:
# Сюда попадает и AuthDatabaseNotConfiguredError (IDENTITY_STORE=auth
# без AUTH_DATABASE_URL): резолв сессии не состоялся, дальше работает
# тот же путь, что и при любом сбое БД, — auth_mode решает, пускать ли
# legacy trusted-header.
#
# ⚠️ Этот except НЕ должен быть тем, что ловит сломанный DSN: молча
# деградировать в legacy trusted-header означало бы раздавать права
# из roles.yaml в обход реестра (включая аккаунты с access_state
# 'disabled'/'trial_expired'), причём сутками — продуктовая БД жива,
# приложение работоспособно, сигнал только в логах. Поэтому
# конфигурацию проверяет lifespan (app/main.py): при
# IDENTITY_STORE=auth пустой DSN роняет СТАРТ. Здесь остаётся второй
# рубеж — реестр, отвалившийся уже после успешного старта, не имеет
# права отдавать 500.
logger.exception("RBAC: session lookup failed for %s", path)
if session_user is not None:
username = session_user["username"]
role = session_user["role"]
from_session = True
_propagate_authenticated_user(request, username)
if not from_session:
# auth_mode == "db_only" — легаси trusted-header путь ПОЛНОСТЬЮ
# отключён, даже если валидный X-Authenticated-User присутствует.
if settings.auth_mode != "dual":
return JSONResponse(
status_code=401,
content={"detail": "valid session required"},
)
# ---- legacy trusted-header path — BIT-FOR-BIT как было до #2552 ----
username = request.headers.get("X-Authenticated-User") username = request.headers.get("X-Authenticated-User")
if not username: if not username:
return JSONResponse( return JSONResponse(
status_code=401, status_code=401,
content={"detail": "no authenticated user (Caddy basic_auth required)"}, content={"detail": "no authenticated user (valid session required)"},
) )
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User # #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
@ -94,6 +253,9 @@ async def rbac_guard(
content={"detail": "user not in roles config"}, content={"detail": "user not in roles config"},
) )
assert username is not None
assert role is not None
if _ADMIN_API_RE.match(path) and role != "admin": if _ADMIN_API_RE.match(path) and role != "admin":
logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path) logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path)
return JSONResponse( return JSONResponse(
@ -101,15 +263,14 @@ async def rbac_guard(
content={"detail": "admin only"}, content={"detail": "admin only"},
) )
# #R2-H3: энфорсим roles.yaml scope (paths/deny) для ВСЕХ non-admin путей, а не # #R2-H3: энфорсим scope (paths/deny) для ВСЕХ non-admin путей, а не
# только /admin/*. Иначе revoked (role=expired, paths:[] deny:/**) или узко- # только /admin/*. Bootstrap-пути (/me, /brand) исключены — иначе revoked/
# скоупленный аккаунт достаёт non-admin API (напр. POST /api/v1/search — # scope-narrowed юзер не смог бы получить свою роль вовсе.
# экспорт листингов), который roles.yaml ему запрещает. Bootstrap-пути (/me,
# /brand) исключены выше по списку. roles.yaml globs внешние → восстанавливаем
# внешний путь (Caddy срезал /trade-in). На сбой парса — fail-open + громкий
# лог: не лочим платящего pilot из-за конфиг-бага (admin-гейт выше остаётся).
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT): if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
external_path = _EXTERNAL_PREFIX + path external_path = _EXTERNAL_PREFIX + path
if from_session:
allowed = _db_role_path_allowed(role, external_path)
else:
try: try:
allowed = is_path_allowed(role, external_path) allowed = is_path_allowed(role, external_path)
except Exception: except Exception:

View file

@ -23,6 +23,7 @@ from sentry_sdk.integrations.starlette import StarletteIntegration
from app.api.v1 import ( from app.api.v1 import (
admin, admin,
audit, audit,
auth,
brand, brand,
buildings, buildings,
geocode, geocode,
@ -31,8 +32,10 @@ from app.api.v1 import (
privacy_admin, privacy_admin,
search, search,
support, support,
team,
trade_in, trade_in,
) )
from app.core.auth_db import get_auth_engine
from app.core.config import settings from app.core.config import settings
from app.core.db import SessionLocal from app.core.db import SessionLocal
from app.core.fdw import ensure_fdw_user_mapping from app.core.fdw import ensure_fdw_user_mapping
@ -107,6 +110,40 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
".env.runtime ОБОИХ стеков (Caddy главного стека + tradein-backend)" ".env.runtime ОБОИХ стеков (Caddy главного стека + tradein-backend)"
) )
# #2552: session_secret зарезервирован на будущее (напр. подписанные токены) —
# opaque session-токены (secrets.token_urlsafe, см. app.services.auth_session)
# НЕ требуют подписи, их валидность проверяется исключительно наличием строки
# в tradein_sessions + expires_at/is_active. Пустой session_secret НЕ должен
# ронять старт контейнера (не startup-fail) — только громкий WARNING, чтобы
# прод не остался без него незамеченно до момента, когда он реально понадобится.
if not settings.session_secret:
logger.warning(
"SESSION_SECRET пуст — не блокирует старт (opaque session-токены не "
"требуют подписи), но задай его в .env.runtime до появления фич, "
"которым подпись реально нужна"
)
# Эпик «единый вход»: при IDENTITY_STORE=auth реестр людей обязан быть
# СКОНФИГУРИРОВАН — иначе стартуем сломанными. Ошибка DSN не похожа на «БД
# недоступна»: продуктовая БД жива, приложение полностью работоспособно и
# может так работать сутками, а rbac_guard ловит AuthDatabaseNotConfiguredError
# вместе с любым другим сбоем резолва сессии и падает в legacy
# trusted-header ветку (auth_mode='dual'). То есть любой, кого пропустил
# Caddy basic_auth, молча получал бы права из roles.yaml — даже аккаунт с
# access_state='disabled'/'trial_expired' в реестре. Пусть лучше сломанный
# деплой не поднимется вообще, чем сутки раздаёт доступ мимо реестра.
#
# На ДЕФОЛТНЫЙ режим не влияет: при identity_store="tradein" (прод сегодня)
# ветка не выполняется, engine БД `auth` не создаётся, пустой
# AUTH_DATABASE_URL по-прежнему не ошибка.
if settings.identity_store == "auth":
# Наружу летит AuthDatabaseNotConfiguredError с внятным текстом
# (app.core.auth_db); create_engine к серверу не ходит, так что это
# проверка КОНФИГУРАЦИИ, а не доступности БД — недоступный сервер
# по-прежнему не мешает старту.
get_auth_engine()
logger.info("identity_store=auth: DSN общего реестра людей (БД `auth`) сконфигурирован")
# FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server. # FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server.
# Best-effort: failure does not abort startup, just logs. # Best-effort: failure does not abort startup, just logs.
try: try:
@ -159,6 +196,7 @@ def health() -> dict[str, str]:
return {"status": "ok", "environment": settings.environment} return {"status": "ok", "environment": settings.environment}
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(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"]) 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(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
@ -170,3 +208,4 @@ app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-su
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"]) app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
app.include_router(search.router, prefix="/api/v1", tags=["search"]) app.include_router(search.router, prefix="/api/v1", tags=["search"])
app.include_router(me.router, prefix="/api/v1", tags=["me"]) app.include_router(me.router, prefix="/api/v1", tags=["me"])
app.include_router(team.router, prefix="/api/v1/team", tags=["team"])

View file

@ -48,8 +48,11 @@ _TG_BOT_TOKEN_REPLACEMENT = "/bot[REDACTED]"
_TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b") _TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b")
# Query-string секреты в исходящих URL сторонних API (аудит-фикс, #security-audit): # Query-string секреты в исходящих URL сторонних API (аудит-фикс, #security-audit):
# mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др., admin.py # исторически — mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др.,
# rotate_proxy_ip) несёт провайдерский API-ключ в query (`?...&proxy_key=...`). # admin.rotate_proxy_ip) несла провайдерский API-ключ в query
# (`?...&proxy_key=...`). Ручка и переменные удалены (#2616 шаг 2/3, мёртвая
# подписка) — редактор оставлен как generic safety net (не ключ-based, любой
# будущий query-секрет с распространённым именем параметра тоже покрыт).
# Два независимых пути утечки в GlitchTip, зеркалящих TG-токен выше: # Два независимых пути утечки в GlitchTip, зеркалящих TG-токен выше:
# 1. `HttpxIntegration.send()` парсит URL через `parse_url(str(request.url), # 1. `HttpxIntegration.send()` парсит URL через `parse_url(str(request.url),
# sanitize=False)` (ЯВНЫЙ opt-out из sentry_sdk `sanitize_url`, который иначе # sanitize=False)` (ЯВНЫЙ opt-out из sentry_sdk `sanitize_url`, который иначе
@ -58,13 +61,14 @@ _TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b")
# span не сэмплится/не уходит), но молча перестанет спасать, если трейсинг # span не сэмплится/не уходит), но молча перестанет спасать, если трейсинг
# когда-нибудь включат. # когда-нибудь включат.
# 2. `include_local_variables=True` (sentry_sdk default в app/main.py — в отличие # 2. `include_local_variables=True` (sentry_sdk default в app/main.py — в отличие
# от tgbot_main.py, где явно False) кладёт stack-frame locals (`rotate_url`, # от tgbot_main.py, где явно False) кладёт stack-frame locals в traceback
# `exc` в rotate_proxy_ip) в traceback открытым текстом. # открытым текстом (был прецедент: `rotate_url`/`exc` в удалённом
# admin.rotate_proxy_ip).
# Как и TG-токен — full-text regex по КАЖДОЙ строке event (не ключ-based): секрет # Как и TG-токен — full-text regex по КАЖДОЙ строке event (не ключ-based): секрет
# может всплыть где угодно (frame locals, breadcrumb, exception message). НЕ # может всплыть где угодно (frame locals, breadcrumb, exception message). НЕ
# завязано на конкретного провайдера — покрывает любой query-параметр из # завязано на конкретного провайдера — покрывает любой query-параметр из
# общеупотребимого набора секретных имён (api_key/proxy_key/token/secret/password/ # общеупотребимого набора секретных имён (api_key/proxy_key/token/secret/password/
# access_token/auth), т.к. cian/yandex у нас имеют СВОИ rotate-URL (потенциально # access_token/auth) — живой пример: ASOCKS_API_TOKEN (потенциально
# другой провайдер, другое имя параметра). # другой провайдер, другое имя параметра).
_URL_SECRET_QUERY_RE = re.compile( _URL_SECRET_QUERY_RE = re.compile(
r"(?i)([?&](?:api[_-]?key|proxy[_-]?key|token|secret|password|pwd|" r"(?i)([?&](?:api[_-]?key|proxy[_-]?key|token|secret|password|pwd|"

View file

@ -40,7 +40,9 @@ class SearchParams(BaseModel):
floors_total_max: int | None = Field(default=None, ge=1) floors_total_max: int | None = Field(default=None, ge=1)
# --- Quality / cross-source --- # --- 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 sources: list[Literal["avito", "cian", "yandex_realty"]] | None = None
multi_source_only: bool = False multi_source_only: bool = False
require_avito: bool = False require_avito: bool = False

View file

@ -0,0 +1,116 @@
"""Pydantic-схемы team-management API (#2554, эпик #2549).
CRUD управляемых аккаунтов (`tradein_users.role IN ('employee','manager')`
manager'ы доступны только actor'у-admin, см. `app.api.v1.team`), квоты, история
оценок. Org-изоляция (manager видит/меняет только своих employee) реализована в
`app.api.v1.team`, эти схемы только форма запросов/ответов.
"""
from __future__ import annotations
import re
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
# ASCII-only — не-ASCII username ломает downstream identity-пропагацию
# (`app.core.rbac._propagate_authenticated_user` кодирует latin-1 с
# errors="replace"), поэтому валидация формы обязательна на границе API,
# а не только на уровне БД.
#
# `\Z`, НЕ `$` — deep-review seed #2564: в Python `$` матчит перед trailing
# newline (`re.match(r'...\$', 'admin\n')` → True), а Postgres `~` в CHECK
# tradein_users_username_ascii_ck (миграция 193) — False. С `$` строка
# "admin\n" проходила бы Pydantic-валидацию и падала уже в БД → 500 вместо
# честного 422. `\Z` — конец строки БЕЗ поблажки на trailing newline, совпадает
# с семантикой Postgres `~`.
_USERNAME_RE = re.compile(r"^[A-Za-z0-9._-]{3,64}\Z")
class QuotaStatusOut(BaseModel):
"""Статус месячной квоты оценок — вложен в `EmployeeOut`."""
model_config = ConfigDict(from_attributes=True)
limit: int
used: int
remaining: int
unlimited: bool
class EmployeeCreateRequest(BaseModel):
"""`POST /employees` — создать сотрудника. Роль всегда `employee` (не в теле)."""
username: str
password: str
display_name: str | None = None
org_name: str | None = None
email: str | None = None
monthly_limit: int | None = Field(default=None, ge=1)
# Только для actor.role == admin — опциональная привязка к конкретному manager.
# Для actor.role == manager это поле ИГНОРИРУЕТСЯ (принудительно свой id) —
# см. app.api.v1.team.create_employee.
manager_id: int | None = None
@field_validator("username")
@classmethod
def _validate_username(cls, v: str) -> str:
if not _USERNAME_RE.match(v):
raise ValueError(
"username must be 3-64 ASCII chars: letters, digits, dot, underscore, hyphen"
)
return v
class EmployeeUpdateRequest(BaseModel):
"""`PATCH /employees/{id}` — частичное обновление, все поля опциональны."""
is_active: bool | None = None
monthly_limit: int | None = Field(default=None, ge=1)
display_name: str | None = None
org_name: str | None = None
email: str | None = None
new_password: str | None = None
class EmployeeOut(BaseModel):
"""Одна строка в `GET /employees` + ответ `POST`/`PATCH /employees/{id}`."""
model_config = ConfigDict(from_attributes=True)
id: int
username: str
# 'employee' | 'manager' — admin управляет обоими, manager видит только
# employee (см. app.api.v1.team, модульный docstring). Строки role='admin'
# через этот API не отдаются никогда, поэтому в Literal их нет.
role: Literal["employee", "manager"]
display_name: str | None = None
org_name: str | None = None
email: str | None = None
is_active: bool
manager_id: int | None = None
created_at: datetime
quota: QuotaStatusOut
class EmployeeHistoryEntry(BaseModel):
"""Одна строка истории оценок сотрудника — `GET /employees/{id}/history`.
Источник `user_events` (event_type='estimate_request', паттерн
`app.api.v1.audit.account_drilldown`), LEFT JOIN на `trade_in_estimates`
за фактическим результатом (median_price/confidence/n_analogs) join
может не сматчиться (старая запись без estimate_id / оценка insufficient_data),
поэтому все result-поля nullable.
"""
model_config = ConfigDict(from_attributes=True)
estimate_id: str | None = None
address: str | None = None
area_m2: str | None = None
rooms: str | None = None
median_price: int | None = None
confidence: str | None = None
n_analogs: int | None = None
created_at: datetime

View file

@ -27,6 +27,12 @@ class TradeInEstimateInput(BaseModel):
# geocode() (который падает на DaData-формах при мёртвом Yandex-ключе). # geocode() (который падает на DaData-формах при мёртвом Yandex-ключе).
lat: float | None = Field(default=None, ge=-90, le=90) lat: float | None = Field(default=None, ge=-90, le=90)
lon: float | None = Field(default=None, ge=-180, le=180) lon: float | None = Field(default=None, ge=-180, le=180)
# #2576: город, если известен фронту (например выбран отдельным полем UI).
# Опционально — без него geocode() внутри estimate_quality() БОЛЬШЕ НЕ
# подставляет "Екатеринбург" молча (см. app.services.geocoder), что раньше
# давало уверенно неверную цену для жителей других городов области (те же
# улица+дом существуют и в ЕКБ, и, например, в Нижнем Тагиле).
city_hint: str | None = Field(default=None, max_length=100)
# ФИАС/ГАР OBJECTGUID целевого дома, если фронт разрешил его через suggest # ФИАС/ГАР OBJECTGUID целевого дома, если фронт разрешил его через suggest
# (SuggestItem.fias_id у house-level кандидата). Прокидывается в матчер # (SuggestItem.fias_id у house-level кандидата). Прокидывается в матчер
# (Tier 0.5 fias_exact) ПЕРВЫМ, до fias из DaData /clean. Additive/optional — # (Tier 0.5 fias_exact) ПЕРВЫМ, до fias из DaData /clean. Additive/optional —
@ -194,6 +200,12 @@ class AggregatedEstimate(BaseModel):
target_address: str | None = None # geocoded full address target_address: str | None = None # geocoded full address
target_lat: float | None = None target_lat: float | None = None
target_lon: float | None = None target_lon: float | None = None
# #2576: True если ни адрес, ни `TradeInEstimateInput.city_hint` не называли
# город явно — итоговый город (и, соответственно, набор аналогов/цена)
# определил геокодер-провайдер, а не пользователь. Честный сигнал для
# UI (снизить доверие / переспросить город), НЕ персистится в БД
# (ephemeral, только для текущего POST /estimate ответа).
target_city_ambiguous: bool = False
sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr'] sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr']
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
# абсолютный timestamp самого свежего парсинга аналогов # абсолютный timestamp самого свежего парсинга аналогов
@ -258,6 +270,16 @@ class AggregatedEstimate(BaseModel):
# null — нет данных / оценка не построена # null — нет данных / оценка не построена
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него). # НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
# search_radius_m — фактический радиус (метры), по которому реально отбирались
# listings-аналоги (estimator.py: base_radius_m/fallback_radius_m, #2632). Может
# ОТЛИЧАТЬСЯ от TradeInEstimateInput.radius_m (выбор пользователя в дропдауне):
# сервер молча расширяет 1 км → 2 км при нехватке аналогов (см.
# confidence_explanation "расширили радиус до 2 км"). Фронт рисует круг на карте
# по ЭТОМУ полю (не по своему выбору) — иначе карта врёт о реально
# использованном радиусе. None на GET-rehydrate (не персистится, старые записи)
# и у _empty_estimate (поиск аналогов не выполнялся) — фронт в этом случае
# fallback'ает на выбор пользователя.
search_radius_m: int | None = None
# ── #2002: премиальный дом (флаг, НЕ ценовой сигнал) ── # ── #2002: премиальный дом (флаг, НЕ ценовой сигнал) ──
# premium_building — целевой дом признан премиальным. Источник — curated overlay # premium_building — целевой дом признан премиальным. Источник — curated overlay
# `premium_buildings_curated` (data/sql/142, AI/human-выверенный класс + false- # `premium_buildings_curated` (data/sql/142, AI/human-выверенный класс + false-
@ -404,6 +426,11 @@ class ScheduleConfigUpdate(BaseModel):
window_start_hour: int = Field(default=2, ge=0, le=23) window_start_hour: int = Field(default=2, ge=0, le=23)
window_end_hour: int = Field(default=5, ge=0, le=23) window_end_hour: int = Field(default=5, ge=0, le=23)
default_params: dict[str, Any] = Field(default_factory=dict) 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) ─────────────────────── # ── House analytics (house_placement_history backfill) ───────────────────────
@ -591,6 +618,13 @@ class SalesVsListingsResponse(BaseModel):
deals_with_listings: int # сколько имеют связанный listing deals_with_listings: int # сколько имеют связанный listing
linkage_rate_pct: float # deals_with_listings / total_deals * 100 linkage_rate_pct: float # deals_with_listings / total_deals * 100
median_discount_pct: float | None # медиана по парам с listing 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) data_quality: str # "house_linked" | "street_only" | "no_data" (#721, ADR v3)
pairs: list[SalesListingPair] # все пары, sorted by deal_date DESC pairs: list[SalesListingPair] # все пары, sorted by deal_date DESC

View file

@ -0,0 +1,334 @@
"""Session-сервис для DB-backed auth (#2552, эпик #2549 — auth-core).
Схема НЕ зашита: имена таблиц и имя колонки состояния доступа берутся из
`app.services.identity_store.identity_schema()` эпик «единый вход» переводит
реестр людей с `tradein_users`/`tradein_sessions` (migration
`192_tradein_users_auth.sql`, БД tradein) на `users`/`sessions` (БД `auth`,
миграции data/sql/auth/001-004) флагом `IDENTITY_STORE`, дефолт которого =
сегодняшнее прод-поведение. Никаких других отличий между режимами у этого
модуля нет: SQL один и тот же, подставляются только имена из фиксированного
словаря `identity_store._SCHEMAS`.
Опаковые (`secrets.token_urlsafe`) токены-сессии не JWT, не подписаны: валидность
проверяется исключительно наличием строки + `expires_at` + состоянием доступа
юзера в БД, поэтому `SESSION_SECRET` НЕ обязателен для работы этого модуля
(зарезервирован на будущее, см. `app.core.config.Settings.session_secret` docstring).
Все функции здесь принимают уже открытую `db: Session` сами НЕ открывают
сессию (вызывающая сторона решает время жизни транзакции: `rbac_guard` и
роуты открывают её по-разному). Это ОБЯЗАНА быть сессия РЕЕСТРА
(`identity_store.identity_session()` / `Depends(get_identity_db)`), а не
`app.core.db.get_db`: при `IDENTITY_STORE=auth` запрос уйдёт в БД tradein,
где таблиц `users`/`sessions` нет. В дефолтном режиме это один и тот же объект.
Модуль остаётся тривиально unit-тестируемым тесты просто передают
fake/real `Session`.
Ни одна функция не должна ронять вызывающий HTTP-запрос: DB-ошибки логируются
через `logger` вызывающей стороной (см. `app.core.rbac.rbac_guard`,
`app.api.v1.me`), сам сервис поднимает исключения как есть (это НЕ fire-and-forget
аудит-лог вроде `app.services.user_events`, а часть auth-decision сбой обязан
быть виден вызывающему, чтобы тот мог fail-closed).
"""
from __future__ import annotations
import logging
import secrets
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services.identity_store import identity_schema, to_access_state
logger = logging.getLogger(__name__)
# Sliding-window refresh: last_seen_at/expires_at продлеваются НЕ чаще раза в
# 5 минут — иначе каждый API-запрос авторизованного юзера бил бы в БД лишним
# UPDATE (RBAC гоняет get_session_user на КАЖДЫЙ non-public запрос).
_SLIDING_REFRESH_INTERVAL = timedelta(minutes=5)
_TOKEN_BYTES = 32 # secrets.token_urlsafe(32) — 256 бит энтропии, ~43 символа
def create_session(
db: Session,
user_id: int,
ip: str | None = None,
user_agent: str | None = None,
) -> str:
"""Создаёт новую сессию для *user_id* и возвращает opaque-токен.
`expires_at = now() + settings.session_ttl_hours`. Коммитит сам (self-contained,
как `app.services.user_events.record_event`).
"""
schema = identity_schema()
token = secrets.token_urlsafe(_TOKEN_BYTES)
db.execute(
text(
f"""
INSERT INTO {schema.sessions_table}
(token, user_id, expires_at, ip_address, user_agent)
VALUES (
:token, :user_id,
now() + make_interval(hours => CAST(:ttl_hours AS integer)),
CAST(:ip AS inet), :user_agent
)
"""
),
{
"token": token,
"user_id": user_id,
"ttl_hours": settings.session_ttl_hours,
"ip": ip,
"user_agent": user_agent,
},
)
db.commit()
return token
def get_session_user(db: Session, token: str) -> dict[str, Any] | None:
"""Резолвит сессионный токен в данные юзера, или None если сессия
невалидна (не найдена / истекла / доступ юзера не `active`).
Состояние доступа: пропускает ТОЛЬКО `AccessState.ACTIVE`. Любое другое
(`disabled`, `trial_expired`, а также нераспознанное `to_access_state`
fail-closed'ит его в `disabled`) делает уже выданную сессию недействительной
немедленно, без ожидания TTL. Это то же решение, что и в булевой схеме
(`is_active = false` None), просто теперь состояний больше одного:
«пробный период истёк» гасит живую сессию так же, как блокировка иначе
сотрудник, залогиненный до истечения пробного доступа, продолжал бы
работать, а sliding-refresh продлевал бы ему сессию бесконечно.
Sliding refresh: если с последнего `last_seen_at` прошло >=5 минут
продлевает `expires_at`/`last_seen_at` ОДНИМ UPDATE. Сбой refresh
(напр. read-replica) логируется и НЕ мешает вернуть валидного юзера
это best-effort продление, а не часть решения "валидна ли сессия".
"""
if not token:
return None
schema = identity_schema()
row = db.execute(
text(
f"""
SELECT s.user_id, s.expires_at, s.last_seen_at,
u.username, u.role, u.display_name, u.org_name, u.email,
u.{schema.access_state_column} AS access_state
FROM {schema.sessions_table} s
JOIN {schema.users_table} u ON u.id = s.user_id
WHERE s.token = :token
"""
),
{"token": token},
).fetchone()
if row is None:
return None
now = datetime.now(UTC)
if row.expires_at is None or row.expires_at <= now:
return None
access_state = to_access_state(row.access_state)
if not access_state.can_sign_in:
return None
if row.last_seen_at is None or (now - row.last_seen_at) >= _SLIDING_REFRESH_INTERVAL:
try:
db.execute(
text(
f"""
UPDATE {schema.sessions_table}
SET last_seen_at = now(),
expires_at = now() + make_interval(hours => CAST(:ttl_hours AS integer))
WHERE token = :token
"""
),
{"ttl_hours": settings.session_ttl_hours, "token": token},
)
db.commit()
except Exception:
logger.warning(
"auth_session: sliding refresh failed for user_id=%r", row.user_id, exc_info=True
)
db.rollback()
return {
"user_id": row.user_id,
"username": row.username,
"role": row.role,
"display_name": row.display_name,
"org_name": row.org_name,
"email": row.email,
# Всегда AccessState.ACTIVE — не-active сюда не доходит (см. выше).
# Ключ оставлен вместо прежнего `is_active`, чтобы состояние доступа во
# ВСЁМ коде называлось и выражалось одинаково.
"access_state": access_state,
}
def get_user_by_username(db: Session, username: str) -> dict[str, Any] | None:
"""Возвращает строку реестра по username, или None если не найден.
Используется login-флоу (`app.api.v1.auth.login`) для password-проверки.
Отдаёт `password_hash` как есть (может быть NULL переходный период,
см. migration 192 docstring) вызывающая сторона решает, что с ним делать.
`access_state` уже `AccessState` (не сырое значение колонки): решение
«пускать / не пускать / показать экран пробного периода» принимает login,
и принимать его он обязан по ОДНОМУ понятию, а не по boolean в одном режиме
и строке в другом. Отсутствие юзера состоянием НЕ выражается (None остаётся
None) иначе login потерял бы разницу между «нет такого логина» и
«заблокирован», а она нужна ему для выбора события аудита.
"""
schema = identity_schema()
row = db.execute(
text(
f"""
SELECT id, username, password_hash, role,
{schema.access_state_column} AS access_state,
display_name, org_name, email
FROM {schema.users_table}
WHERE username = :username
"""
),
{"username": username},
).fetchone()
if row is None:
return None
return {
"user_id": row.id,
"username": row.username,
"password_hash": row.password_hash,
"role": row.role,
"access_state": to_access_state(row.access_state),
"display_name": row.display_name,
"org_name": row.org_name,
"email": row.email,
}
def revoke_session(db: Session, token: str) -> None:
"""Удаляет одну сессию по токену (logout). No-op если токен не найден."""
schema = identity_schema()
db.execute(text(f"DELETE FROM {schema.sessions_table} WHERE token = :token"), {"token": token})
db.commit()
def revoke_user_sessions(db: Session, user_id: int) -> None:
"""Удаляет ВСЕ сессии юзера — смена пароля и блокировка обязаны рвать
активные сессии немедленно (см. `app.api.v1.team.update_employee`)."""
schema = identity_schema()
db.execute(
text(f"DELETE FROM {schema.sessions_table} WHERE user_id = :user_id"),
{"user_id": user_id},
)
db.commit()
# ---------------------------------------------------------------------------
# DB-role → RBAC scope (paths/deny) — #2552 dual-mode.
# ---------------------------------------------------------------------------
#
# Роли реестра ('admin'|'manager'|'employee' — CHECK-констрейнт: tradein м.192 для
# tradein_users.role, auth м.004 для auth.users.role; наборы значений совпадают
# намеренно, чтобы код «Меры» переехал на общий реестр без правок в проверках роли)
# НЕ являются ключами auth/roles.yaml (тот файл — legacy Caddy trusted-header путь,
# который этот эпик намеренно не трогает). Маппинг ниже даёт DB-ролям тот же
# paths/deny-смысл, что и legacy-ролям, БЕЗ правки roles.yaml:
# employee -> клиентский доступ: весь /trade-in/** МИНУС внутренние разделы
# (см. deny ниже — раньше было «ровно как legacy pilot»).
# manager -> employee + /api/v1/team/** (дашборд команды, #2556).
# admin -> полный доступ, как legacy admin.
#
# Почему «Доля в продаже» и «Кэш» в deny у ОБЕИХ клиентских ролей (2026-07-31,
# решение владельца продукта): это внутренние инструменты, а не продукт клиента.
# «Доля в продаже» — аналитика рынка (сколько квартир дома выставлено, срез по
# домам/ЖК), «Кэш» — состояние кэшей и скраперов. Клиентские аккаунты видеть их
# не должны; триггер — аккаунт praktika (DB-роль manager), у которого оба пункта
# висели в топбаре на /trade-in/team.
#
# Почему в deny И страницы (/trade-in/sale-share, /trade-in/cache), И их API
# (/trade-in/api/v1/buildings/**, /trade-in/api/v1/trade-in/cache-stats/**): один
# deny-список гейтит СРАЗУ ТРИ места, потому что все трое сверяются с ним через
# один и тот же матчер —
# 1) пункт меню: Topbar фильтрует NAV_ITEMS по scopePath из /me;
# 2) сама страница: RouteGuard проверяет абсолютный путь из /me;
# 3) серверные ручки: app.core.rbac.rbac_guard (deny проверяется ПЕРВЫМ,
# внешний путь реконструируется как '/trade-in' + path).
# Только страницы = пункт исчез, но прямой URL и API остались открыты; только
# API = мёртвый пункт меню с 403 на каждый фетч.
#
# Почему '/trade-in/api/v1/buildings/**' безопасно закрывать целиком: весь
# роутер app/api/v1/buildings.py обслуживает ТОЛЬКО раздел sale-share
# (/sale-share, /sale-share/summary, /{house_id}/listings). Экран оценки его не
# использует — секция «Продажи в доме» питается estimate-хендлерами
# (useEstimatePlacementHistory / useSalesVsListings), а BuildingListingsDrawer
# импортируется единственной страницей app/sale-share/page.tsx.
#
# NB (границы глоба): '<prefix>/**' компилируется в '^<prefix>(?:/.*)?$' — матчит
# сам prefix, его же с трейлинг-слэшем и подпути через '/', но НЕ соседей по
# префиксу (см. app.core.rbac._db_glob_match и app.core.auth._glob_to_regex).
# Поэтому '/trade-in/cache/**' не задевает '/trade-in/cache-stats', а
# '/trade-in/api/v1/trade-in/cache-stats/**' — не '/…/cache-statistics'.
#
# Почему у cache-stats ГЛОБ, а не «более точный» '/trade-in/api/v1/trade-in/
# cache-stats': точный паттерн — это строгое равенство, и его обходит обычный
# трейлинг-слэш (измерено: '…/cache-stats/' → allowed=True). Сегодня от этого
# спасает только Starlette redirect_slashes (307 на путь без слэша → там уже
# 403), т.е. защита держалась бы на роутере, а не на RBAC — достаточно
# выключить redirect_slashes или сменить роутер, и deny тихо перестанет
# работать. Глоб закрывает и сам путь, и слэш, и любые будущие подпути.
# НЕ «уточнять» обратно до точного пути.
#
# NB (ограничение мини-матчера — читать перед копированием паттернов):
# DB_ROLE_PATHS и pilot.deny в auth/roles.yaml — зеркала по СМЫСЛУ, но матчеры
# у них РАЗНЫЕ. app.core.rbac._db_glob_match понимает ТОЛЬКО три формы:
# '/**' | '<prefix>/**' | точный путь (строгое равенство).
# app.core.auth._glob_to_regex (roles.yaml) понимает сверх этого ещё одиночную
# '*' ('/foo/*' = один сегмент). Паттерн с одиночной '*', скопированный сюда из
# roles.yaml, станет ЛИТЕРАЛЬНОЙ строкой и МОЛЧА перестанет что-либо запрещать —
# без ошибки на импорте и без падения тестов, если на него нет прямого теста.
# Т.е. в DB_ROLE_PATHS допустимы только '/**', '<prefix>/**' и точный путь;
# одиночная '*' здесь = silent no-op.
DB_ROLE_PATHS: dict[str, tuple[list[str], list[str]]] = {
"employee": (
["/trade-in/**", "/trade-in/api/v1/**"],
[
"/admin/**",
"/api/v1/admin/**",
"/trade-in/api/v1/admin/**",
"/trade-in/sale-share/**",
"/trade-in/cache/**",
"/trade-in/api/v1/buildings/**",
"/trade-in/api/v1/trade-in/cache-stats/**",
],
),
"manager": (
["/trade-in/**", "/trade-in/api/v1/**", "/api/v1/team/**"],
[
"/admin/**",
"/api/v1/admin/**",
"/trade-in/api/v1/admin/**",
"/trade-in/sale-share/**",
"/trade-in/cache/**",
"/trade-in/api/v1/buildings/**",
"/trade-in/api/v1/trade-in/cache-stats/**",
],
),
"admin": (["/**"], []),
}
def get_db_role_scope(role: str) -> tuple[list[str], list[str]]:
"""Возвращает (allowed_paths, deny_paths) для DB-роли.
Неизвестная роль (не должно случиться CHECK-констрейнт на колонке
ограничивает role тремя значениями) -> fail-closed (пустой allow, deny всё).
"""
return DB_ROLE_PATHS.get(role, ([], ["/**"]))

View file

@ -8,6 +8,7 @@ from __future__ import annotations
import json import json
import logging import logging
from datetime import datetime
from typing import Any from typing import Any
from curl_cffi.requests import AsyncSession from curl_cffi.requests import AsyncSession
@ -23,6 +24,11 @@ from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# За сколько дней до протухания кук предупреждать (#2658). Обновление кук — РУЧНАЯ
# операция (залить дамп через админку), человеку нужен запас: алерт по факту протухания
# приходит, когда сбор уже встал. save_session ставит ttl 30 дней, так что окно широкое.
COOKIE_EXPIRY_WARN_DAYS = 5
# Cookies критичные для Cian auth — фильтр перед сохранением. # Cookies критичные для Cian auth — фильтр перед сохранением.
# Список обновлён по реальному DevTools-дампу из logged-in сессии cian.ru (2026-05-23). # Список обновлён по реальному DevTools-дампу из logged-in сессии cian.ru (2026-05-23).
# Старые записи оставлены как fallback (backward compat). # Старые записи оставлены как fallback (backward compat).
@ -294,6 +300,37 @@ def load_session(db: Session) -> dict[str, str] | None:
return cookies 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: def mark_session_invalid(db: Session, account_user_id: int) -> None:
"""Flag session как expired/invalid (например после 401 во время scrape).""" """Flag session как expired/invalid (например после 401 во время scrape)."""
db.execute( db.execute(

View file

@ -343,6 +343,10 @@ async def suggest_addresses(
жёсткий фильтр (не boost) на уровне указанного admin-поля доп. жёсткий фильтр (не boost) на уровне указанного admin-поля доп.
параметров не требуется. По умолчанию не задан поведение (и body параметров не требуется. По умолчанию не задан поведение (и body
запроса) для существующих вызовов не меняется. запроса) для существующих вызовов не меняется.
ВАЖНО: значение сравнивается с полем DaData `region`, где имя лежит
БЕЗ типа («Свердловская», а тип отдельно в `region_type`="обл").
Передашь «Свердловская область» совпадений не будет, и запрос
вернёт ПУСТО без всякой ошибки (hard-filter, не boost).
Returns: Returns:
list[DadataSuggestion] пустой список если: list[DadataSuggestion] пустой список если:

View file

@ -17,6 +17,7 @@ from __future__ import annotations
import json import json
import logging import logging
from datetime import datetime
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -25,6 +26,13 @@ from app.core.config import settings
logger = logging.getLogger(__name__) 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) — фильтр перед сохранением. # Cookies критичные для DomClick auth (Sber ID) — фильтр перед сохранением.
# Список составлен по реальному DevTools/Cookie-Editor дампу авторизованной # Список составлен по реальному DevTools/Cookie-Editor дампу авторизованной
# test-аккаунт сессии (Sber ID login), 2026-07-04. # test-аккаунт сессии (Sber ID login), 2026-07-04.
@ -148,6 +156,37 @@ def load_session(db: Session) -> dict[str, str] | None:
return cookies 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: def mark_session_invalid(db: Session, account_cas_id: int) -> None:
"""Flag session как expired/invalid (например после блока во время scrape).""" """Flag session как expired/invalid (например после блока во время scrape)."""
db.execute( db.execute(

View file

@ -48,6 +48,7 @@ from scraper_kit.providers.cian.valuation import (
estimate_via_cian_valuation, estimate_via_cian_valuation,
) )
from scraper_kit.providers.yandex.valuation import ( from scraper_kit.providers.yandex.valuation import (
ValuationHouseMeta,
YandexValuationResult, YandexValuationResult,
YandexValuationScraper, YandexValuationScraper,
) )
@ -80,6 +81,7 @@ from app.services.house_metadata import get_house_metadata
from app.services.matching.houses import match_house_readonly, match_or_create_house from app.services.matching.houses import match_house_readonly, match_or_create_house
from app.services.scraper_adapters import RealScraperConfig from app.services.scraper_adapters import RealScraperConfig
from app.services.scraper_settings import get_scraper_delay from app.services.scraper_settings import get_scraper_delay
from app.tasks.asking_to_sold_ratio import area_bucket
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -165,6 +167,24 @@ def _estimate_consent_persist_fields(
DKP_CORRIDOR_CITY_WIDE_MIN_N = 3 DKP_CORRIDOR_CITY_WIDE_MIN_N = 3
DEALS_HEADLINE_FALLBACK_MIN_N = 3 DEALS_HEADLINE_FALLBACK_MIN_N = 3
# #oblast-E (money-path sufficiency gate, live-audit 2026-08-02): минимум
# LIVE-объявлений, из которых можно честно построить headline-медиану по
# рынку. Ниже порога median() по 1-4 случайным лотам не отражает рынок —
# live repro на проде: Серов 2к/45м², n=3 листинга → 42 391 ₽/м² (36% vs
# городской ДКП-коридор 54 126 ₽/м², которых сама эта улица не показывала
# из-за тонкой street-выборки), а соседняя улица того же города с другими
# 1-2 случайными лотами даёт разброс до ×1.66. Ниже порога
# _price_from_inputs ОБНУЛЯЕТ радиусную популяцию (listings_clean=[]) —
# 100%-переиспользует уже существующий и покрытый тестами путь «листингов
# нет» (same-building anchor / #oblast-D deals-headline-fallback /
# insufficient_data ниже), а не изобретает новую ветку. Значение 5 выбрано
# по live-данным (n=3 уже недостаточно; n=5 — тот же порог, что
# MIN_ANALOGS_TIER_0 использует для "хватает на строгий когортный тир" —
# согласованная планка "достаточно, чтобы не быть шумом одного-двух лотов").
# НЕ трогает подбор аналогов/тиры/радиусы — только решение, доверять ли
# ИТОГОВОЙ выборке как headline-источнику.
HEADLINE_LISTINGS_MIN_N = 5
# #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals. # #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals.
# Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm² # Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm²
# to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ). # to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ).
@ -264,6 +284,55 @@ def _load_city_price_bands(db: Session) -> dict[str, tuple[int, int]]:
return bands return bands
# Правдоподобный диапазон года постройки МКД (guard на входе — Mera-audit 2026-08-02).
# Нижняя граница 1917: массовая многоквартирная застройка в РФ/СССР началась
# после революции — год раньше почти гарантированно ошибка источника
# (house_metadata OSM/кадастр смешивают год постройки дома с годом основания
# места/памятника на тех же координатах — прод-инцидент 2026-08: house_metadata
# отдал year_built=1829 для обычной вторички, см. vault fixes). Верхняя граница
# — текущий год + 3: допуск на цели trade-in со строящимся домом (год сдачи по
# ДДУ известен заранее, но не более чем на несколько лет вперёд).
# Год вне диапазона трактуем как ОТСУТСТВУЮЩИЙ (None), а НЕ клампим к границе —
# хедонический фактор (_price_from_inputs, #2002) экстраполирует regression fit
# far вне обучающей выборки (COHORTS ниже даже не определяет когорту раньше
# 1955 — модель никогда не видела осмысленного объёма домов старше этого), и
# estimate_hedonic_factor_min=0.75 в таком случае не защита, а маскировка
# выхода за диапазон под видом уверенной 25% поправки. «Не знаем год» —
# честный сигнал, который просто отключает year-term фактора (нейтрален).
MIN_PLAUSIBLE_BUILD_YEAR = 1917
MAX_PLAUSIBLE_BUILD_YEAR_LEAD = 3 # текущий год + N — допуск на стройки
def _sanitize_build_year(
year: int | None, *, house_id: int | None = None, address: str | None = None
) -> int | None:
"""Отбрасывает неправдоподобный год постройки, трактуя его как «неизвестен».
Валидный диапазон [MIN_PLAUSIBLE_BUILD_YEAR, текущий год + LEAD]. Год вне
диапазона логируется на WARNING (с идентификатором дома house_id либо
адрес) и заменяется на None, а не клампится к границе: клампинг превращает
заведомый мусор источника (house_metadata OSM/кадастр, либо year_built из
payload ge=1800 в схеме пропускает подобные значения) в уверенный вход
для хедонической поправки (_price_from_inputs), хотя физического смысла
у результата нет.
"""
if year is None:
return None
max_year = datetime.now(UTC).year + MAX_PLAUSIBLE_BUILD_YEAR_LEAD
if year < MIN_PLAUSIBLE_BUILD_YEAR or year > max_year:
logger.warning(
"estimate: implausible year_built=%s dropped (house_id=%s, address=%s) — "
"valid range [%s, %s]",
year,
house_id,
address,
MIN_PLAUSIBLE_BUILD_YEAR,
max_year,
)
return None
return year
# Когорта по году постройки — типизация массовой застройки РФ. # Когорта по году постройки — типизация массовой застройки РФ.
# Используется как hard-filter в Tier 0 _fetch_analogs (PR 9, 2026-05-24). # Используется как hard-filter в Tier 0 _fetch_analogs (PR 9, 2026-05-24).
# Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается. # Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается.
@ -377,11 +446,22 @@ _asking_sold_ratio_cache: dict[int, tuple[float | None, str | None, float]] = {}
def _get_asking_sold_ratio( def _get_asking_sold_ratio(
db: Session, db: Session,
rooms: int | None, rooms: int | None,
area_m2: float | None = None,
anchor_ppm2: float | None = None, anchor_ppm2: float | None = None,
) -> tuple[float | None, str | None]: ) -> tuple[float | None, str | None]:
"""Возвращает (ratio, basis) asking→sold для бакета комнат. """Возвращает (ratio, basis) asking→sold для area-бакета клиентской квартиры.
bucket = min(max(rooms or 0, 0), 4). bucket = area_bucket(area_m2) при area_m2 > 0, иначе min(max(rooms or 0, 0), 4)
(rooms-фолбэк только когда площадь неизвестна).
#2620-2 (deep-review, вторая половина #2620): расчёт ratio (asking_to_sold_ratio.py
ask_side) теперь ключуется по area-бакету (rooms_bucket в asking_to_sold_ratios
это на самом деле area-бакет, легаси-имя колонки), но ДО этой правки применение
здесь читало rooms_bucket по РЕАЛЬНЫМ комнатам клиента тот же mismatch, что чинили
в расчёте, просто переехавший в применение. Прод-замер ревьюера (2026-08): 310/1038
(29.9%) исторических клиентских запросов легли бы в другой ratio-бакет при
rooms-ключе vs area-ключе. area_bucket() тот же Python-двойник, что и в
asking_to_sold_ratio.py (см. комментарий там, границы 30/44/62/85 = import-rosreestr.sh).
Запрос к asking_to_sold_ratios (migration 080): per-rooms строка Запрос к asking_to_sold_ratios (migration 080): per-rooms строка
(WHERE rooms_bucket = bucket AND district = '') fallback на global -1 (WHERE rooms_bucket = bucket AND district = '') fallback на global -1
@ -395,7 +475,7 @@ def _get_asking_sold_ratio(
Таблицы нет / любая ошибка (None, None), НЕ raise (graceful). Таблицы нет / любая ошибка (None, None), НЕ raise (graceful).
Кэшируется на ключ bucket с TTL _ASKING_SOLD_RATIO_CACHE_TTL_S. Кэшируется на ключ bucket с TTL _ASKING_SOLD_RATIO_CACHE_TTL_S.
""" """
bucket = min(max(rooms or 0, 0), 4) bucket = area_bucket(area_m2) if area_m2 else min(max(rooms or 0, 0), 4)
cached = _asking_sold_ratio_cache.get(bucket) cached = _asking_sold_ratio_cache.get(bucket)
if cached is not None: if cached is not None:
@ -811,10 +891,15 @@ def _save_yandex_history_items(
(address|publish_date|area|floor|prices) hash. (address|publish_date|area|floor|prices) hash.
Batch semantics: single try/except; on any failure the batch rolls back. Batch semantics: single try/except; on any failure the batch rolls back.
"""
if not result.history_items:
return 0
#2674 (ревью): резолв дома и запись houses.has_panorama идут ДО раннего возврата по
пустой истории. Раньше возврат стоял первым, и страница, отрисованная идеально, но
без единого объявления в истории, до записи панорамы не доходила на проде это
1519 оценок против 1360 домов с историей, ~10% страниц молча пропускались. Цена
переноса: match_or_create_house теперь вызывается и для таких страниц (может
СОЗДАТЬ дом). Это тот же вызов, с тем же адресом, что уже отрабатывает на
остальных 90% новых сущностей класс не появляется, появляется недостающая доля.
"""
# Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16] # Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16]
# — stable across re-runs, distinguishes pages for different addresses. # — stable across re-runs, distinguishes pages for different addresses.
address_seed = (result.address or "").strip().lower() address_seed = (result.address or "").strip().lower()
@ -850,6 +935,12 @@ def _save_yandex_history_items(
result.address, result.address,
) )
# Наблюдение о доме не зависит от того, есть ли на странице история объявлений.
_save_yandex_house_panorama(db, house_id, result.house)
if not result.history_items:
return 0
rows = [] rows = []
skipped_area = 0 skipped_area = 0
for item in result.history_items: for item in result.history_items:
@ -931,6 +1022,60 @@ def _save_yandex_history_items(
return 0 return 0
# #2674: has_panorama разбирался парсером (providers/yandex/valuation.py:334), лежал в
# HOUSE_FIELD_PRIORITY и обещался публичным контрактом market.v_houses (мигр. 154) — но
# в houses не попадал НИ ОДНОЙ строкой кода: 0 непустых из 9366 домов на проде. Здесь —
# единственное место, где yandex_valuation уже держит и house_id, и разобранную мету.
#
# ГЕЙТ ЧЕСТНОСТИ. Парсер отдаёт `bool`, а не `bool | None`: "Панорама" not in body_text
# даёт False и когда метки правда нет, и когда страница не отрисовалась (капча, редизайн,
# пустой ответ). Записывать такой False — снова выдать «не измеряли» за «измерили и нет».
# Пишем только когда страница ТОЧНО отрисовалась: в мете есть год постройки или этажность
# (обе — обязательные блоки нормальной страницы оценки). Иначе колонка остаётся NULL.
def _save_yandex_house_panorama(
db: Session,
house_id: int | None,
meta: ValuationHouseMeta,
) -> None:
"""Пишет houses.has_panorama по разобранной мете yandex_valuation.
No-op без house_id или когда страница не подтверждена как отрисованная (см. гейт
выше). Best-effort: ошибка логируется и глотается оценка не должна падать из-за
справочного флага. Именно поэтому UPDATE идёт в begin_nested: сбой откатывает
только свой SAVEPOINT и не отравляет транзакцию, в которой уже осела история.
"""
if house_id is None:
return
if meta.year_built is None and meta.total_floors is None:
logger.debug(
"yandex_valuation: has_panorama не пишем для house_id=%s"
"страница не подтверждена (нет ни года, ни этажности)",
house_id,
)
return
try:
with db.begin_nested():
db.execute(
text(
"""
UPDATE houses
SET has_panorama = CAST(:panorama AS boolean)
WHERE id = CAST(:hid AS bigint)
AND has_panorama IS DISTINCT FROM CAST(:panorama AS boolean)
"""
),
{"hid": house_id, "panorama": meta.has_panorama},
)
db.commit()
except Exception as e:
logger.warning(
"yandex_valuation: has_panorama save failed for house_id=%s (continuing): %s",
house_id,
e,
)
db.rollback()
# ── #651: IMV / Yandex blend (killer accuracy fix) ───────────────────────────── # ── #651: IMV / Yandex blend (killer accuracy fix) ─────────────────────────────
@ -1686,6 +1831,20 @@ def _normalize_building_key(
(корпус) схлопываются к base (тот же дом). Литеры РАЗНЫЕ дома (204г 204д). (корпус) схлопываются к base (тот же дом). Литеры РАЗНЫЕ дома (204г 204д).
- street_core прогоняется через _STREET_ALIAS_MAP (ткачеваткачей). - street_core прогоняется через _STREET_ALIAS_MAP (ткачеваткачей).
#2581: город/рНАМЕРЕННО дропается (не возвращается в ключе) — это
единственное, что делает «Ткачёва 13»-вариант с городом и без города давать
один ключ (см. test_normalize_tkachei13_all_db_variants_same_key). Городской
токен как 4-й элемент ключа НЕ добавлен: (а) часть источников (Avito
anonymous-адреса) вообще не несёт городской токен в тексте ключ с
обязательным городом сломал бы их матчинг; (б) написание города варьируется
(ЕКБ/Екатеринбург/г. Екатеринбург) ещё один normalization-слой, дающий
те же false-negative риски, которые уже решает `_CITY_TOKENS`-дропинг.
Кросс-городская коллизия (одноимённая улица+дом в разных городах области)
закрыта на SQL-уровне через ST_DWithin от subject-координат см. Tier A
в `_fetch_anchor_comps` (#2581) и Tier S в `_fetch_analogs` (#oblast-D,
f9ae6f0c) геопредикат надёжнее строкового city-токена и не ломает то,
что уже нормализуется здесь.
Returns (street_core, base_no, letter) любой элемент None если не извлёкся. Returns (street_core, base_no, letter) любой элемент None если не извлёкся.
Best-effort: при пустом адресе (None, None, None). Best-effort: при пустом адресе (None, None, None).
""" """
@ -1786,6 +1945,18 @@ def _anchor_comp_from_row(r: Any) -> dict[str, Any]:
} }
# #2581: Tier A ("same building") ST_DWithin safety-radius. Reuses the SAME
# city-scale DEFAULT_RADIUS_M already used by Tier S's mirrored geo-bound fix
# (f9ae6f0c, #oblast-D). The address-string match (_normalize_building_key +
# _house_boundary_regex) already establishes "same street + house number" —
# this radius only needs to reject GENUINELY cross-city collisions (e.g.
# «улица Ленина» exists in both Екатеринбург AND Серов/Нижний Тагил, 150+ km
# apart) — it does not need to be building-tight like Tier C's 500m
# micro-radius (that tier's precision comes from proximity alone, without a
# street/house string match to lean on).
ANCHOR_TIER_A_RADIUS_M = DEFAULT_RADIUS_M
def _fetch_anchor_comps( def _fetch_anchor_comps(
db: Session, db: Session,
*, *,
@ -1798,9 +1969,19 @@ def _fetch_anchor_comps(
) -> tuple[list[dict[str, Any]], str | None]: ) -> tuple[list[dict[str, Any]], str | None]:
"""Тированный набор комплов для same-building якоря. Стоп на 1-м тире с ≥ min_comps. """Тированный набор комплов для same-building якоря. Стоп на 1-м тире с ≥ min_comps.
Tier A SAME BUILDING: normalized street + base house no (+ литера если есть). Tier A SAME BUILDING: normalized street + base house no (+ литера если есть)
RELAXED rooms (без фильтра), БЕЗ area±15%. Не группируем по house_id_fk + ST_DWithin(ANCHOR_TIER_A_RADIUS_M) от subject lat/lon (#2581 — до этого
один дом дробится на несколько fk (Хохрякова 48 = 7085/9878/12797). SQL не имел ГЕО-предиката вовсе, и одноимённая улица+дом в ДРУГОМ городе
(область 368 городов, «Ленина»/«Мира»/... повторяются) молча матчила
ЕКБ-листинги для областного subject'а — ~40 191 из ~40 200 активных
листингов ЕКБ, distance неизвестна без фильтра). RELAXED rooms (без
фильтра), БЕЗ area±15%. Не группируем по house_id_fk один дом дробится
на несколько fk (Хохрякова 48 = 7085/9878/12797); ST_DWithin тот же
компромисс, что и Tier S ниже (см. _fetch_analogs), не house_id_fk.
lat/lon subject'а обязательны (гейт как у Tier C) — без них геопредикат
невозможен, и Tier A целиком пропускается (в проде geo ВСЕГДА есть
estimate_quality возвращает _empty_estimate раньше при неудачном
geocode, см. `if geo is None`).
Tier C micro-radius 500m (ST_DWithin) + вторичка-канон guard (#1186): NULL = legacy Tier C micro-radius 500m (ST_DWithin) + вторичка-канон guard (#1186): NULL = legacy
вторичка + rooms match + area±25%. (Tier B «тот же ЖК» skip: complex_id/cian_zhk_url вторичка + rooms match + area±25%. (Tier B «тот же ЖК» skip: complex_id/cian_zhk_url
ненадёжны.) ненадёжны.)
@ -1816,7 +1997,7 @@ def _fetch_anchor_comps(
# ── Tier A: same building ──────────────────────────────────────────────── # ── Tier A: same building ────────────────────────────────────────────────
street, base_no, letter = _normalize_building_key(address) street, base_no, letter = _normalize_building_key(address)
if street and base_no is not None: if street and base_no is not None and lat is not None and lon is not None:
# ё→е в SQL для symmetry с нормализатором. psycopg v3: bind через :param, # ё→е в SQL для symmetry с нормализатором. psycopg v3: bind через :param,
# оператор ~. Boundary-regex вынесен в _house_boundary_regex (общий с # оператор ~. Boundary-regex вынесен в _house_boundary_regex (общий с
# Tier S radius-fallback ниже, см. _fetch_analogs). # Tier S radius-fallback ниже, см. _fetch_analogs).
@ -1835,11 +2016,20 @@ def _fetch_anchor_comps(
AND price_per_m2 > 0 AND price_per_m2 > 0
AND lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like AND lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like
AND lower(translate(address, 'ёЁ', 'ее')) ~ :house_re AND lower(translate(address, 'ёЁ', 'ее')) ~ :house_re
AND geom IS NOT NULL
AND ST_DWithin(
geom::geography,
ST_MakePoint(:lon, :lat)::geography,
:radius
)
""" """
), ),
{ {
"street_like": "%" + street + "%", "street_like": "%" + street + "%",
"house_re": house_re, "house_re": house_re,
"lon": lon,
"lat": lat,
"radius": ANCHOR_TIER_A_RADIUS_M,
}, },
) )
.mappings() .mappings()
@ -1964,12 +2154,17 @@ def _band_haircut(anchor_ppm2: float) -> float:
LOW audit #3: 0.04/0.07 (и mid из settings.asking_to_sold_haircut) — LOW audit #3: 0.04/0.07 (и mid из settings.asking_to_sold_haircut) —
EKB-secondary-market calibration constants, но применяются ENGINE-WIDE (нет EKB-secondary-market calibration constants, но применяются ENGINE-WIDE (нет
city-параметра ни здесь, ни у единственного вызывающего city-параметра ни здесь, ни у единственного вызывающего
`_compute_same_building_anchor`). Реального импакта на не-ЕКБ область пока нет `_compute_same_building_anchor`). #2581 update: та формулировка была НЕВЕРНОЙ —
(same-building anchor pool для oblast сейчас не формируется anchor_ppm2 сюда same-building anchor pool для oblast ВСЕГДА мог сформироваться (Tier A до
просто не доходит), но это доверие к отсутствию данных, а не к дизайну. Как #2581 не имел гео-предиката вообще, поэтому for oblast-subject'ов он либо
только oblast anchor pools появятся (см. #oblast-D fallback выше), эти пороги молча тянул ЕКБ-листинги по одноимённой улице/дому, либо для действительно
нужно пересмотреть/сделать per-city не оставлять ЕКБ-калибровку по умолчанию уникальных названий честно матчил местные листинги, если они были). После
для другого рынка. No behavior change here (doc-only). #2581 (ST_DWithin(ANCHOR_TIER_A_RADIUS_M) на Tier A) anchor_ppm2 ДЛЯ ОБЛАСТИ
доходит сюда легитимно (местные комплы того же дома в Серове/Тагиле/etc, если
они есть в БД), но всё ещё через ЕКБ-калиброванный haircut эти пороги
по-прежнему стоит пересмотреть/сделать per-city, не оставлять ЕКБ-калибровку
по умолчанию для другого рынка. No behavior change here (doc-only, кроме
исправления ложной посылки).
""" """
if anchor_ppm2 >= 350_000: if anchor_ppm2 >= 350_000:
return 0.04 return 0.04
@ -2367,6 +2562,12 @@ class PricingResult:
# headline. Anchor-путь → CV комплов (anchor["cv"]); radius-путь → CV # headline. Anchor-путь → CV комплов (anchor["cv"]); radius-путь → CV
# радиусной ₽/м²-выборки. None если <2 цен (недостаточно данных). # радиусной ₽/м²-выборки. None если <2 цен (недостаточно данных).
cv: float | None = None cv: float | None = None
# #oblast-E: >0 когда n листингов было найдено но ниже HEADLINE_LISTINGS_MIN_N
# (headline suppressed, listings_clean deliberately left intact — see gate
# comment above). Caller uses this to also keep the thin listings out of the
# display `analogs` cards when no anchor overrides the headline. 0 = either
# sufficient listings were used, or genuinely zero were found.
listings_headline_thin_n: int = 0
def _price_from_inputs( def _price_from_inputs(
@ -2445,10 +2646,45 @@ def _price_from_inputs(
n_analogs = 0 n_analogs = 0
cv = None cv = None
# 4b. Repair coefficient # 4a. #oblast-E sufficiency gate (see HEADLINE_LISTINGS_MIN_N docstring above).
# 1..HEADLINE_LISTINGS_MIN_N-1 listings are a real find but too thin to trust
# as a market median — suppress the AGGREGATE (median/range/n_analogs/cv)
# exactly like "no usable listings", so the anchor/#oblast-D-deals-fallback/
# insufficient_data chain below all take the already-honest zero-analogs
# path automatically (no new branches there). `listings_clean` itself is
# deliberately LEFT INTACT (not cleared) — the same-building anchor's own
# ghost-anchor guard (#1871, `if not listings_clean`) uses it to tell
# "genuinely zero nearby listings" from "some nearby listings, just too few
# to trust as THIS estimate's headline" — those are different confidence
# signals and clearing the list here would conflate them. The caller
# (estimate_quality) uses `listings_headline_thin_n` on the returned
# PricingResult to also keep suppressed listings out of the display
# `analogs` cards when no anchor overrides the headline (n_analogs
# invariant: cards shown ⊆ what n_analogs counts).
listings_headline_thin_n = 0
if 0 < n_analogs < HEADLINE_LISTINGS_MIN_N:
listings_headline_thin_n = n_analogs
logger.info(
"headline sufficiency gate #oblast-E: n=%d < %d listings — suppressing "
"listings-derived median (falling back to anchor/deals/insufficient_data)",
n_analogs,
HEADLINE_LISTINGS_MIN_N,
)
median_ppm2 = 0.0
q1_ppm2 = 0.0
q3_ppm2 = 0.0
median_price = 0
range_low = 0
range_high = 0
n_analogs = 0
cv = None
# 4b. Repair coefficient — skipped when the headline was thin-suppressed
# above (median_price is already 0; applying a coefficient would leave it
# 0 but still emit a misleading "adjusted for repair state" note).
repair_coef = _repair_coefficient(repair_state) repair_coef = _repair_coefficient(repair_state)
repair_note = "" repair_note = ""
if listings_clean and repair_coef != 1.0: if listings_clean and not listings_headline_thin_n and repair_coef != 1.0:
median_price = int(median_price * repair_coef) median_price = int(median_price * repair_coef)
range_low = int(range_low * repair_coef) range_low = int(range_low * repair_coef)
range_high = int(range_high * repair_coef) range_high = int(range_high * repair_coef)
@ -2489,6 +2725,20 @@ def _price_from_inputs(
area_widened, area_widened,
listings=listings_clean, listings=listings_clean,
) )
# #oblast-E: honest override — _compute_confidence's generic "не найдено
# аналогов" is FALSE here (we DID find listings_headline_thin_n of them,
# just too few to trust). Stays the final explanation unless a later block
# (anchor / #oblast-D deals-fallback) overwrites it with its OWN honest
# reasoning — both of those already check truthy `explanation` and either
# replace it (anchor) or append a construction-method clause that reads
# this same thin-count (deals-fallback), so no contradiction either way.
if listings_headline_thin_n:
confidence = "low"
explanation = (
f"Рядом найдено недостаточно объявлений ({listings_headline_thin_n} шт., "
f"минимум для оценки по рынку — {HEADLINE_LISTINGS_MIN_N}) — медиана по "
"такой маленькой выборке слишком чувствительна к случайным лотам."
)
# Tier note — информируем пользователя о качестве house-match # Tier note — информируем пользователя о качестве house-match
tier_note = "" tier_note = ""
@ -3041,9 +3291,20 @@ def _price_from_inputs(
# generic ghost-anchor). No repair_state adjustment: the deal corridor # generic ghost-anchor). No repair_state adjustment: the deal corridor
# mixes conditions across sold units — unlike the listings comp pool, # mixes conditions across sold units — unlike the listings comp pool,
# there is no per-unit signal to correct against. # there is no per-unit signal to correct against.
#
# #oblast-E: guards on `anchor is None` (the actual computed anchor dict),
# NOT `anchor_tier is None`. Found via live backtest-fixture regen: when
# `_compute_same_building_anchor` rejects a candidate outright (e.g. its
# own MAD-clip drops comps below estimate_sb_min_comps), it returns None
# WITHOUT the caller resetting `anchor_tier` back to None (it stays
# whatever `anchor_tier_fetched` was, e.g. "C") — the anchor never fired,
# but the stale tier flag falsely reads as "anchor claimed the headline"
# and blocked this fallback even with a large, valid ДКП corridor
# available (observed: 677 deals for one fixture case). `anchor is None`
# is the ground truth of whether the anchor actually produced a headline.
if ( if (
median_ppm2 <= 0 median_ppm2 <= 0
and anchor_tier is None and anchor is None
and dkp_raw is not None and dkp_raw is not None
and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N
and dkp_raw.get("median_ppm2", 0) > 0 and dkp_raw.get("median_ppm2", 0) > 0
@ -3055,16 +3316,27 @@ def _price_from_inputs(
n_analogs = 0 n_analogs = 0
confidence = "low" confidence = "low"
cv = None cv = None
# #oblast-E: differentiate "genuinely zero listings" (unchanged wording)
# from "found some but below HEADLINE_LISTINGS_MIN_N, suppressed above" —
# the latter must NOT claim "рядом нет объявлений" (false, contradicts the
# thin-sufficiency explanation already set above this block).
no_listings_clause = (
f" Из {listings_headline_thin_n} найденных объявлений недостаточно для "
"надёжной медианы —"
if listings_headline_thin_n
else " Рядом нет актуальных объявлений —"
)
explanation = (explanation or "") + ( explanation = (explanation or "") + (
" Рядом нет актуальных объявлений — оценка построена по реальным " f"{no_listings_clause} оценка построена по реальным "
f"сделкам Росреестра ({dkp_raw['count']} шт. за {dkp_raw['period_months']} мес.)," f"сделкам Росреестра ({dkp_raw['count']} шт. за {dkp_raw['period_months']} мес.),"
" точность ориентировочная." " точность ориентировочная."
) )
logger.info( logger.info(
"deals_headline_fallback #oblast-D: dkp median=%d (n=%d) → headline" "deals_headline_fallback #oblast-D: dkp median=%d (n=%d) → headline"
" (listings=0, anchor=None)", " (listings=0 [thin_suppressed=%d], anchor=None)",
int(median_ppm2), int(median_ppm2),
dkp_raw["count"], dkp_raw["count"],
listings_headline_thin_n,
) )
# ── #652: ДКП-коридор реальных сделок (advisory) ───────────────────────── # ── #652: ДКП-коридор реальных сделок (advisory) ─────────────────────────
@ -3196,6 +3468,7 @@ def _price_from_inputs(
sources_used_pre=sources_used_pre, sources_used_pre=sources_used_pre,
listings_clean=listings_clean, listings_clean=listings_clean,
cv=cv, cv=cv,
listings_headline_thin_n=listings_headline_thin_n,
) )
@ -3245,12 +3518,13 @@ async def estimate_quality(
detail="consent required for anonymous estimate request", detail="consent required for anonymous estimate request",
) )
# 1. Geocode (#654: time-budgeted — Yandex/Nominatim retry chain can stack # 1. Geocode (#654: time-budgeted — Nominatim retry chain can stack
# multiple network round-trips + 1s Nominatim rate-limit sleeps). # multiple network round-trips + 1s Nominatim rate-limit sleeps).
geo: GeocodeResult | None = None geo: GeocodeResult | None = None
# Variant A: trust client-provided coords (resolved by autocomplete/map) when present # Variant A: trust client-provided coords (resolved by autocomplete/map) when present
# and inside the oblast bbox — skips the geocode() chain that fails on DaData-format # and inside the oblast bbox — skips the geocode() chain that fails on DaData-format
# addresses with the Yandex key dead. Out-of-bbox / partial → ignore, geocode normally. # addresses (#2593: Yandex Geocoder, the previous fallback for those, removed).
# Out-of-bbox / partial → ignore, geocode normally.
# (oblast C2): was tight EKB-only bbox (60.40-60.85 / 56.65-56.95) — widened to # (oblast C2): was tight EKB-only bbox (60.40-60.85 / 56.65-56.95) — widened to
# geocoder.is_within_oblast66_bbox (region 66) so client-coords from oblast towns also # geocoder.is_within_oblast66_bbox (region 66) so client-coords from oblast towns also
# get this perf fast-path instead of always paying the geocode() round-trip. Perf-only, # get this perf fast-path instead of always paying the geocode() round-trip. Perf-only,
@ -3274,8 +3548,12 @@ async def estimate_quality(
payload.lon, payload.lon,
) )
if geo is None and payload.address: if geo is None and payload.address:
# #2576: city_hint прокидывается из payload — БЕЗ него geocode() больше не
# подставляет "Екатеринбург" молча (см. app.services.geocoder). Опционально:
# фронт пока (до отдельного изменения UI) его не шлёт, geo.city_ambiguous
# честно сигнализирует об этом ниже.
geo = await _with_budget( geo = await _with_budget(
geocode(payload.address, db), geocode(payload.address, db, city_hint=payload.city_hint),
settings.estimate_geocode_budget_s, settings.estimate_geocode_budget_s,
label="geocode", label="geocode",
) )
@ -3380,6 +3658,16 @@ async def estimate_quality(
if target_house_type is None: if target_house_type is None:
target_house_type = house_meta.house_type target_house_type = house_meta.house_type
# 2b. Mera-audit 2026-08-02: неправдоподобный год (payload user-input ge=1800/le=2100 в схеме,
# либо house_metadata OSM/кадастр — прод-инцидент year_built=1829) — на
# «неизвестен» ДО того как target_year уйдёт в cohort-фильтр (ниже),
# _fetch_analogs house-match scoring и хедонический фактор
# (_price_from_inputs, #2002). Единая точка входа — все три места ниже
# используют этот же target_year.
target_year = _sanitize_build_year(
target_year, house_id=target_house_id, address=payload.address
)
# 3. Four-tier fallback (PR 9 — added Tier 0 with cohort filter): # 3. Four-tier fallback (PR 9 — added Tier 0 with cohort filter):
# 0) 1km + ±15% area + cohort match (year_built — если задан) # 0) 1km + ±15% area + cohort match (year_built — если задан)
# a) 1km + ±15% area (без cohort — drop fallback) # a) 1km + ±15% area (без cohort — drop fallback)
@ -3392,6 +3680,13 @@ async def estimate_quality(
# радиус (он же — максимум, без авто-расширения за пределы выбранного). # радиус (он же — максимум, без авто-расширения за пределы выбранного).
base_radius_m = payload.radius_m or DEFAULT_RADIUS_M base_radius_m = payload.radius_m or DEFAULT_RADIUS_M
fallback_radius_m = payload.radius_m or FALLBACK_RADIUS_M fallback_radius_m = payload.radius_m or FALLBACK_RADIUS_M
# #2632: фактический радиус, по которому реально отобраны listings-аналоги
# (в отличие от payload.radius_m — выбор пользователя в дропдауне). Стартует
# с base_radius_m, переключается на fallback_radius_m в тех же ветках, что
# выставляют fallback_used ниже (см. _compute_confidence "расширили радиус"
# note) — держим оба сигнала консистентными по построению. Прокидывается в
# AggregatedEstimate.search_radius_m для карты (ParamsPanel circle, #2632).
search_radius_m = base_radius_m
cohort_range = _target_cohort_range(target_year) cohort_range = _target_cohort_range(target_year)
if cohort_range is not None: if cohort_range is not None:
@ -3456,6 +3751,7 @@ async def estimate_quality(
listings = listings_wide listings = listings_wide
fallback_used = True fallback_used = True
analog_tier = analog_tier_wide analog_tier = analog_tier_wide
search_radius_m = fallback_radius_m
# Tier C: если даже на 2км мало — расширяем area tolerance до ±25% # Tier C: если даже на 2км мало — расширяем area tolerance до ±25%
# (актуально для отдалённых районов / новостроек с нестандартной планировкой) # (актуально для отдалённых районов / новостроек с нестандартной планировкой)
@ -3480,6 +3776,7 @@ async def estimate_quality(
fallback_used = True fallback_used = True
area_widened = True area_widened = True
analog_tier = analog_tier_wa analog_tier = analog_tier_wa
search_radius_m = fallback_radius_m
# ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ────────────── # ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ──────────────
# #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы # #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы
@ -3627,7 +3924,8 @@ async def estimate_quality(
def _ratio_resolver( def _ratio_resolver(
appm2: float | None, appm2: float | None,
) -> tuple[float | None, str | None]: ) -> tuple[float | None, str | None]:
return _get_asking_sold_ratio(db, payload.rooms, anchor_ppm2=appm2) # #2620-2: area-bucket key (payload.rooms — фолбэк только без площади).
return _get_asking_sold_ratio(db, payload.rooms, payload.area_m2, anchor_ppm2=appm2)
def _qi_lookup(q: str) -> tuple[float, int] | None: def _qi_lookup(q: str) -> tuple[float, int] | None:
return _lookup_quarter_index( return _lookup_quarter_index(
@ -3692,6 +3990,7 @@ async def estimate_quality(
ratio_basis = pr.ratio_basis ratio_basis = pr.ratio_basis
listings_clean = pr.listings_clean listings_clean = pr.listings_clean
cv = pr.cv cv = pr.cv
listings_headline_thin_n = pr.listings_headline_thin_n
# 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals. # 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals.
# Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков # Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков
@ -3727,6 +4026,14 @@ async def estimate_quality(
# иначе «обновлено N мин назад»/дата парсинга/срок продажи относятся к другому # иначе «обновлено N мин назад»/дата парсинга/срок продажи относятся к другому
# набору (или = None при пустом listings_clean, хотя у комплов данные есть). # набору (или = None при пустом listings_clean, хотя у комплов данные есть).
metadata_lots = display_pool metadata_lots = display_pool
elif listings_headline_thin_n:
# #oblast-E: headline was suppressed (thin radius sample, no anchor to
# take over) — do NOT surface those same listings as display cards
# either, else `analogs` would show N cards while n_analogs==0 (broken
# invariant, same dishonesty this gate exists to remove). Degrades to
# the exact same empty-display state as "genuinely zero listings".
analogs_lots = []
metadata_lots = []
else: else:
# display-consistency fix: только ЦЕНОВЫЕ листинги — та же популяция, что # display-consistency fix: только ЦЕНОВЫЕ листинги — та же популяция, что
# дала n_analogs = len(prices_ppm2) в radius-ветке _price_from_inputs. # дала n_analogs = len(prices_ppm2) в radius-ветке _price_from_inputs.
@ -3984,6 +4291,7 @@ async def estimate_quality(
target_address=geo.full_address, target_address=geo.full_address,
target_lat=geo.lat, target_lat=geo.lat,
target_lon=geo.lon, target_lon=geo.lon,
target_city_ambiguous=geo.city_ambiguous,
sources_used=sources_used, sources_used=sources_used,
data_freshness_minutes=freshness_min, data_freshness_minutes=freshness_min,
last_scraped_at=last_scraped_at, last_scraped_at=last_scraped_at,
@ -4033,6 +4341,11 @@ async def estimate_quality(
metro_nearest=(dadata.metro if dadata and dadata.metro else []), metro_nearest=(dadata.metro if dadata and dadata.metro else []),
address_precision=_qc_geo_to_precision(dadata.qc_geo if dadata else None), address_precision=_qc_geo_to_precision(dadata.qc_geo if dadata else None),
analog_tier=api_analog_tier, # type: ignore[arg-type] analog_tier=api_analog_tier, # type: ignore[arg-type]
# #2632: фактический радиус отбора listings-аналогов (см. search_radius_m
# def выше) — может отличаться от payload.radius_m (выбор пользователя),
# когда сервер сам расширил поиск. None только у _empty_estimate (поиск
# аналогов вообще не выполнялся).
search_radius_m=search_radius_m,
premium_building=premium_building, premium_building=premium_building,
premium_building_median_ppm2=premium_building_median_ppm2, premium_building_median_ppm2=premium_building_median_ppm2,
premium_building_class=premium_building_class, premium_building_class=premium_building_class,
@ -5621,11 +5934,6 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]:
return street, house return street, house
def _extract_street_token(addr: str | None) -> str:
"""Нормализованный уличный токен для дедуп-ключа (#2265). См. _parse_street_house."""
return _parse_street_house(addr)[0]
def _lot_dedup_components( def _lot_dedup_components(
lot: dict[str, Any], lot: dict[str, Any],
*, *,
@ -5663,15 +5971,13 @@ def _lot_dedup_components(
return cad_s, house, cad_key, street_key return cad_s, house, cad_key, street_key
def _phys_dedup_key(lot: dict[str, Any]) -> tuple[str, Any, int, int] | None: # #2674: здесь жили `_phys_dedup_key` и `_extract_street_token` — однострочные обёртки
"""Первичный физический ключ (building, floor, area_bucket, price_bucket). # над _lot_dedup_components / _parse_street_house. Прод не звал ни ту, ни другую ни разу
# (25 ссылок, все из тестов). Хуже: _phys_dedup_key утверждала правило «первичный ключ =
building = cadnum (надёжнее) ИЛИ street_token (#2265). None, если нет # кадастр ИЛИ улица», которого в проде нет — живой путь (_union_find_phys_dedup) держит
площади/цены или не из чего построить building. Сохраняет 4-кортежную форму # ОБА композита и сливает по любому совпадению, с guard'ами на разные кадастры/номера
(canonical-ключ; union-find в _dedup_cross_source использует оба композита). # домов. Тесты, проверявшие обёртку, проверяли не тот алгоритм; они переведены на живые
""" # функции (tests/test_estimator_dedup_cross_source_2087.py).
_cad_s, _house, cad_key, street_key = _lot_dedup_components(lot)
return cad_key or street_key
def _dedup_rep_key(lot: dict[str, Any]) -> tuple[float, int, str, str]: def _dedup_rep_key(lot: dict[str, Any]) -> tuple[float, int, str, str]:

File diff suppressed because it is too large Load diff

View file

@ -132,9 +132,16 @@ _COMPLETENESS_EXPR = """
# Keeper ORDER BY, shared by the ROW_NUMBER() rank and the first_value() keeper pick so they # 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. # 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-перенос полей неполон (год постройки / тип дома /
# этажность / застройщик не переносятся) — данные богатого проигравшего удаляются безвозвратно.
_KEEPER_ORDER = f""" _KEEPER_ORDER = f"""
(h.geom IS NOT NULL) DESC, (h.geom IS NOT NULL) DESC,
listing_cnt DESC, listing_cnt DESC NULLS LAST,
({_COMPLETENESS_EXPR}) DESC, ({_COMPLETENESS_EXPR}) DESC,
h.id ASC h.id ASC
""" """

View file

@ -30,6 +30,7 @@ from dataclasses import dataclass, field
from typing import Literal from typing import Literal
from scraper_kit.browser_fetcher import BrowserFetcher from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.house_type_normalizer import normalize_house_type
# #2337 (Group E4, эпик #2277): переключено на scraper_kit — тот же периметр риска, # #2337 (Group E4, эпик #2277): переключено на scraper_kit — тот же периметр риска,
# что и estimator.py (обе точки читают/пишут house_imv_evaluations, #651 IMV/Yandex # что и estimator.py (обе точки читают/пишут house_imv_evaluations, #651 IMV/Yandex
@ -65,22 +66,76 @@ _HEARTBEAT_EVERY_N_HOUSES = 5
# ── house_type normalisation ───────────────────────────────────────────────── # ── house_type normalisation ─────────────────────────────────────────────────
# Ключи — КАНОНИЧНЫЕ значения listings.house_type (после normalize_house_type),
# значения — вокабуляр Avito IMV.
_HOUSE_TYPE_TO_IMV: dict[str, str] = { _HOUSE_TYPE_TO_IMV: dict[str, str] = {
"panel": "panel", "panel": "panel",
"brick": "brick", "brick": "brick",
"monolith": "monolithic", "monolith": "monolithic",
"monolithic": "monolithic",
"monolith_brick": "monolithic", # Avito API не принимает гибриды "monolith_brick": "monolithic", # Avito API не принимает гибриды
"block": "block", "block": "block",
"wood": "wood", "wood": "wood",
} }
_HOUSE_TYPE_DEFAULT = "panel" # самый распространённый в ЕКБ
def _map_house_type(raw: str | None) -> str: def _map_house_type(raw: str | None) -> str | None:
if not raw: """Наш house_type → вокабуляр Avito IMV. None = тип неизвестен, запрос не шлём.
return _HOUSE_TYPE_DEFAULT
return _HOUSE_TYPE_TO_IMV.get(raw.lower().strip(), _HOUSE_TYPE_DEFAULT) Сырое значение сначала прогоняем через общий 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_repairrequired / standardcosmetic / goodeuro /
excellentdesigner). Импорт ленивый: 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 ──────────────────────────────────── # ── Region bbox prefix для Avito geocoder ────────────────────────────────────
@ -135,7 +190,8 @@ def pick_lot_params(db: Session, house_id: int) -> dict:
AS integer) AS floor, AS integer) AS floor,
CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY total_floors) CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY total_floors)
AS integer) AS 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 FROM listings
WHERE house_id_fk = :hid WHERE house_id_fk = :hid
AND rooms IS NOT NULL AND rooms IS NOT NULL
@ -173,7 +229,12 @@ def pick_lot_params(db: Session, house_id: int) -> dict:
"floor": floor, "floor": floor,
"floor_at_home": floor_at_home, "floor_at_home": floor_at_home,
"house_type": _map_house_type(row["house_type"] or (house and house["house_type"])), "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_balcony": True,
"has_loggia": False, "has_loggia": False,
} }
@ -284,26 +345,37 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat
) )
# 3. Suggestions # 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: for sug in result.suggestions:
db.execute( db.execute(
text(""" text("""
INSERT INTO house_suggestions ( INSERT INTO house_suggestions (
house_id, ext_item_id, title, address, price_rub, house_id, ext_item_id, title, address, price_rub,
area_m2, rooms, floor, total_floors,
exposure_days, publish_date, 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 has_good_price_badge, raw_payload, fetched_at
) VALUES ( ) VALUES (
:hid, :ext, :title, :addr, :price, :hid, :ext, :title, :addr, :price,
CAST(:area AS numeric), :rooms, :floor, :total_floors,
:exp, :pdate, :exp, :pdate,
:link, :mname, :mdist, :link, :img, :mname, :mdist,
:gpb, CAST(:raw AS jsonb), NOW() :gpb, CAST(:raw AS jsonb), NOW()
) )
ON CONFLICT (house_id, ext_item_id) DO UPDATE SET ON CONFLICT (house_id, ext_item_id) DO UPDATE SET
title = EXCLUDED.title, title = EXCLUDED.title,
price_rub = EXCLUDED.price_rub, 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, exposure_days = EXCLUDED.exposure_days,
publish_date = EXCLUDED.publish_date, publish_date = EXCLUDED.publish_date,
item_link = EXCLUDED.item_link, item_link = EXCLUDED.item_link,
image_link = EXCLUDED.image_link,
metro_name = EXCLUDED.metro_name, metro_name = EXCLUDED.metro_name,
metro_distance = EXCLUDED.metro_distance, metro_distance = EXCLUDED.metro_distance,
has_good_price_badge = EXCLUDED.has_good_price_badge, 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, "title": sug.title,
"addr": sug.address, "addr": sug.address,
"price": sug.price_rub, "price": sug.price_rub,
"area": sug.area_m2,
"rooms": sug.rooms,
"floor": sug.floor,
"total_floors": sug.total_floors,
"exp": sug.exposure_days, "exp": sug.exposure_days,
"pdate": sug.publish_date, "pdate": sug.publish_date,
"link": sug.item_url, "link": sug.item_url,
"img": sug.image_link,
"mname": sug.metro_name, "mname": sug.metro_name,
"mdist": sug.metro_distance, "mdist": sug.metro_distance,
"gpb": sug.has_good_price_badge, "gpb": sug.has_good_price_badge,
@ -500,8 +577,33 @@ async def backfill_house_imv(
# + прокси переиспользуются всеми домами; обходит datacenter-403, #562/#853). # + прокси переиспользуются всеми домами; обходит datacenter-403, #562/#853).
# Флаг OFF → _bf=None → evaluate_via_imv делает свою curl-сессию как раньше # Флаг 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: 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) await _run_loop(_bf)
else: else:
await _run_loop(None) await _run_loop(None)
@ -652,6 +754,14 @@ async def _process_one_house(
_mark_status(db, hid, "no_params", "no listings with rooms+area") _mark_status(db, hid, "no_params", "no listings with rooms+area")
return "no_params" 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") address = house.get("address") or house.get("full_address")
if not address: if not address:
_mark_status(db, hid, "no_address", "house.address is NULL") _mark_status(db, hid, "no_address", "house.address is NULL")

View file

@ -0,0 +1,291 @@
"""Единственное место, знающее, В КАКОЙ БД и В КАКИХ ТАБЛИЦАХ живёт identity.
Эпик «единый вход»: люди «Меры» (trade-in) и «Птицы» (Site Finder) переезжают в
общую БД `auth` (`users` / `sessions`, миграции data/sql/auth/001-004), а
`tradein_users` в итоге удаляется. Переезд идёт под флагом
`settings.identity_store`, дефолт которого = СТАРОЕ поведение:
"tradein" (ДЕФОЛТ) БД tradein, tradein_users / tradein_sessions;
"auth" БД auth, users / sessions.
Смысл модуля: во всём остальном коде не должно быть ни одного упоминания
конкретной БД, конкретных имён таблиц и того, каким столбцом выражено состояние
доступа. Кто хочет читать/писать людей и сессии спрашивает здесь.
Что модуль отдаёт вызывающему:
* `identity_session()` / `get_identity_db()` сессия ТОЙ БД, которая сейчас
является реестром (для "tradein" это ровно `app.core.db.SessionLocal`, то
есть сегодняшний прод-путь без единого лишнего коннекта);
* `identity_schema()` имена таблиц users/sessions и имя колонки состояния
доступа;
* `AccessState` + `to_access_state()` ОДНО понятие «состояние доступа» для
обеих схем.
Схемы `tradein_users` и `auth.users` совпадают, кроме состояния доступа:
`tradein_users.is_active` boolean, `auth.users.access_state` text из трёх
значений (`active` / `trial_expired` / `disabled`, семантика в COMMENT'е
миграции 004). Вызывающий код обязан работать с ОДНИМ понятием: он читает
колонку `schema.access_state_column` и прогоняет значение через
`to_access_state()`. Второго представления состояния в коде быть не должно
`if row.is_active` вне этого модуля больше не пишем.
Как СПРАШИВАТЬ состояние доступа (канонический вызов):
schema = identity_schema()
with identity_session() as db:
row = db.execute(
text(
f"SELECT u.id, u.username, u.role, "
f" u.{schema.access_state_column} AS access_state "
f" FROM {schema.users_table} u "
f" WHERE u.username = :username"
),
{"username": username},
).fetchone()
state = to_access_state(row.access_state)
if not state.can_sign_in:
... # 401 для disabled, отдельный 403 для AccessState.TRIAL_EXPIRED
Значение подставляется bind-параметром (`:username`), имя таблицы и имя колонки
из `schema`, то есть из фиксированного словаря; в SQL-строку не попадает ничего,
пришедшего снаружи.
Как ПИСАТЬ состояние доступа (обратное направление, `access_state_param()`):
db.execute(
text(
f"UPDATE {schema.users_table} "
f" SET {schema.access_state_column} = :access_state "
f" WHERE id = :id"
),
{"access_state": access_state_param(AccessState.DISABLED), "id": user_id},
)
Литералов `True` / `'active'` по месту быть не должно: тип колонки разный, и
единственное место, знающее какой, этот модуль.
SQL-инъекция по имени таблицы: имена таблиц/колонок в SQL нельзя передать
bind-параметром, поэтому они подставляются в строку запроса. Единственный
допустимый источник фиксированный словарь `_SCHEMAS` НИЖЕ. Никакой
конкатенации с внешним вводом (заголовок, тело запроса, переменная окружения,
имя роли) значение `settings.identity_store` ограничено `Literal` в pydantic,
и лукап по нему делается только здесь.
"""
from __future__ import annotations
import logging
from collections.abc import Generator, Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from enum import StrEnum
from typing import Annotated
from fastapi import Depends
from sqlalchemy.orm import Session
from app.core import auth_db
from app.core.config import settings
from app.core.db import SessionLocal, get_db
logger = logging.getLogger(__name__)
class AccessState(StrEnum):
"""Состояние доступа аккаунта — ЕДИНОЕ понятие для обеих схем.
Значения дословно совпадают с `auth.users.access_state` (CHECK-констрейнт
`users_access_state_ck`, миграция 004); булев `tradein_users.is_active`
приводится сюда в `to_access_state()`.
Семантика (COMMENT миграции 004, решение владельца от 2026-07-31):
active вход разрешён;
trial_expired пароль ВЕРНЫЙ, но пробный период истёк: отдельный 403 и
экран «пробный доступ закончился», сессия не выдаётся;
disabled доступ закрыт: generic 401, для пользователя неотличимо от
неверного пароля.
Неверный пароль в ЛЮБОМ состоянии generic 401, иначе отдельный ответ для
trial_expired превращается в оракул существования логина.
"""
ACTIVE = "active"
TRIAL_EXPIRED = "trial_expired"
DISABLED = "disabled"
@property
def can_sign_in(self) -> bool:
"""True только для `active` — единственная проверка «пускать ли».
Вынесена в свойство, чтобы вызывающий не писал `state == "active"`:
добавится четвёртое состояние оно по умолчанию окажется «не пускать»,
а не «пускать, потому что не disabled».
"""
return self is AccessState.ACTIVE
@dataclass(frozen=True, slots=True)
class IdentitySchema:
"""Где физически лежит identity при текущем значении флага.
Attributes:
store: значение `settings.identity_store`, которому соответствует схема.
users_table: имя таблицы людей.
sessions_table: имя таблицы сессий.
access_state_column: имя колонки состояния доступа. Значение из неё
ОБЯЗАНО пройти через `to_access_state()` тип отличается между
схемами (boolean против text).
access_state_sql_type: SQL-тип этой колонки для `CAST(:param AS ...)`.
Нужен там, где параметр может быть NULL (`COALESCE(CAST(:x AS T), col)`
в PATCH «Команды»): без явного типа Postgres не может вывести тип
NULL-параметра. Значение литерал из `_SCHEMAS`, в SQL-строку
снаружи ничего не попадает.
"""
store: str
users_table: str
sessions_table: str
access_state_column: str
access_state_sql_type: str
# Фиксированный словарь — ЕДИНСТВЕННЫЙ источник имён таблиц/колонок для SQL.
# Ключи = допустимые значения settings.identity_store (Literal в pydantic).
_SCHEMAS: dict[str, IdentitySchema] = {
"tradein": IdentitySchema(
store="tradein",
users_table="tradein_users",
sessions_table="tradein_sessions",
access_state_column="is_active",
access_state_sql_type="boolean",
),
"auth": IdentitySchema(
store="auth",
# В БД `auth` таблицы лежат без префикса продукта — реестр общий
# (data/sql/auth/001_identity_schema.sql).
users_table="users",
sessions_table="sessions",
access_state_column="access_state",
access_state_sql_type="text",
),
}
def identity_schema() -> IdentitySchema:
"""Схема реестра для текущего значения `settings.identity_store`.
Читается на КАЖДОМ вызове, а не кешируется на импорте: тесты и
переключение флага не должны требовать перезагрузки модулей.
"""
schema = _SCHEMAS.get(settings.identity_store)
if schema is None:
# Недостижимо через настройки (Literal валидируется pydantic), но
# молчаливый fallback здесь означал бы поход не в ту БД.
raise ValueError(f"неизвестный identity_store={settings.identity_store!r}")
return schema
@contextmanager
def identity_session() -> Iterator[Session]:
"""Сессия БД, в которой сейчас живёт identity.
"tradein" `app.core.db.SessionLocal` (та же БД и тот же пул, что у всего
остального приложения сегодняшнее поведение прода без изменений).
"auth" ленивый engine `app.core.auth_db`; пустой `AUTH_DATABASE_URL`
здесь поднимет `AuthDatabaseNotConfiguredError`, а не отдаст пустой
результат.
"""
if settings.identity_store == "auth":
with auth_db.auth_session() as db:
yield db
else:
with SessionLocal() as db:
yield db
def get_identity_db(
db: Annotated[Session, Depends(get_db)],
) -> Generator[Session, None, None]:
"""FastAPI-зависимость: `db: Annotated[Session, Depends(get_identity_db)]`.
Аналог `app.core.db.get_db`, но для реестра людей. Роуты, работающие с
identity, обязаны брать сессию отсюда иначе при `identity_store="auth"`
они уйдут запросом в БД tradein, где нужных таблиц уже не будет.
При `identity_store="tradein"` отдаётся РОВНО ТОТ ЖЕ объект `Session`,
что и у `Depends(get_db)` не новая сессия к той же БД. Это не экономия
коннекта, а требование «прод обязан работать точно как сейчас»: роуты
«Команды» пишут в ОДНОЙ транзакции строку сотрудника (реестр) и его квоту
(`account_quota_overrides`, продуктовая таблица). Две сессии = две
транзакции = состояние «сотрудник создан, квота нет» на ровном месте.
FastAPI кеширует результат `Depends(get_db)` в пределах запроса, поэтому
роут, объявивший ОБЕ зависимости, в этом режиме получает один и тот же
объект, и `db is identity_db` честный рантайм-признак «одна БД».
При `identity_store="auth"` это разные БД физически, и одной транзакции
быть не может (двухфазный коммит здесь не заводим): вызывающий код обязан
коммитить обе сессии и понимать порядок см. `app.api.v1.team`.
Зависимость `get_db` при этом всё равно резолвится, но `Session` ленив
без единого запроса он коннект не открывает, так что лишнего соединения с
БД tradein не появляется.
"""
if settings.identity_store != "auth":
yield db
return
with auth_db.auth_session() as identity_db:
yield identity_db
def to_access_state(value: object) -> AccessState:
"""Приводит значение колонки состояния доступа к `AccessState`.
ЕДИНСТВЕННОЕ место, где булев `tradein_users.is_active` превращается в
трёхзначное состояние: True `active`, False `disabled` (жёсткая
блокировка, generic 401 ровно то, что булева схема и означала).
`trial_expired` в булевой схеме выразить нечем: состояния там не
существовало, и на tradein-пути оно не появится.
Fail-closed: неизвестная строка, NULL и любой неожиданный тип `disabled` +
WARNING. Обратный выбор (пускать всё, что не `disabled`) означал бы, что
новое состояние, добавленное миграцией раньше кода, молча раздаёт доступ.
"""
if isinstance(value, bool):
return AccessState.ACTIVE if value else AccessState.DISABLED
if isinstance(value, str):
try:
return AccessState(value)
except ValueError:
logger.warning(
"identity_store: неизвестное состояние доступа %r → трактую как disabled", value
)
return AccessState.DISABLED
logger.warning(
"identity_store: состояние доступа %r неожиданного типа %s → трактую как disabled",
value,
type(value).__name__,
)
return AccessState.DISABLED
def access_state_param(state: AccessState) -> bool | str:
"""Значение для ЗАПИСИ в `schema.access_state_column` — обратная к `to_access_state()`.
Тип колонки разный (boolean против text), поэтому конверсию нельзя оставить
вызывающему: он бы неизбежно писал `True`/`'active'` по месту, и это ровно
то второе представление состояния, которого в коде быть не должно.
Для булевой схемы `trial_expired` невыразим там существуют только «пустят»
и «не пустят», и попытка записать промежуточное состояние молча стала бы
жёсткой блокировкой (клиент увидел бы «неверный пароль» вместо экрана
пробного периода). Поэтому это ошибка вызывающего, а не тихое приведение:
писать `trial_expired` можно только при `identity_store="auth"`.
"""
schema = identity_schema()
if schema.access_state_sql_type == "boolean":
if state is AccessState.TRIAL_EXPIRED:
raise ValueError(
f"состояние {state.value!r} невыразимо в схеме {schema.store!r} "
f"(колонка {schema.access_state_column} — boolean): доступны только "
f"{AccessState.ACTIVE.value!r} и {AccessState.DISABLED.value!r}"
)
return state.can_sign_in
return state.value

View file

@ -13,9 +13,13 @@
POI-score его не улавливал (POI ranking цена). POI-score его не улавливал (POI ranking цена).
НОВЫЙ ПОКАЗАТЕЛЬ (location index): НОВЫЙ ПОКАЗАТЕЛЬ (location index):
location_index_pct = (медиана /м² сопоставимых активных листингов в радиусе точки location_index_pct = (медиана /м² сопоставимых листингов в радиусе точки
медиана /м² по всему ЕКБ) / медиана по ЕКБ * 100 медиана /м² по всему ЕКБ) / медиана по ЕКБ * 100
«Сопоставимые» = ровно тот же пул, что берёт эстиматор (#2660): активные И свежие
(scraped_at в пределах LISTINGS_FRESH_DAYS `is_active` на проде не равно «живо») И
только вторичка (гард #1186 — девелоперский прайс новостроек завышал обе медианы).
Самообновляем (те же `listings`, что уже скрейпятся под estimator), интерпретируем напрямую Самообновляем (те же `listings`, что уже скрейпятся под estimator), интерпретируем напрямую
("район на N% дороже/дешевле среднего по городу"), устойчив к выбросам (percentile_cont(0.5) ("район на N% дороже/дешевле среднего по городу"), устойчив к выбросам (percentile_cont(0.5)
медиана самой природой игнорирует единичные экстремумы, в отличие от mean/min/max), и НЕ зажат медиана самой природой игнорирует единичные экстремумы, в отличие от mean/min/max), и НЕ зажат
@ -45,6 +49,11 @@ from typing import Any
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import text from sqlalchemy import text
# #2660: окно свежести берём ИЗ эстиматора — единственное определение в проекте.
# Дублировать значение здесь нельзя: две константы разъедутся при первой же
# перекалибровке, и витрина начнёт показывать другой пул, чем считает цена.
from app.services.estimator import LISTINGS_FRESH_DAYS
logger = logging.getLogger(__name__) 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 BETWEEN sanity-границы — не бизнес-калибровка, а защита от битых строк
# (см. _PRICE_PER_M2_SANITY_MIN/MAX выше). # (см. _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) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если # bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если
# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index # сам продукт уже скрейпит соседние города области (city-sweep): географию location_index
# явно ограничил владелец продукта. # явно ограничил владелец продукта.
@ -173,6 +219,8 @@ _MEDIAN_PPM2_LOCAL_SQL = text(
AND price_per_m2 IS NOT NULL AND price_per_m2 IS NOT NULL
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer) AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
AND (geo_precision IS DISTINCT FROM 'city') 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 lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision) AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west 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 IS NOT NULL
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer) AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
AND (geo_precision IS DISTINCT FROM 'city') 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 lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision) AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west 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, "lat": lat,
"lon": lon, "lon": lon,
"radius_m": radius_m, "radius_m": radius_m,
"fresh_days": LISTINGS_FRESH_DAYS,
"price_min": _PRICE_PER_M2_SANITY_MIN, "price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX, "price_max": _PRICE_PER_M2_SANITY_MAX,
"bbox_south": _EKB_BBOX_SOUTH, "bbox_south": _EKB_BBOX_SOUTH,
@ -257,6 +308,7 @@ def _citywide_median_ppm2(db: Any) -> tuple[float | None, int]:
db.execute( db.execute(
_MEDIAN_PPM2_CITYWIDE_SQL, _MEDIAN_PPM2_CITYWIDE_SQL,
{ {
"fresh_days": LISTINGS_FRESH_DAYS,
"price_min": _PRICE_PER_M2_SANITY_MIN, "price_min": _PRICE_PER_M2_SANITY_MIN,
"price_max": _PRICE_PER_M2_SANITY_MAX, "price_max": _PRICE_PER_M2_SANITY_MAX,
"bbox_south": _EKB_BBOX_SOUTH, "bbox_south": _EKB_BBOX_SOUTH,

View file

@ -1,11 +1,40 @@
"""House cross-source matching — tiered algorithm. """House cross-source matching — tiered algorithm.
Tier 0 (confidence 1.0): cadastral_number exact match on houses table. `match_or_create_house` (путь скрейпинга, создаёт дома):
Tier 0.5 (confidence 0.95): house_fias_id (ГАР OBJECTGUID) exact match, case-insensitive. 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 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 2 (confidence 0.9): address_fingerprint match in house_address_aliases.
Tier 3 (confidence 0.7): geo-proximity within 30 m (PostGIS ST_DWithin). Tier 3 (confidence 0.7): geo-proximity within 30 m (PostGIS ST_DWithin).
New (confidence 1.0): INSERT new canonical house. 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 Algorithm reference: decisions/Cross_Source_Matching_Strategy.md sec 3
""" """
@ -46,8 +75,6 @@ def match_or_create_house(
*, *,
year_built: int | None = None, year_built: int | None = None,
building_cadastral_number: str | None = None, building_cadastral_number: str | None = None,
cadastral_number: str | None = None,
house_fias_id: str | None = None,
source_url: str | None = None, source_url: str | None = None,
) -> tuple[int | None, float, str]: ) -> tuple[int | None, float, str]:
"""Match existing house or create new canonical record. """Match existing house or create new canonical record.
@ -58,21 +85,18 @@ def match_or_create_house(
for an unknown address could both miss Tier 0-3 and each INSERT a duplicate 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. house row. Closes finding #1 from 2026-05-24 audit.
Args: NB: параметра `house_fias_id` здесь НЕТ намеренно (#2674) — см. шапку модуля.
house_fias_id: ГАР OBJECTGUID (UUID) of the building, when known upstream ФИАС-тир живёт только в `match_house_readonly`, у которого есть источник ФИАС.
(e.g. DaData /clean/address). Enables Tier 0.5 fias_exact additive and
optional, existing callers are unaffected.
Returns: Returns:
(house_id, confidence [0.0, 1.0], method { (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' 'geo_proximity', 'new', 'no_house_number'
}) })
house_id is None only for the 'no_house_number' terminal case below. house_id is None only for the 'no_house_number' terminal case below.
Method values: Method values:
'cadastr_exact' matched by cadastral number (confidence 1.0) '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) 'source_exact' already in house_sources for this source+ext_id (confidence 1.0)
'fingerprint' matched by address fingerprint (confidence 0.9) 'fingerprint' matched by address fingerprint (confidence 0.9)
'geo_proximity' matched by geo within 30 m (confidence 0.7) 'geo_proximity' matched by geo within 30 m (confidence 0.7)
@ -87,7 +111,16 @@ def match_or_create_house(
'р-н Чкаловский, мкр. Вторчермет' 480). A cadastral number is a precise building 'р-н Чкаловский, мкр. Вторчермет' 480). A cadastral number is a precise building
identity, so cad-carrying rows stay exempt (Tier 0 owns them). 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. # Compute fingerprint early so we can acquire the advisory lock before any tier reads.
fp = address_fingerprint(address, lat, lon) fp = address_fingerprint(address, lat, lon)
@ -136,34 +169,10 @@ def match_or_create_house(
logger.info("house match cadastr_exact house_id=%s cad=%s", house_id, cad) logger.info("house match cadastr_exact house_id=%s cad=%s", house_id, cad)
return (house_id, 1.0, "cadastr_exact") return (house_id, 1.0, "cadastr_exact")
# Tier 0.5: house_fias_id (ГАР OBJECTGUID) exact match, case-insensitive. # Tier 0.5 fias_exact удалён (#2674): передать `house_fias_id` в этот путь было
# Stable ORDER BY id so concurrent/duplicate rows resolve deterministically. # некому — ни Protocol HouseMatcher, ни RealMatcherAdapter, ни оба прямых вызывающих
if house_fias_id: # такого параметра не имели, поэтому за всю историю тир не сработал ни разу (0 из
row = ( # 49 502 house_sources). Живой ФИАС-тир остался в match_house_readonly.
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 1: source+ext_id already registered in house_sources # Tier 1: source+ext_id already registered in house_sources
row = ( row = (

View file

@ -0,0 +1,17 @@
"""Т-Банк интернет-эквайринг — чистый интеграционный слой (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` запросов + проверка подписи нотификаций.
- `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

View 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

View file

@ -0,0 +1,249 @@
"""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` в теле) тоже НЕ
ретраится: это содержательный ответ банка, а не сбой транспорта.
БЕЗОПАСНОСТЬ: `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`."""
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`)."""
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)

View file

@ -0,0 +1,98 @@
"""Подпись `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`/`float` строка без
экспоненциальной записи; `str` как есть.
3. Добавляем пару `Password: <пароль_терминала>`.
4. Сортируем пары по имени ключа (лексикографически по строке ключа),
конкатенируем ТОЛЬКО значения (не ключи и не имена) в одну строку.
5. SHA-256 (UTF-8) от строки, hex-digest в нижнем регистре.
Эталонные векторы (см. `tests/test_payments_token.py`) сняты дословно с
doc-портала оба подтверждены живым запросом, не выдуманы.
"""
from __future__ import annotations
import hashlib
import hmac
from typing import Any
_EXCLUDED_KEYS = frozenset({"Token"})
def _stringify_value(value: bool | int | float | str) -> str:
"""Приводит плоское значение к строке по правилам Т-Банка.
`bool` проверяем ДО `int`: в Python `bool` подкласс `int`
(`isinstance(True, int) is True`), поэтому порядок веток важен
иначе `True` попал бы в ветку int и дал `"1"` вместо `"true"`.
"""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
# `format(..., "f")` — фиксированная нотация, Python никогда не
# добавляет экспоненту при presentation type 'f' (в отличие от
# str()/repr(), которые для очень больших/малых float дают "1e+21").
text = format(value, "f")
if "." in text:
text = text.rstrip("0").rstrip(".")
return text
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`
(нечего сравнивать) вызывающая сторона обязана трактовать это как
отказ в обработке нотификации, а не как «пропустить проверку».
"""
received_token = payload.get("Token")
if not isinstance(received_token, str) or not received_token:
return False
expected_token = sign(payload, password)
return hmac.compare_digest(expected_token, received_token)

View file

@ -21,8 +21,10 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from scraper_kit.orchestration import runs as kit_runs
from scraper_kit.orchestration.scheduler import ( from scraper_kit.orchestration.scheduler import (
Handler, Handler,
reschedule_after_minutes, reschedule_after_minutes,
@ -39,39 +41,97 @@ logger = logging.getLogger(__name__)
# ── cian_history_backfill — cookie-gated backfill ──────────────────────────── # ── 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: async def _cian_pre_claim(db: Session, schedule_row: dict[str, Any], ctx: SchedulerContext) -> bool:
"""Pre-claim gate: проверить наличие/валидность cian-cookies ДО claim (#1522). """Pre-claim gate: проверить наличие/валидность cian-cookies ДО claim (#1522).
Cookies отсутствуют/протухли defer next_run_at на следующее окно и skip Cookies отсутствуют/протухли пишем строку прогона status='skipped' с причиной,
(иначе get_due_schedules переотбирает schedule каждые 60с и verify_session двигаем next_run_at на следующее окно и skip (иначе get_due_schedules переотбирает
долбит Cian круглосуточно). Дословно из боевого trigger_cian_backfill_run. schedule каждые 60с и verify_session долбит Cian круглосуточно).
"""
import sentry_sdk
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) cookies = load_session(db)
if cookies is None: 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) kit_defer_next_run_at(db, schedule_row)
return False return False
state = await verify_session(cookies) state = await verify_session(cookies)
if state is None: if state is None:
logger.warning( # verify вернул именно None (401 / isAuthenticated=false) — куки числятся
"scheduler: cian_history_backfill — cookies expired or invalid, skipping run" # валидными по сроку, но Циан их не принимает. Sentinel-ответы (бан / источник
) # недоступен / сменилась вёрстка) сюда НЕ попадают, они truthy — см. cian_session.
try: detail = "Циан не принимает куки (разлогин)"
sentry_sdk.capture_message( _alert_cian_cookies(source, detail)
"cian_history_backfill skipped: Cian session cookies expired — " kit_runs.mark_skipped(db, source=source, reason=SKIP_CIAN_COOKIES_INVALID, details=detail)
"please re-upload via admin UI",
level="warning",
)
except Exception:
pass # sentry_sdk not initialised in dev
kit_defer_next_run_at(db, schedule_row) kit_defer_next_run_at(db, schedule_row)
return False 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 return True
@ -94,13 +154,15 @@ async def _job_rosreestr_dkp(
# ── listing_source_snapshot — sync DB-snapshot в executor ──────────────────── # ── listing_source_snapshot — sync DB-snapshot в executor ────────────────────
# params прокинуты (#2607) — snapshot_listing_sources теперь читает budget_sec из
# default_params (SET LOCAL statement_timeout, см. app/tasks/listing_source_snapshot.py).
async def _job_listing_source_snapshot( async def _job_listing_source_snapshot(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None: ) -> None:
from app.tasks.listing_source_snapshot import snapshot_listing_sources from app.tasks.listing_source_snapshot import snapshot_listing_sources
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
await loop.run_in_executor(None, snapshot_listing_sources, db, run_id) await loop.run_in_executor(None, snapshot_listing_sources, db, run_id, params)
# ── asking_to_sold_ratio_refresh — sync re-derive в executor ───────────────── # ── asking_to_sold_ratio_refresh — sync re-derive в executor ─────────────────
@ -113,6 +175,16 @@ async def _job_asking_to_sold_ratio(
await loop.run_in_executor(None, recompute_asking_to_sold_ratios, db, run_id) await loop.run_in_executor(None, recompute_asking_to_sold_ratios, db, run_id)
# ── deal_city_price_bands_refresh — sync tier-aware re-derive в executor ──────
async def _job_deal_city_price_bands_refresh(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.deal_city_price_bands_refresh import refresh_deal_city_price_bands
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, refresh_deal_city_price_bands, db, run_id)
# ── refresh_search_matview — REFRESH MATVIEW CONCURRENTLY (own connection) ──── # ── refresh_search_matview — REFRESH MATVIEW CONCURRENTLY (own connection) ────
async def _job_refresh_search_matview( async def _job_refresh_search_matview(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -144,12 +216,19 @@ async def _job_deactivate_stale(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None: ) -> None:
from app.core.config import settings as _settings 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,
deactivate_stale_listings,
)
listing_source: str = params.get("listing_source", "avito") listing_source: str = params.get("listing_source", "avito")
ttl_days: int = params.get("ttl_days", _settings.avito_stale_ttl_days) ttl_days: int = params.get("ttl_days", _settings.avito_stale_ttl_days)
segments: list[str] | None = params.get("segments") segments: list[str] | None = params.get("segments")
staleness_column: str = params.get("staleness_column", "last_seen_at") staleness_column: str = params.get("staleness_column", "last_seen_at")
# Гейт по здоровью сбора (#2659) включён по умолчанию: незасеянное расписание
# получает страховочный порог, а не «деактивируй вслепую». Посчитанные по
# источнику пороги приходят из default_params (миграция 219).
min_confirmations: int = params.get("min_confirmations", DEFAULT_MIN_CONFIRMATIONS)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
await loop.run_in_executor( await loop.run_in_executor(
@ -161,6 +240,7 @@ async def _job_deactivate_stale(
ttl_days=ttl_days, ttl_days=ttl_days,
segments=segments, segments=segments,
staleness_column=staleness_column, staleness_column=staleness_column,
min_confirmations=min_confirmations,
), ),
) )
@ -329,17 +409,38 @@ async def _job_house_imv_backfill(
only_status=only_status, only_status=only_status,
heartbeat=_heartbeat, heartbeat=_heartbeat,
) )
ctx.runs.mark_done( counters = {
db,
run_id,
{
"checked": result.checked, "checked": result.checked,
"saved": result.saved, "saved": result.saved,
"skipped": result.skipped, "skipped": result.skipped,
"errors": result.errors, "errors": result.errors,
"duration_sec": int(result.duration_sec), "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: except Exception as exc:
logger.exception("scheduler: house_imv_backfill crashed run_id=%d", run_id) logger.exception("scheduler: house_imv_backfill crashed run_id=%d", run_id)
try: try:
@ -348,6 +449,57 @@ async def _job_house_imv_backfill(
logger.exception("scheduler: mark_failed crashed run_id=%d", run_id) 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-ФЗ) ─────────────── # ── purge_expired_trade_in_data — ЭТАП 4 B2C retention (152-ФЗ) ───────────────
async def _job_purge_expired_trade_in_data( async def _job_purge_expired_trade_in_data(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -400,8 +552,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"""Реестр НЕ-sweep продуктовых source→Handler для kit build_registry. """Реестр НЕ-sweep продуктовых source→Handler для kit build_registry.
Kit-native sweeps (avito/yandex/cian/domclick city/full-load/newbuilding) НЕ здесь Kit-native sweeps (avito/yandex/cian/domclick city/full-load/newbuilding) НЕ здесь
их даёт build_registry(_default_kit_handlers). Здесь 18 именованных + 1 wildcard их даёт build_registry(_default_kit_handlers). Здесь именованные + 1 wildcard
(deactivate_stale_*), покрывающие каждый НЕ-sweep source боевого scheduler-dispatch. (deactivate_stale_*), покрывающие каждый НЕ-sweep source боевого scheduler-dispatch.
(Число намеренно не названо: прежнее «19» разошлось с реальностью на пять записей.)
`ctx` принят для симметрии контракта; сами Handler-job'ы получают ctx во время `ctx` принят для симметрии контракта; сами Handler-job'ы получают ctx во время
dispatch (см. kit `_dispatch`), поэтому здесь он не замыкается. dispatch (см. kit `_dispatch`), поэтому здесь он не замыкается.
@ -417,6 +570,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"asking_to_sold_ratio_refresh": Handler( "asking_to_sold_ratio_refresh": Handler(
_job_asking_to_sold_ratio, "asking_to_sold_ratio_refresh" _job_asking_to_sold_ratio, "asking_to_sold_ratio_refresh"
), ),
"deal_city_price_bands_refresh": Handler(
_job_deal_city_price_bands_refresh, "deal_city_price_bands_refresh"
),
"refresh_search_matview": Handler(_job_refresh_search_matview, "refresh_search_matview"), "refresh_search_matview": Handler(_job_refresh_search_matview, "refresh_search_matview"),
"yandex_address_backfill": Handler(_job_yandex_address_backfill, "yandex_address_backfill"), "yandex_address_backfill": Handler(_job_yandex_address_backfill, "yandex_address_backfill"),
"sber_index_pull": Handler(_job_sber_index_pull, "sber_index_pull"), "sber_index_pull": Handler(_job_sber_index_pull, "sber_index_pull"),
@ -442,6 +598,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"), "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_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"),
"house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"), "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( "purge_expired_trade_in_data": Handler(
_job_purge_expired_trade_in_data, "purge_expired_trade_in_data" _job_purge_expired_trade_in_data, "purge_expired_trade_in_data"
), ),

View file

@ -15,11 +15,68 @@ ipify-пробу через каждый прокси и обновляет heal
за одну строку второй параллельный вызов пропустит залоченную и возьмёт следующую). за одну строку второй параллельный вызов пропустит залоченную и возьмёт следующую).
Health: Health:
- mark_health(ok=True) consecutive_fails=0, last_ok_at/last_check_at, exit_ip, latency. - mark_health(ok=True) consecutive_fails=0, enabled=true, last_ok_at/last_check_at,
exit_ip, latency. enabled=true реанимация: узел, выключенный
ранее авто-disable'ом, возвращается в строй первой же успешной
пробой (см. run_proxy_healthcheck).
- mark_health(ok=False) consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси - mark_health(ok=False) consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси
авто-disable (enabled=false), чтобы битый узел выпал из пула. авто-disable (enabled=false), чтобы битый узел выпал из пула.
- acquire отфильтровывает enabled=false И consecutive_fails >= MAX_FAILS. - acquire отфильтровывает enabled=false И consecutive_fails >= MAX_FAILS.
Self-healing (#2600):
- run_proxy_healthcheck проверяет не только enabled-узлы, но и disabled реже, раз в
DISABLED_RECHECK_MINUTES (или если ни разу не проверялся). Успешная проба выключенного
узла реанимирует его (enabled=true), инкрементит счётчик `revived` и пишет INFO-лог.
Без этого auto-disable необратим: транзиентный сбой = вечный приговор узлу.
- acquire, не найдя свободного здорового узла нужной provider_affinity, вторым заходом
берёт любой свободный здоровый узел ЛЮБОЙ affinity (WARNING-лог) иначе источник
голодает при живых свободных узлах чужой affinity. Fallback НЕ забирает последний
enabled-узел выделенной affinity (пример domclick, один узел на всё, см. acquire
docstring) иначе чинили бы один источник ценой полной поломки другого.
Бан по паре «узел × источник» (#2600 п.2, таблица scrape_proxy_source_bans, миграция 210):
- Авито банит IP, Яндекс через тот же IP ходит чисто. Поэтому распознанный бан
площадкой (`mark_banned`) НЕ выключает узел глобально (так делал #2600 п.1), а
пишет строку (proxy_id, source, banned_until) `acquire(source)` перестаёт
выдавать узел ЭТОМУ источнику, для остальных узел остаётся первосортным.
- Отличие от `enabled=false`: глобальное выключение это либо решение оператора
(disabled_reason НЕ NULL, #2610), либо авто-disable по серии ТРАНСПОРТНЫХ сбоев
(mark_health, DISABLE_THRESHOLD). Бан площадкой свойство ПАРЫ, а не узла, и
снимается сам по времени, без ручного PATCH и без ipify-пробы (ipify площадку не
эмулирует, бана не видит ровно тот баг, из-за которого п.1 требовал ручного
вмешательства).
- Срок эскалирует на повторных банах той же пары: SOURCE_BAN_BASE_HOURS *
2^(ban_count-1), но не больше SOURCE_BAN_MAX_HOURS. Истёкшие строки сносятся
purge'ем в run_proxy_healthcheck только через SOURCE_BAN_PURGE_DAYS — это же и
механизм сброса ban_count (см. комментарий у purge, НЕ «оптимизировать»).
- Защита последнего узла сохранена, но теперь ПО ИСТОЧНИКУ: если после записи бана
у acquire(source) не останется ни одного кандидата бан не пишется, только
WARNING (пул надо пополнять, #2638).
- Ручное снятие `clear_source_bans` (ложный бан детектора капчи, #2642) плюс
автоматическое после успешной ротации exit-IP: бан привязан к proxy_id, а банился
IP, поэтому смена адреса делает строку недействительной.
Ручное выключение vs авто-выключение (#2610):
- scrape_proxies.disabled_reason (миграция 209) различает ДВЕ разные причины
enabled=false: пул выключил сам после серии сбоев (disabled_reason IS NULL)
воскрешается первой же успешной пробой, как задумано #2609; оператор выключил
руками через admin API (disabled_reason НЕ NULL) mark_health(ok=True) НЕ
трогает enabled, пишет WARNING с id узла и причиной. Без этого узел, снятый
оператором из ротации (например забаненный площадкой ipify через него всё
равно отвечает 200), возвращался бы в строй первой же health-пробой молча.
- Сброс флага (возврат к авто-восстанавливаемому состоянию) только через
admin API PATCH /proxies/{id} enabled=true (app/api/v1/admin.py:patch_proxy),
который явно обнуляет disabled_reason в NULL.
Sticky session lease (browser-путь, живая регрессия 2026-08):
- `BrowserFetcher` (scraper_kit) берёт ОДИН lease на весь жизненный цикл сессии
(весь прогон), а не на каждый `/fetch` иначе при N>=2 живых узлах пула каждый
/fetch получал ДРУГОЙ прокси (acquire сортирует по last_ok_at) и camoufox
релончился на каждый запрос (server.py: relaunch только при реальной смене
желаемого прокси). См. `touch()` heartbeat, которым сессия продлевает leased_at
на каждый /fetch, чтобы reap_stale_leases не отобрал прокси у многочасового
прогона.
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
""" """
@ -36,16 +93,23 @@ from sqlalchemy.orm import Session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
__all__ = [ __all__ = [
"DISABLED_RECHECK_MINUTES",
"DISABLE_THRESHOLD", "DISABLE_THRESHOLD",
"MAX_CONSECUTIVE_FAILS", "MAX_CONSECUTIVE_FAILS",
"NON_RUN_LEASE_MARKER", "NON_RUN_LEASE_MARKER",
"SOURCE_BAN_BASE_HOURS",
"SOURCE_BAN_MAX_HOURS",
"SOURCE_BAN_PURGE_DAYS",
"STALE_LEASE_MINUTES", "STALE_LEASE_MINUTES",
"ProxyLease", "ProxyLease",
"acquire", "acquire",
"clear_source_bans",
"mark_banned",
"mark_health", "mark_health",
"reap_stale_leases", "reap_stale_leases",
"release", "release",
"run_proxy_healthcheck", "run_proxy_healthcheck",
"touch",
] ]
# ── Пороги ─────────────────────────────────────────────────────────────────── # ── Пороги ───────────────────────────────────────────────────────────────────
@ -62,14 +126,42 @@ DISABLE_THRESHOLD = 5
# освобождается reap_stale_leases — иначе прокси навсегда «занят» мёртвым run'ом. # освобождается reap_stale_leases — иначе прокси навсегда «занят» мёртвым run'ом.
STALE_LEASE_MINUTES = 30 STALE_LEASE_MINUTES = 30
# Disabled-узлы перепроверяются не каждый прогон (это долбёж по мёртвому/дорогому
# провайдеру), а раз в это число минут — либо если ни разу не проверялся. Успешная
# проба реанимирует узел (см. run_proxy_healthcheck). Без recheck'а auto-disable
# необратим: транзиентный сбой = вечный приговор (#2600).
DISABLED_RECHECK_MINUTES = 60
# Маркер lease для не-run вызовов (leased_by NOT NULL = занят, но это не id из scrape_runs). # Маркер lease для не-run вызовов (leased_by NOT NULL = занят, но это не id из scrape_runs).
NON_RUN_LEASE_MARKER = -1 NON_RUN_LEASE_MARKER = -1
# ── Бан по паре «узел × источник» (#2600 п.2) ────────────────────────────────
# Срок ПЕРВОГО бана пары (proxy_id, source). 6 часов — эмпирический компромисс:
# площадки снимают IP-баны обычно за часы, а не минуты (короче — вернём узел под тот
# же бан и потратим прогон впустую), но и не сутки (узел дефицитный, #2638).
SOURCE_BAN_BASE_HOURS = 6
# Потолок эскалации: SOURCE_BAN_BASE_HOURS * 2^(ban_count-1) обрезается этим значением
# (6 → 12 → 24 → 48 → 72 → 72 …). Дольше 3 суток держать бесполезно: либо площадка
# сняла бан, либо узел мёртв насовсем и его должен вычистить оператор.
SOURCE_BAN_MAX_HOURS = 72
# Через столько суток ПОСЛЕ истечения бана строка сносится purge'ем (см.
# run_proxy_healthcheck). Это же и сброс ban_count — см. комментарий там.
SOURCE_BAN_PURGE_DAYS = 7
# URL для health-пробы: возвращает exit-IP JSON'ом. Тот же эндпоинт, что и admin # URL для health-пробы: возвращает exit-IP JSON'ом. Тот же эндпоинт, что и admin
# /scraper/health (_probe_current_ip). # /scraper/health (_probe_current_ip).
_HEALTH_PROBE_URL = "https://api.ipify.org" _HEALTH_PROBE_URL = "https://api.ipify.org"
_HEALTH_PROBE_TIMEOUT_S = 10.0 _HEALTH_PROBE_TIMEOUT_S = 10.0
# deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для
# mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что
# в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не
# столкнуться с advisory-локами других частей системы, которые тоже могут
# использовать pg_advisory_lock с мелкими/предсказуемыми ключами.
_MARK_BANNED_ADVISORY_LOCK_KEY = 0x2600_BA22 # "2600 BAn" — мнемоника, не magic
@dataclass @dataclass
class ProxyLease: class ProxyLease:
@ -90,10 +182,30 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
(last_ok_at NULLS LAST). Затем помечает строку leased_by=run_id (или (last_ok_at NULLS LAST). Затем помечает строку leased_by=run_id (или
NON_RUN_LEASE_MARKER если run_id не задан) и коммитит. NON_RUN_LEASE_MARKER если run_id не задан) и коммитит.
Если свободных здоровых узлов нужной affinity (provider/'any') нет вторым заходом
берётся любой свободный здоровый узел ЛЮБОЙ affinity (тот же ORDER BY/FOR UPDATE SKIP
LOCKED), с WARNING-логом. Приоритет не меняется: своя affinity всегда предпочтительнее,
чужая только запасной вариант, чтобы источник не голодал при живых свободных узлах
чужой 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 целиком.
ОБА запроса отсекают узлы с АКТИВНЫМ баном по ЭТОМУ provider'у
(scrape_proxy_source_bans.banned_until > now(), #2600 п.2) — узел, забаненный Авито,
остаётся полноценным кандидатом для Яндекса и остальных источников. Бан по чужому
source на выдачу не влияет вообще.
Конкурентные acquire не дерутся за одну строку: SKIP LOCKED пропускает залоченную Конкурентные acquire не дерутся за одну строку: SKIP LOCKED пропускает залоченную
другим вызовом строку, второй параллельный acquire берёт следующую свободную. другим вызовом строку, второй параллельный acquire берёт следующую свободную.
Returns ProxyLease или None если свободных здоровых прокси нет. Returns ProxyLease или None если свободных здоровых прокси нет вообще.
""" """
lease_marker = run_id if run_id is not None else NON_RUN_LEASE_MARKER lease_marker = run_id if run_id is not None else NON_RUN_LEASE_MARKER
@ -107,6 +219,13 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
AND consecutive_fails < CAST(:max_fails AS integer) AND consecutive_fails < CAST(:max_fails AS integer)
AND provider_affinity IN (:provider, 'any') AND provider_affinity IN (:provider, 'any')
AND leased_by IS NULL AND leased_by IS NULL
AND NOT EXISTS (
SELECT 1
FROM scrape_proxy_source_bans b
WHERE b.proxy_id = scrape_proxies.id
AND b.source = :provider
AND b.banned_until > now()
)
ORDER BY last_ok_at NULLS LAST, id ORDER BY last_ok_at NULLS LAST, id
FOR UPDATE SKIP LOCKED FOR UPDATE SKIP LOCKED
LIMIT 1 LIMIT 1
@ -117,6 +236,65 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
.mappings() .mappings()
.fetchone() .fetchone()
) )
fallback_used = False
if row is None:
# Нет своих (provider/'any') — запасной заход: любой свободный здоровый узел
# ЛЮБОЙ affinity, кроме последнего enabled-узла выделенной affinity (domclick и
# т.п.) — EXISTS-подзапрос требует хотя бы ОДИН ДРУГОЙ enabled-узел той же
# affinity, иначе affinity='any' достаточно.
row = (
db.execute(
text(
"""
SELECT sp.id, sp.url, sp.kind, sp.rotate_url
FROM scrape_proxies AS sp
WHERE sp.enabled
AND sp.consecutive_fails < CAST(:max_fails AS integer)
AND sp.leased_by IS NULL
AND NOT EXISTS (
SELECT 1
FROM scrape_proxy_source_bans b
WHERE b.proxy_id = sp.id
AND b.source = :provider
AND b.banned_until > now()
)
AND (
sp.provider_affinity = 'any'
-- backup обязан быть ПРИГОДЕН для своей affinity, а не просто
-- enabled (#2600 п.2 deep-review): после перехода на per-source
-- баны узел бывает enabled и одновременно забанен СВОИМ же
-- источником. Засчитывать такой как backup значит разрешить
-- fallback увести последний реально рабочий узел выделенной
-- affinity и обрушить её (два domclick-узла, один забанен
-- domclick'ом → второй уходит под avito → domclick без прокси).
OR EXISTS (
SELECT 1
FROM scrape_proxies AS other
WHERE other.provider_affinity = sp.provider_affinity
AND other.enabled
AND other.id <> sp.id
AND NOT EXISTS (
SELECT 1
FROM scrape_proxy_source_bans b2
WHERE b2.proxy_id = other.id
AND b2.source = other.provider_affinity
AND b2.banned_until > now()
)
)
)
ORDER BY sp.last_ok_at NULLS LAST, sp.id
FOR UPDATE SKIP LOCKED
LIMIT 1
"""
),
{"max_fails": MAX_CONSECUTIVE_FAILS, "provider": provider},
)
.mappings()
.fetchone()
)
fallback_used = row is not None
if row is None: if row is None:
db.rollback() # снять FOR UPDATE-транзакцию (ничего не залочено, но чисто) db.rollback() # снять FOR UPDATE-транзакцию (ничего не залочено, но чисто)
return None return None
@ -133,6 +311,15 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
{"run_id": lease_marker, "id": proxy_id}, {"run_id": lease_marker, "id": proxy_id},
) )
db.commit() db.commit()
if fallback_used:
logger.warning(
"proxy_pool: leased proxy id=%d provider=%s by=%s — FALLBACK affinity "
"(no free healthy proxy of matching affinity, issuing proxy of other affinity)",
proxy_id,
provider,
lease_marker,
)
else:
logger.info( logger.info(
"proxy_pool: leased proxy id=%d provider=%s by=%s", proxy_id, provider, lease_marker "proxy_pool: leased proxy id=%d provider=%s by=%s", proxy_id, provider, lease_marker
) )
@ -160,6 +347,47 @@ def release(db: Session, proxy_id: int) -> None:
logger.info("proxy_pool: released proxy id=%d", proxy_id) logger.info("proxy_pool: released proxy id=%d", proxy_id)
def touch(db: Session, proxy_id: int) -> None:
"""Heartbeat: продлить lease (leased_at=now()) без трогания health-полей.
#2164 P4 sticky-session fix (2026-08).
Раньше `BrowserFetcher` брал/отпускал прокси на КАЖДЫЙ `/fetch` при N>=2 живых узлах
это гарантированно меняло прокси между соседними запросами (`acquire` сортирует ORDER
BY last_ok_at NULLS LAST, id «давно не использованный первый») и гоняло camoufox
relaunch на каждый /fetch (см. server.py `_ensure_browser` relaunch только при
реальной смене желаемого прокси). Фикс: один lease на весь жизненный цикл
`BrowserFetcher` (весь прогон, часы). Но `reap_stale_leases` освобождает lease старше
`STALE_LEASE_MINUTES` (=30) прогон ДОЛЬШЕ 30 минут (полная загрузка Циана шла часами)
остался бы без прокси на середине, а второй consumer мог бы получить тот же прокси.
Решение: НЕ увеличивать `STALE_LEASE_MINUTES` (это притупило бы реальную задачу
reaper'а — освобождать lease мёртвого/зависшего run'а, который никогда не вызовет
release). Вместо этого `BrowserFetcher` вызывает `touch` на каждый /fetch (успешный
ИЛИ неуспешный сам факт завершённого запроса доказывает, что процесс жив и активно
использует прокси) `leased_at` подтверждается заново, окно `STALE_LEASE_MINUTES`
сдвигается вперёд, пока идёт трафик. Реальный мёртвый/зависший run (упал/завис БЕЗ
единого /fetch дольше 30 минут) по-прежнему реапится штатно семантика reaper'а не
ослаблена, просто измеряется от «последней активности», а не от «момента acquire».
No-op (0 rows), если прокси уже не арендован (leased_by IS NULL, например reaper
успел отобрать в гонке) defensive, вызывающий код (BrowserFetcher) не должен падать.
"""
db.execute(
text(
"""
UPDATE scrape_proxies
SET leased_at = now()
WHERE id = CAST(:id AS bigint)
AND leased_by IS NOT NULL
"""
),
{"id": proxy_id},
)
db.commit()
logger.debug("proxy_pool: touch (heartbeat) proxy id=%d", proxy_id)
def mark_health( def mark_health(
db: Session, db: Session,
proxy_id: int, proxy_id: int,
@ -167,15 +395,34 @@ def mark_health(
*, *,
exit_ip: str | None = None, exit_ip: str | None = None,
latency_ms: int | None = None, latency_ms: int | None = None,
fail_kind: str | None = None,
) -> None: ) -> None:
"""Записать результат health-check'а прокси. """Записать результат health-check'а прокси.
ok=True consecutive_fails обнуляется, обновляются last_ok_at/last_check_at/ ok=True consecutive_fails обнуляется, обновляются last_ok_at/last_check_at/
exit_ip/latency_ms. exit_ip/latency_ms. enabled=true РЕАНИМАЦИЯ, но ТОЛЬКО если узел не
выключен вручную (disabled_reason IS NULL, #2610): узел, ранее выключенный
auto-disable'ом (disabled_reason IS NULL), возвращается в строй первой же
успешной пробой, как задумано #2609 п.1. Узел, выключенный оператором
(disabled_reason НЕ NULL), остаётся enabled=false иначе снятый с ротации
забаненный площадкой узел воскрешался бы первой же ipify-пробой (ipify
площадку не эмулирует, значит бан ею не ловится). Этот случай логируется
WARNING'ом — раньше (до #2610) происходил молча.
ok=False consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси ok=False consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси
авто-disable (enabled=false). last_check_at обновляется в любом случае. авто-disable (enabled=false, disabled_reason НЕ трогается узел уходит в
disable БЕЗ причины, т.е. остаётся авто-воскрешаемым). last_check_at
обновляется в любом случае.
fail_kind необязательная классификация неуспеха ("timeout" / "connect_error" /
"http_error" / "other", см. _probe_proxy), используется ТОЛЬКО для логирования.
Счётчик consecutive_fails/порог disable инкрементится одинаково для любого fail_kind
аккуратное разделение "транзиентный сбой vs перманентный бан" (разные пороги/скорость
инкремента по типу ошибки) требует более глубокой переработки модуля (отдельный
трекинг по типам ошибок, вероятно per-fail_kind счётчики) и намеренно НЕ сделано в
рамках #2600 п.2 — см. обоснование в PR. fail_kind — задел под это на будущее.
""" """
if ok: if ok:
row = (
db.execute( db.execute(
text( text(
""" """
@ -185,12 +432,26 @@ def mark_health(
last_check_at = now(), last_check_at = now(),
exit_ip = CAST(:exit_ip AS text), exit_ip = CAST(:exit_ip AS text),
latency_ms = CAST(:latency_ms AS integer), latency_ms = CAST(:latency_ms AS integer),
enabled = CASE
WHEN disabled_reason IS NULL THEN true ELSE enabled
END,
updated_at = now() updated_at = now()
WHERE id = CAST(:id AS bigint) WHERE id = CAST(:id AS bigint)
RETURNING disabled_reason
""" """
), ),
{"exit_ip": exit_ip, "latency_ms": latency_ms, "id": proxy_id}, {"exit_ip": exit_ip, "latency_ms": latency_ms, "id": proxy_id},
) )
.mappings()
.fetchone()
)
if row is not None and row["disabled_reason"] is not None:
logger.warning(
"proxy_pool: mark_health id=%d ok=True but stays disabled — manually "
"disabled (reason=%r), auto-revive skipped (#2610)",
proxy_id,
row["disabled_reason"],
)
else: else:
# consecutive_fails+1 >= порог → enabled=false (авто-вывод битого узла). # consecutive_fails+1 >= порог → enabled=false (авто-вывод битого узла).
db.execute( db.execute(
@ -210,7 +471,245 @@ def mark_health(
{"disable_threshold": DISABLE_THRESHOLD, "id": proxy_id}, {"disable_threshold": DISABLE_THRESHOLD, "id": proxy_id},
) )
db.commit() db.commit()
logger.info("proxy_pool: mark_health id=%d ok=%s exit_ip=%s", proxy_id, ok, exit_ip) logger.info(
"proxy_pool: mark_health id=%d ok=%s exit_ip=%s fail_kind=%s",
proxy_id,
ok,
exit_ip,
fail_kind,
)
def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
"""Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2.
Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и
авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация —
транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно
распознана вызывающим кодом (валидная HTML-заглушка/капча/QRATOR-маркер НЕ
исключение транспорта, НЕ голый network-fail).
ЧТО ИМЕННО ДЕЛАЕТСЯ (изменение против #2600 п.1): узел БОЛЬШЕ НЕ выключается
глобально (`enabled=false, disabled_reason='banned:<source>'` так было в п.1).
Пишется строка в `scrape_proxy_source_bans` (миграция 210): пока
`banned_until > now()`, `acquire(source)` этот узел не выдаёт, а для ЛЮБОГО
другого источника он остаётся первосортным. Авито банит IP Яндекс через тот же
IP ходит чисто; глобальное выключение выкидывало живой узел отовсюду и худило пул
в разы быстрее, чем его пополняют (#2638). `enabled`/`disabled_reason` остаются
исключительно за оператором (#2610) и за авто-disable'ом по транспортным сбоям.
ЭСКАЛАЦИЯ: первый бан пары SOURCE_BAN_BASE_HOURS; каждый следующий удваивает
срок (ban_count после инкремента N SOURCE_BAN_BASE_HOURS * 2^(N-1)), но не выше
SOURCE_BAN_MAX_HOURS. Узел, который площадка банит раз за разом, отдыхает от неё
всё дольше, вместо того чтобы жечь прогоны. Сброс ban_count только purge'ем
истёкших строк (run_proxy_healthcheck, SOURCE_BAN_PURGE_DAYS).
ЗАЩИТА ПОСЛЕДНЕГО УЗЛА, ТЕПЕРЬ ПО ИСТОЧНИКУ (issue #2600 риск, паттерн #2609):
если после записи бана у `acquire(source)` не останется НИ ОДНОГО кандидата бан
НЕ пишется, только WARNING. Доступность считается ТЕМ ЖЕ правилом, что и acquire()
(primary affinity ИЛИ 'any' + fallback на чужую affinity, которая не последняя из
своей) ПЛЮС отсутствие активной бан-строки для этого source EXISTS ниже, а не
наивный `COUNT(*) WHERE enabled`. Голодать без прокси хуже, чем ходить через
забаненный: капча хотя бы иногда пропускает, отсутствие узла нет.
`leased_by IS NULL` защита НАМЕРЕННО не проверяет (в отличие от acquire) так было
и в п.1, и это не оплошность: lease живёт минуты-часы и снимается сам (release /
reap_stale_leases), т.е. занятый узел это доступный узел через мгновение, а вот
отказ записать бан из-за чужого lease был бы вечным (узел так и остался бы в выдаче
забаненным). Точность здесь не бесплатна: с проверкой lease защита срабатывала бы
ложно при каждом параллельном прогоне.
КОНКУРЕНТНОСТЬ (deep-review fix 2 из #2600 п.1, сохранено): один
`INSERT ... WHERE EXISTS(...)` НЕ атомарная гарантия поперёк СТРОК. EXISTS читает
состояние других строк на момент своего снапшота (READ COMMITTED), но не лочит их
два ПАРАЛЛЕЛЬНЫХ mark_banned для РАЗНЫХ proxy_id (напр. avito банит A, cian банит B
миллисекундами позже) каждый может увидеть другого как "ещё живого" в своём EXISTS и
оба закоммититься для источника не остаётся ни одного узла разом. Фикс:
`pg_advisory_xact_lock` в начале транзакции сериализует ВСЕ mark_banned-вызовы между
собой (xact-scoped снимается сам на commit/rollback, leak невозможен). Один
глобальный ключ вместо per-source сериализует и непересекающиеся баны тоже, но
частота вызовов низкая (несколько банов в час, не hot-path) цена оправдана
простотой против per-row `SELECT ... FOR UPDATE` по кандидатам (выше риск deadlock
между параллельными mark_banned, лочащими пересекающиеся строки в разном порядке).
ponytail: global advisory lock, не per-source переходи на составной ключ
(напр. hashtext(source)) если частота банов когда-нибудь станет hot-path.
Идемпотентно: повторный бан той же пары не создаёт дубль (PK (proxy_id, source))
продлевает срок по правилу эскалации. Несуществующий proxy_id no-op + WARNING.
Best-effort по контракту вызывающих (`BrowserFetcher.report_ban`, `curl_proxy_url`)
сюда попадают уже обёрнутыми в try/except, но сам mark_banned ошибки БД не глотает
(падает как обычно) caller решает, ловить или нет.
"""
# Сериализует check+insert ниже с другими конкурентными mark_banned (см. докстринг
# "КОНКУРЕНТНОСТЬ"). Держится до db.commit()/rollback() этой транзакции.
db.execute(
text("SELECT pg_advisory_xact_lock(CAST(:key AS bigint))"),
{"key": _MARK_BANNED_ADVISORY_LOCK_KEY},
)
# INSERT ... SELECT ... WHERE EXISTS: guard'ы в WHERE источника строк — не прошли,
# значит строк на вставку нет, конфликта нет, эскалации нет (0 rows → ветка логов ниже).
# LEAST(ban_count, 16) в показателе — страховка от переполнения double при абсурдном
# ban_count (потолок SOURCE_BAN_MAX_HOURS всё равно срежет результат гораздо раньше).
row = (
db.execute(
text(
"""
INSERT INTO scrape_proxy_source_bans (proxy_id, source, banned_until, reason)
SELECT CAST(:proxy_id AS bigint),
CAST(:source AS text),
now() + make_interval(hours => CAST(:base_hours AS integer)),
CAST(:reason AS text)
WHERE EXISTS (
SELECT 1 FROM scrape_proxies
WHERE id = CAST(:proxy_id AS bigint)
)
AND EXISTS (
SELECT 1
FROM scrape_proxies sp
WHERE sp.id <> CAST(:proxy_id AS bigint)
AND sp.enabled
AND sp.consecutive_fails < CAST(:max_fails AS integer)
AND NOT EXISTS (
SELECT 1
FROM scrape_proxy_source_bans b
WHERE b.proxy_id = sp.id
AND b.source = CAST(:source AS text)
AND b.banned_until > now()
)
AND (
sp.provider_affinity IN (:source, 'any')
-- other.id <> sp.id (а не NOT IN (sp.id, :proxy_id), как в
-- п.1): банимый узел остаётся enabled и по-прежнему обслуживает
-- СВОЮ affinity значит он и есть валидный backup для неё.
-- NOT EXISTS b2 тот же критерий пригодности, что в acquire()
-- fallback: enabled-узел, забаненный СВОИМ источником, backup'ом
-- не считается (иначе защита сочла бы affinity живой, когда она
-- уже нет).
OR EXISTS (
SELECT 1
FROM scrape_proxies other
WHERE other.provider_affinity = sp.provider_affinity
AND other.enabled
AND other.id <> sp.id
AND NOT EXISTS (
SELECT 1
FROM scrape_proxy_source_bans b2
WHERE b2.proxy_id = other.id
AND b2.source = other.provider_affinity
AND b2.banned_until > now()
)
)
)
)
ON CONFLICT (proxy_id, source) DO UPDATE
SET ban_count = scrape_proxy_source_bans.ban_count + 1,
banned_until = now() + make_interval(hours => CAST(
LEAST(
CAST(:base_hours AS integer)
* power(2, LEAST(scrape_proxy_source_bans.ban_count, 16)),
CAST(:max_hours AS integer)
) AS integer)),
reason = CAST(:reason AS text),
updated_at = now()
RETURNING ban_count, banned_until
"""
),
{
"proxy_id": proxy_id,
"source": source,
"reason": f"banned:{source}",
"base_hours": SOURCE_BAN_BASE_HOURS,
"max_hours": SOURCE_BAN_MAX_HOURS,
"max_fails": MAX_CONSECUTIVE_FAILS,
},
)
.mappings()
.fetchone()
)
db.commit()
if row is not None:
logger.warning(
"proxy_pool: proxy id=%d BANNED by source=%s — узел снят с выдачи ТОЛЬКО для "
"этого источника до %s (ban_count=%s); для остальных источников остаётся в "
"строю (#2600 п.2)",
proxy_id,
source,
row["banned_until"],
row["ban_count"],
)
return
# 0 rows: либо узла нет, либо защита последнего узла отменила запись бана — читаем
# текущее состояние ТОЛЬКО для точного лога (на решение уже не влияет).
current = (
db.execute(
text(
"SELECT enabled, disabled_reason FROM scrape_proxies "
"WHERE id = CAST(:id AS bigint)"
),
{"id": proxy_id},
)
.mappings()
.fetchone()
)
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,
)
def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, reason: str) -> int:
"""Снять баны узла по источникам (#2600 п.2). Returns число снятых строк.
ЗАЧЕМ ОТДЕЛЬНАЯ РУЧКА: до п.2 ложный бан лечился оператором через
`PATCH /proxies/{id} enabled=true` включение обнуляло `disabled_reason`, и узел
возвращался в строй. Теперь бан живёт в отдельной таблице и сам по себе истекает
только по таймеру, вплоть до 72 часов при эскалации. Без этой функции ложное
срабатывание детектора капчи (#2642) парковало бы узел на часы, а снять это можно
было бы только руками в SQL.
ГДЕ ВЫЗЫВАЕТСЯ:
- `admin.patch_proxy` при ручном включении узла «оператор включил» означает
чистый лист, ровно как обнуление disabled_reason рядом (#2610);
- после УСПЕШНОЙ ротации exit-IP (`proxy_rotation.rotate_proxy`) площадка
банила IP, а строка бана привязана к proxy_id и пережила бы смену адреса,
держа узел вне выдачи уже без причины.
source=None снять все баны узла; конкретный source только его. DELETE, а не
`banned_until = now()`: строка живёт ещё и ради `ban_count` (память об эскалации),
а здесь мы как раз объявляем историю недействительной новый бан начнётся с базовых
SOURCE_BAN_BASE_HOURS.
`reason` идёт только в лог (человекочитаемый повод «manual enable», «ip rotated»).
"""
rows = db.execute(
text(
"""
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))
RETURNING source
"""
),
{"proxy_id": proxy_id, "source": source},
).fetchall()
db.commit()
if rows:
logger.info(
"proxy_pool: cleared %d source ban(s) for proxy id=%d (%s) — reason=%s",
len(rows),
proxy_id,
[r.source for r in rows],
reason,
)
return len(rows)
def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int: def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int:
@ -237,10 +736,17 @@ def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES
return len(rows) return len(rows)
async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None]: async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | None]:
"""GET ipify через прокси (timeout _HEALTH_PROBE_TIMEOUT_S). """GET ipify через прокси (timeout _HEALTH_PROBE_TIMEOUT_S).
Returns (ok, exit_ip, latency_ms). ok=False + (None, None) при любой ошибке. Returns (ok, exit_ip, latency_ms, fail_kind). При успехе fail_kind=None. При неуспехе
exit_ip/latency_ms=None, а fail_kind классифицирует что случилось (#2600 п.2 —
транзиентный сбой узла перманентный бан, используется пока только для логов):
- "timeout" сеть недоступна/медленная (httpx.TimeoutException)
- "connect_error" прокси не поднят/не слушает/DNS (httpx.ConnectError)
- "http_error" ipify ответил ошибкой через прокси (auth/upstream)
- "other" прочее
url несёт схему (http:// / socks5://) httpx[socks] обрабатывает оба. url несёт схему (http:// / socks5://) httpx[socks] обрабатывает оба.
""" """
started = time.monotonic() started = time.monotonic()
@ -250,10 +756,23 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None]:
resp.raise_for_status() resp.raise_for_status()
ip = resp.json().get("ip") ip = resp.json().get("ip")
latency_ms = int((time.monotonic() - started) * 1000) latency_ms = int((time.monotonic() - started) * 1000)
return True, (str(ip) if ip else None), latency_ms return True, (str(ip) if ip else None), latency_ms, None
except httpx.TimeoutException:
logger.warning("proxy_pool: health probe timeout proxy=%s", _mask(url))
return False, None, None, "timeout"
except httpx.ConnectError:
logger.warning("proxy_pool: health probe connect_error proxy=%s", _mask(url))
return False, None, None, "connect_error"
except httpx.HTTPStatusError as exc:
logger.warning(
"proxy_pool: health probe http_error proxy=%s status=%s",
_mask(url),
exc.response.status_code,
)
return False, None, None, "http_error"
except Exception: except Exception:
logger.warning("proxy_pool: health probe failed proxy=%s", _mask(url), exc_info=True) logger.warning("proxy_pool: health probe failed proxy=%s", _mask(url), exc_info=True)
return False, None, None return False, None, None, "other"
def _mask(url: str) -> str: def _mask(url: str) -> str:
@ -269,16 +788,29 @@ def _mask(url: str) -> str:
async def run_proxy_healthcheck(db: Session) -> dict[str, int]: async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
"""Периодический health-check всех enabled-прокси пула (#2162). """Периодический health-check прокси пула — enabled каждый прогон, disabled реже (#2162, #2600).
Сначала reap_stale_leases (освобождает протухшие lease'ы), затем для каждого Сначала reap_stale_leases (освобождает протухшие lease'ы), затем гоняет ipify-пробу
enabled-прокси гоняет ipify-пробу через сам прокси и пишет результат через через каждый кандидат и пишет результат через mark_health (успех сброс fails +
mark_health (успех сброс fails + свежий exit_ip/latency; фейл инкремент, enabled=true + свежий exit_ip/latency; фейл инкремент, авто-disable при
авто-disable при DISABLE_THRESHOLD). DISABLE_THRESHOLD).
Кандидаты: ВСЕ enabled-узлы (как раньше) + disabled-узлы, которые ни разу не
проверялись (last_check_at IS NULL) или проверялись давнее DISABLED_RECHECK_MINUTES
назад. Без этого auto-disable необратим узел, ушедший в disable из-за транзиентного
сбоя, никогда больше не проверяется и не может вернуться (#2600 п.1). Успешная проба
disabled-узла реанимирует его (enabled=true через mark_health) инкрементит `revived`
и пишет отдельный INFO-лог. Ручно-выключенные узлы (disabled_reason НЕ NULL, #2610)
тоже пробуются (чтобы после ручного включения признак немедленно ожил без ожидания
следующего disable/enable цикла), но mark_health их не воскрешает revived не растёт,
WARNING пишет сам mark_health.
В конце purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад
(см. комментарий у самого DELETE: отложенность это и есть сброс ban_count).
Пробы идут последовательно пул небольшой (десятки узлов), а параллельный залп на Пробы идут последовательно пул небольшой (десятки узлов), а параллельный залп на
один и тот же upstream-endpoint (ipify) не нужен. Returns counters один и тот же upstream-endpoint (ipify) не нужен. Returns counters
{reaped, checked, ok, failed}. {reaped, checked, ok, failed, revived, bans_purged}.
""" """
reaped = reap_stale_leases(db) reaped = reap_stale_leases(db)
@ -286,12 +818,17 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
db.execute( db.execute(
text( text(
""" """
SELECT id, url, kind SELECT id, url, kind, enabled, disabled_reason
FROM scrape_proxies FROM scrape_proxies
WHERE enabled WHERE enabled
OR last_check_at IS NULL
OR last_check_at < now() - make_interval(
mins => CAST(:disabled_recheck_minutes AS integer)
)
ORDER BY id ORDER BY id
""" """
) ),
{"disabled_recheck_minutes": DISABLED_RECHECK_MINUTES},
) )
.mappings() .mappings()
.all() .all()
@ -300,22 +837,64 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
checked = 0 checked = 0
ok_count = 0 ok_count = 0
failed = 0 failed = 0
revived = 0
for row in proxies: for row in proxies:
proxy_id = int(row["id"]) proxy_id = int(row["id"])
url = str(row["url"]) url = str(row["url"])
ok, exit_ip, latency_ms = await _probe_proxy(url) was_disabled = not bool(row["enabled"])
mark_health(db, proxy_id, ok, exit_ip=exit_ip, latency_ms=latency_ms) manually_disabled = row["disabled_reason"] is not None
ok, exit_ip, latency_ms, fail_kind = await _probe_proxy(url)
mark_health(db, proxy_id, ok, exit_ip=exit_ip, latency_ms=latency_ms, fail_kind=fail_kind)
checked += 1 checked += 1
if ok: if ok:
ok_count += 1 ok_count += 1
# manually_disabled → mark_health не тронул enabled (см. её WARNING-лог);
# revived считает только реальное авто-воскрешение (#2610).
if was_disabled and not manually_disabled:
revived += 1
logger.info(
"proxy_pool: REVIVED proxy id=%d — successful probe of a disabled node, "
"returned to service (enabled=true, consecutive_fails=0)",
proxy_id,
)
else: else:
failed += 1 failed += 1
# Purge ДАВНО истёкших бан-строк (#2600 п.2). Порог — banned_until + SOURCE_BAN_PURGE_DAYS,
# НЕ просто `banned_until < now()`: строка после истечения бана ещё ничего не блокирует
# (acquire фильтрует по banned_until > now()), но хранит ban_count — память об эскалации.
# Снесём раньше — узел, который площадка банит каждые сутки, каждый раз начинал бы с
# 6 часов и никогда не доходил до длинных пауз. Отложенный purge и есть механизм сброса:
# неделя без нового бана = пара считается чистой, эскалация с нуля. НЕ «оптимизировать».
purged = len(
db.execute(
text(
"""
DELETE FROM scrape_proxy_source_bans
WHERE banned_until < now() - make_interval(days => CAST(:days AS integer))
RETURNING proxy_id
"""
),
{"days": SOURCE_BAN_PURGE_DAYS},
).fetchall()
)
db.commit()
logger.info( logger.info(
"proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d", "proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d "
"bans_purged=%d",
reaped, reaped,
checked, checked,
ok_count, ok_count,
failed, failed,
revived,
purged,
) )
return {"reaped": reaped, "checked": checked, "ok": ok_count, "failed": failed} return {
"reaped": reaped,
"checked": checked,
"ok": ok_count,
"failed": failed,
"revived": revived,
"bans_purged": purged,
}

View file

@ -0,0 +1,376 @@
"""Ротация exit-IP прокси ASocks по требованию, со счётчиком и громким отказом (#2600 п.5).
АДДИТИВНО. НЕ трогает app.services.proxy_pool (pick/lease/health параллельный
PR #2609, конфликт исключён: вся новая логика тут, в новом модуле).
Контекст (эмпирика, issue #2600 п.5 — проверено владельцем аккаунта/пробой):
- Документированный публичный API ASocks (GET /v2/proxy/refresh/{portId}?apiKey=)
для безлимитных портов НЕ работает.
- Ротация сменой session-суффикса логина (-session-N) НЕ работает exit-IP
не меняется (три варианта дали один и тот же IP).
- Единственный рабочий путь ручка веб-кабинета:
POST https://api.asocks.com/unlimited-proxy/{portId}/refresh-ip
Authorization: Bearer <токен>
Без заголовка провайдер отдаёт 401 {"success": false, "message": "Unauthenticated"}.
scrape_proxies.rotate_url уже несёт этот URL (миграция 199) токен НЕ в URL,
он только в ASOCKS_API_TOKEN (env, app.core.config.settings.asocks_api_token).
- Лимит провайдера: 3 ротации в сутки на порт.
- Токен сессионный, однажды протухнет (осознанное решение владельца аккаунта).
Когда это случится, провайдер ответит 401 это ГРОМКИЙ отказ ниже
(logger.error + Sentry/GlitchTip capture_message), а не молчаливая остановка.
Суточный лимит и таблица истории (scrape_proxy_rotations, миграция 198):
Против лимита 3/сутки считаются ТОЛЬКО попытки, реально дошедшие до провайдера
и обработанные им т.е. любой HTTP-ответ провайдера, КРОМЕ 401. Обоснование:
401 это буквально описание провайдера "Unauthenticated": запрос отсеян на
уровне аутентификации ДО обращения к самой логике ротации порта, провайдер не
мог засчитать использование ротации тому, кого даже не подтвердил. Сетевые
ошибки (таймаут / разрыв соединения ответа вообще нет) по той же логике не
считаются: нет подтверждения, что запрос вообще дошёл до провайдера. Локальные
отказы (нет rotate_url / нет токена / лимит уже исчерпан) до HTTP-вызова не
доходят вовсе в таблицу не пишутся и лимит не трогают.
quota-consuming := http_status IS NOT NULL AND http_status != 401
(успех 200 И любой не-401 ответ провайдера, включая его собственные 4xx/5xx
если провайдер прошёл auth и ответил бизнес-ошибкой, запрос точно дошёл до
реальной rotate-логики и мог быть учтён в лимите на его стороне).
Токен никогда не должен появиться в возвращаемом клиенту reason, в тексте
исключения, ни в одной записи scrape_proxy_rotations. Прецедент утечки через
str(exc) тот же паттерн, что закрывал (до удаления #2616 шаг 3) changeip-путь
admin.rotate_proxy_ip: httpx-исключения несут полный request URL/детали,
поэтому наружу только нейтральный reason, полные детали в лог с exc_info=True.
Хост-пиннинг (security review PR #2611): scrape_proxies.rotate_url колонка
НЕОДНОРОДНА часть строк пула (id 3/4/5 на проде) несёт mobileproxy changeip-
ссылки (`https://changeip.mobileproxy.space/?proxy_key=<секрет mobileproxy>`,
тот же формат, что читал удалённый #2616 шаг 2/3 admin.rotate_proxy_ip /
Settings.avito_proxy_rotate_url), не ASocks.
Без явной проверки хоста наш `Authorization: Bearer <ASOCKS_API_TOKEN>` ушёл бы
на ЧУЖОЙ провайдер (mobileproxy) плюс сам GET/POST по их changeip, вероятно,
реально ротирует ИХ IP и тратит ИХ суточный лимит, а мы бы записали это как
успех ASocks. rotate_proxy ПЕРЕД любым HTTP-вызовом проверяет
urlparse(rotate_url).hostname == ALLOWED_ROTATE_HOST (https-only) несовпадение
это ОТКАЗ (ok=False, нейтральный reason), а НЕ попытка безголового запроса без
Authorization: смысл ручной ротации конкретный провайдер (ASocks), молчаливый
вызов чужой ручки без авторизации это сюрприз оператору (он думает "ASocks
ротировал", а фактически задел mobileproxy), которого проще не допустить, чем
потом объяснять админу расхождение счётчиков.
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services.proxy_pool import clear_source_bans
logger = logging.getLogger(__name__)
__all__ = [
"ALLOWED_ROTATE_HOST",
"DAILY_ROTATION_LIMIT",
"RotationResult",
"rotate_proxy",
]
# Лимит провайдера (ASocks, безлимитные порты): 3 ротации в сутки на порт (эмпирика).
DAILY_ROTATION_LIMIT = 3
# Таймаут POST refresh-ip. Пункт задачи требует "~30с".
_ROTATE_TIMEOUT_S = 30.0
# Единственный хост, на который разрешено уходить с ASOCKS_API_TOKEN в заголовке
# (см. "⛔ Хост-пиннинг" в docstring модуля). scrape_proxies.rotate_url может
# нести ЧУЖИЕ changeip-ссылки (mobileproxy и т.п.) — сравнение ДО HTTP-вызова.
ALLOWED_ROTATE_HOST = "api.asocks.com"
def _is_allowed_rotate_url(url: str) -> bool:
"""https-only + hostname точно ALLOWED_ROTATE_HOST (регистронезависимо —
urlparse().hostname уже лоуеркейзит). Не бросает исключений на кривом url."""
try:
parsed = urlparse(url)
except ValueError:
return False
return parsed.scheme == "https" and parsed.hostname == ALLOWED_ROTATE_HOST
@dataclass
class RotationResult:
"""Результат попытки ротации exit-IP одного прокси. reason — ВСЕГДА нейтральный
(безопасен для HTTP-ответа клиенту), никогда не несёт токен/секреты."""
ok: bool
reason: str | None
new_ip: str | None = None
# Сколько quota-consuming попыток остаётся сегодня ПОСЛЕ этой попытки (см. модуль
# docstring за определением quota-consuming). Для локально отклонённых попыток
# (no rotate_url/no token) не относится к текущему прокси — просто текущий остаток.
rotations_remaining_today: int = DAILY_ROTATION_LIMIT
def _quota_used_today(db: Session, proxy_id: int) -> int:
"""Число quota-consuming попыток за последние 24ч (см. docstring модуля).
http_status IS NOT NULL AND != 401 успех И любой не-401 ответ провайдера.
401 (auth-отсев) и сетевые ошибки (http_status IS NULL) не считаются.
"""
row = (
db.execute(
text(
"""
SELECT count(*) AS n
FROM scrape_proxy_rotations
WHERE proxy_id = CAST(:proxy_id AS bigint)
AND rotated_at > now() - interval '24 hours'
AND http_status IS NOT NULL
AND http_status != 401
"""
),
{"proxy_id": proxy_id},
)
.mappings()
.fetchone()
)
return int(row["n"]) if row is not None else 0
def _record_attempt(
db: Session,
proxy_id: int,
*,
success: bool,
http_status: int | None,
note: str | None,
) -> None:
"""Записать попытку ротации в аудит-таблицу. Вызывается ТОЛЬКО когда HTTP-запрос
к провайдеру реально был сделан (локально отклонённые попытки не пишутся
см. модуль docstring)."""
db.execute(
text(
"""
INSERT INTO scrape_proxy_rotations (proxy_id, success, http_status, note)
VALUES (
CAST(:proxy_id AS bigint),
CAST(:success AS boolean),
CAST(:http_status AS integer),
CAST(:note AS text)
)
"""
),
{"proxy_id": proxy_id, "success": success, "http_status": http_status, "note": note},
)
db.commit()
def _alert_stale_token(proxy_id: int) -> None:
"""Громкий отказ на 401: logger.error + событие в Sentry/GlitchTip (best-effort).
401 значит, что провайдер отверг Authorization-заголовок токен протух (issue
#2600 п.5: "Токен — сессионный, однажды протухнет. Это осознанное решение
владельца"). Молчаливая остановка ротации недопустима — операторы должны узнать
об этом сразу, а не когда прокси уже забанены неделю.
"""
logger.error(
"proxy_rotation: ASocks REJECTED Authorization (401) for proxy_id=%d"
"ASOCKS_API_TOKEN likely EXPIRED, IP rotation is now BLOCKED for this proxy "
"until the token is refreshed in web-cabinet + env",
proxy_id,
)
try:
import sentry_sdk
sentry_sdk.capture_message(
f"ASocks rotation token rejected (401) for proxy_id={proxy_id}"
"ASOCKS_API_TOKEN expired, IP rotation blocked until refreshed",
level="error",
)
except Exception:
pass # sentry_sdk not initialised in dev — best-effort only
def _extract_new_ip(resp: httpx.Response) -> str | None:
"""Best-effort вытащить новый exit-IP из ответа провайдера. Формат ответа
refresh-ip для безлимитных портов ASocks не документирован (issue #2600 п.5) —
парсинг заведомо defensive, неудача не является ошибкой ротации."""
try:
data: Any = resp.json()
except Exception:
return None
if not isinstance(data, dict):
return None
for key in ("new_ip", "ip", "exit_ip"):
val = data.get(key)
if val:
return str(val)
nested = data.get("data")
if isinstance(nested, dict):
for key in ("new_ip", "ip", "exit_ip"):
val = nested.get(key)
if val:
return str(val)
return None
async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult:
"""Сменить exit-IP одного прокси пула через ASocks refresh-ip (#2600 п.5).
Порядок:
1. proxy_id не найден в scrape_proxies ok=False, reason нейтральный.
2. rotate_url пусто ok=False, "ротация не поддерживается" (НЕ ошибка).
3. rotate_url хост != ALLOWED_ROTATE_HOST (https://api.asocks.com) ok=False
ДО HTTP-вызова токен не должен уйти на чужой провайдер (mobileproxy
changeip и т.п. в этой же колонке пула, см. "⛔ Хост-пиннинг" в модуле).
4. ASOCKS_API_TOKEN не задан (settings.asocks_api_token) ok=False,
внятный отказ, ничего не ломается.
5. Суточный лимит (см. _quota_used_today) исчерпан ok=False, отказ БЕЗ
обращения к API.
6. POST rotate_url с Authorization: Bearer <token>, timeout ~30с.
- Сетевая ошибка (нет ответа) ok=False, аудит-запись http_status=NULL
(НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True.
- 401 громкий отказ (_alert_stale_token) + аудит-запись (НЕ считается
в лимите), нейтральный reason.
- Другой 4xx/5xx аудит-запись (считается в лимите провайдер прошёл
auth и ответил своей бизнес-логикой), нейтральный reason.
- 2xx аудит-запись success=True (считается в лимите), new_ip best-effort.
Ни в одном из reason/логов НЕ появляется токен.
"""
row = (
db.execute(
text("SELECT id, rotate_url FROM scrape_proxies WHERE id = CAST(:id AS bigint)"),
{"id": proxy_id},
)
.mappings()
.fetchone()
)
if row is None:
return RotationResult(ok=False, reason="proxy not found")
rotate_url = row["rotate_url"]
if not rotate_url:
logger.info(
"proxy_rotation: proxy_id=%d has no rotate_url — rotation not supported", proxy_id
)
return RotationResult(
ok=False, reason="rotation not supported for this proxy (no rotate_url configured)"
)
if not _is_allowed_rotate_url(rotate_url):
# scrape_proxies.rotate_url колонка неоднородна (другие строки пула несут
# mobileproxy changeip-ссылки с ИХ секретом) — отправлять наш
# Authorization: Bearer <ASOCKS_API_TOKEN> на непроверенный хост нельзя.
# Логируем ТОЛЬКО hostname (не полный url — на других провайдерах он
# несёт их собственный секрет в query-string, тот же класс утечки, что
# и в rotate_proxy_ip, см. модуль docstring).
logger.warning(
"proxy_rotation: proxy_id=%d rotate_url host=%r is not the allowed ASocks host "
"(%s) — refusing before any HTTP call to avoid leaking the token to it",
proxy_id,
urlparse(rotate_url).hostname,
ALLOWED_ROTATE_HOST,
)
return RotationResult(
ok=False, reason="rotation not supported for this proxy (unexpected rotate host)"
)
token = settings.asocks_api_token
if not token:
logger.warning(
"proxy_rotation: ASOCKS_API_TOKEN not configured — proxy_id=%d rotation skipped",
proxy_id,
)
return RotationResult(ok=False, reason="rotation not configured (missing API token)")
used = _quota_used_today(db, proxy_id)
if used >= DAILY_ROTATION_LIMIT:
logger.warning(
"proxy_rotation: daily limit reached proxy_id=%d used=%d/%d — skipping API call",
proxy_id,
used,
DAILY_ROTATION_LIMIT,
)
return RotationResult(
ok=False,
reason=f"daily rotation limit reached ({DAILY_ROTATION_LIMIT}/day)",
rotations_remaining_today=0,
)
try:
async with httpx.AsyncClient(timeout=_ROTATE_TIMEOUT_S) as client:
resp = await client.post(rotate_url, headers={"Authorization": f"Bearer {token}"})
except Exception as exc:
# Ответа не было вообще — не подтверждено, что запрос дошёл до провайдера,
# значит квота НЕ тратится. str(exc) НИКОГДА не идёт наружу (может нести
# служебные детали соединения) — только exc_info=True в лог. type(exc).__name__
# секрета не несёт (это имя класса — ConnectError/ReadTimeout/…) и в note
# ПОЛЕЗЕН оператору: отличить "не дозвонились" от "дозвонились, зависли".
logger.warning(
"proxy_rotation: request failed (no response) proxy_id=%d", proxy_id, exc_info=True
)
_record_attempt(
db,
proxy_id,
success=False,
http_status=None,
note=f"request failed: {type(exc).__name__}",
)
return RotationResult(
ok=False,
reason="rotation request failed (network error)",
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - used),
)
status = resp.status_code
if status == 401:
_alert_stale_token(proxy_id)
_record_attempt(
db,
proxy_id,
success=False,
http_status=401,
note="unauthenticated — token expired/invalid (excluded from daily quota)",
)
return RotationResult(
ok=False,
reason="rotation service rejected credentials — alerted, contact operator",
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - used),
)
if status >= 400:
logger.warning(
"proxy_rotation: provider returned error proxy_id=%d status=%d", proxy_id, status
)
_record_attempt(
db, proxy_id, success=False, http_status=status, note="provider returned error"
)
return RotationResult(
ok=False,
reason=f"rotation request failed (provider status {status})",
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - (used + 1)),
)
new_ip = _extract_new_ip(resp)
logger.info("proxy_rotation: rotated proxy_id=%d status=%d new_ip=%s", proxy_id, status, new_ip)
_record_attempt(db, proxy_id, success=True, http_status=status, note=None)
# Площадки банили СТАРЫЙ exit-IP, а строка бана привязана к proxy_id (#2600 п.2) —
# после смены адреса она держала бы узел вне выдачи уже без причины, вплоть до 72ч
# при эскалации. Ротация прошла → история банов этого узла недействительна.
clear_source_bans(db, proxy_id, reason=f"exit ip rotated (status={status})")
return RotationResult(
ok=True,
reason=None,
new_ip=new_ip,
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - (used + 1)),
)

View file

@ -50,9 +50,23 @@ sber_index.py для sberindex.ru (см. #922, тот же паттерн: пу
отвечает HTTP 403 без браузерного User-Agent шлём Chrome UA (тот же паттерн, отвечает HTTP 403 без браузерного User-Agent шлём Chrome UA (тот же паттерн,
что DEFAULT_UA в zhkh_flats_loader.py). что DEFAULT_UA в zhkh_flats_loader.py).
При сетевой ошибке / HTTP 5xx / таймауте логируем warning, возвращаем УРОВНИ СИГНАЛОВ (#2674 — в контейнере скрапера событием GlitchTip становится только
available=False. Отсутствие папки/файла квартала available=False (штатный запись 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 from __future__ import annotations
@ -63,6 +77,7 @@ from typing import Any
from urllib.parse import quote, unquote, urljoin from urllib.parse import quote, unquote, urljoin
import httpx import httpx
import sentry_sdk
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -243,7 +258,8 @@ async def check_new_quarter_available(
try: try:
index_resp = await client.get(_DATA_SETS_BASE_URL, follow_redirects=True) index_resp = await client.get(_DATA_SETS_BASE_URL, follow_redirects=True)
if index_resp.status_code != 200: 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", "rosreestr_poll: unexpected HTTP %d listing %s — treating Q%d %d as unavailable",
index_resp.status_code, index_resp.status_code,
_DATA_SETS_BASE_URL, _DATA_SETS_BASE_URL,
@ -265,7 +281,9 @@ async def check_new_quarter_available(
folder_url = urljoin(_DATA_SETS_BASE_URL, folder_href) folder_url = urljoin(_DATA_SETS_BASE_URL, folder_href)
folder_resp = await client.get(folder_url, follow_redirects=True) folder_resp = await client.get(folder_url, follow_redirects=True)
if folder_resp.status_code != 200: if folder_resp.status_code != 200:
logger.warning( # ERROR (#2674): папка квартала НАЙДЕНА в каталоге, но не открывается —
# это уже не «ещё не опубликовали», а поломка портала.
logger.error(
"rosreestr_poll: unexpected HTTP %d listing folder %s" "rosreestr_poll: unexpected HTTP %d listing folder %s"
"treating Q%d %d as unavailable", "treating Q%d %d as unavailable",
folder_resp.status_code, folder_resp.status_code,
@ -309,7 +327,13 @@ async def check_new_quarter_available(
) )
return True 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 " "rosreestr_poll: Q%d %d file found (%s) but failed availability check "
"(HTTP %d, Content-Type=%r, Content-Length=%d) — soft-404 guard, " "(HTTP %d, Content-Type=%r, Content-Length=%d) — soft-404 guard, "
"treating as unavailable", "treating as unavailable",
@ -338,12 +362,14 @@ async def check_new_quarter_available(
exc, exc,
) )
return False return False
except Exception as exc: except Exception:
logger.warning( # ERROR + traceback (#2674): сюда попадает НАШ баг (сменилась разметка, упал
"rosreestr_poll: unexpected error checking Q%d %d: %s — treating as unavailable", # парсер href'ов), а не сбой сети. Под WARNING он молча превращался в
# «квартала нет» — ровно тот сценарий, из-за которого поллер врал кварталами.
logger.exception(
"rosreestr_poll: unexpected error checking Q%d %d — treating as unavailable",
quarter, quarter,
year, year,
exc,
) )
return False return False
@ -409,6 +435,21 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
rosreestr_dataset_url(next_year, next_quarter), rosreestr_dataset_url(next_year, next_quarter),
_DATA_SETS_BASE_URL, _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 { return {
"available": available, "available": available,

View file

@ -464,8 +464,16 @@ async def pull_sber_indices(
# path or its filter dims are stale (sber renames slugs / changes # path or its filter dims are stale (sber renames slugs / changes
# dimension codes). Surface it loudly with the slug + filter so the # dimension codes). Surface it loudly with the slug + filter so the
# next breakage is diagnosable instead of a silent error-counter bump. # 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: if exc.response.status_code == 404:
logger.warning( logger.error(
"sber_index: 404 for dashboard=%s ref_area=%s filter=%s" "sber_index: 404 for dashboard=%s ref_area=%s filter=%s"
"dataset-path invalid? slug renamed or filter dims stale " "dataset-path invalid? slug renamed or filter dims stale "
"(re-capture /dataset/v1/<slug> via dashboard route-interception)", "(re-capture /dataset/v1/<slug> via dashboard route-interception)",

View file

@ -12,6 +12,7 @@ scheduling-путь (`app/scheduler_main.py` безусловно запуска
Что осталось в этом модуле НЕ scheduler-loop, а функции с живыми потребителями вне Что осталось в этом модуле НЕ scheduler-loop, а функции с живыми потребителями вне
удалённой machinery: удалённой machinery:
- `compute_next_run_at` читается admin.py (операторский предпросмотр "next run"). - `compute_next_run_at` читается admin.py (операторский предпросмотр "next run").
С #2674 это re-export kit-версии, а не вторая копия формулы.
- `has_running_run` читается admin.py (UI-индикатор "уже бежит"). - `has_running_run` читается admin.py (UI-индикатор "уже бежит").
- `import_rosreestr_dkp` job-тело, вызываемое kit-handler'ом - `import_rosreestr_dkp` job-тело, вызываемое kit-handler'ом
product_handlers._job_rosreestr_dkp (lazy import). product_handlers._job_rosreestr_dkp (lazy import).
@ -25,16 +26,24 @@ Zombie-reap, advisory-lock claim и tick-loop теперь целиком в
from __future__ import annotations from __future__ import annotations
import logging import logging
import random
from datetime import UTC, datetime, time, timedelta
from typing import Any 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 import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.shutdown import shutdown_requested from app.core.shutdown import shutdown_requested
from app.services import scrape_runs as runs_mod from app.services import scrape_runs as runs_mod
__all__ = ["compute_next_run_at", "has_running_run"]
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# import_rosreestr_dkp: доля per-row INSERT-ошибок (rows_errored / rows_fetched), выше # 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 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
(например 223 окно 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: def has_running_run(db: Session, source: str) -> bool:
"""Есть ли активный run для source (status='running').""" """Есть ли активный run для source (status='running')."""
row = db.execute( row = db.execute(
@ -115,7 +76,16 @@ async def _execute_cian_backfill(
"""Orchestrate Cian history backfill with heartbeat + checkpoint. """Orchestrate Cian history backfill with heartbeat + checkpoint.
Wraps backfill_cian_history(), updating scrape_runs counters (via update_heartbeat) 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 Checkpoint/resume semantics: backfill_cian_history() queries rows WHERE history IS
NULL via LEFT JOIN so re-running after a partial completion naturally skips 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): Params (from default_params jsonb):
batch_size: int rows per run (listings + houses counted separately). 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)) 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] = { counters: dict[str, int] = {
"listings_processed": 0, "listings_processed": 0,
"listings_succeeded": 0, "listings_succeeded": 0,
@ -145,17 +139,10 @@ async def _execute_cian_backfill(
do_listings=True, do_listings=True,
do_houses=True, do_houses=True,
do_valuations=False, do_valuations=False,
on_progress=_heartbeat,
) )
counters = { counters = {**_counters(result), "duration_sec": int(result.duration_sec)}
"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),
}
runs_mod.mark_done(db, run_id, counters) runs_mod.mark_done(db, run_id, counters)
logger.info( logger.info(
"scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs", "scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs",

View file

@ -2,12 +2,39 @@
Таблица scrape_runs создана в 015_scrape_runs.sql. Таблица scrape_runs создана в 015_scrape_runs.sql.
Расширена в 051_scrape_runs_extend.sql: params/counters/error/finished_at/cancelled. Расширена в 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 from __future__ import annotations
import json import json
import logging import logging
from collections.abc import Callable, Mapping
from functools import cache
from typing import Any from typing import Any
import sentry_sdk import sentry_sdk
@ -21,6 +48,130 @@ logger = logging.getLogger(__name__)
# (anti-spam: не на каждой последующей). # (anti-spam: не на каждой последующей).
CONSECUTIVE_FAILURE_ALERT_THRESHOLD = 3 CONSECUTIVE_FAILURE_ALERT_THRESHOLD = 3
# #2625: количество последовательных 'done' запусков с нулевым бизнес-результатом
# (total_seen=0), при достижении которого отправляется Sentry alert. Статус 'done'
# формально успешен (errors_count=0), но капча/пустая выдача/смена вёрстки источника
# без детекта (см. providers/cian, providers/yandex) деградируют молча — этот класс
# невидим для 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" # наш сайдкар/прокси не отдал страницу — внутреннее
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 _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
"""Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters. """Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.
@ -32,41 +183,37 @@ def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
показывала total_seen=0 при реально сохранённых строках (audit #1871/#1926). показывала total_seen=0 при реально сохранённых строках (audit #1871/#1926).
Приоритет ключей: Приоритет ключей:
- total_seen 'total_seen' (если уже есть в counters) иначе 'lots_fetched' - total_seen _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched)
- new_count 'new_count' (если уже есть) иначе 'lots_inserted' - new_count 'new_count' (если уже есть) иначе 'lots_inserted'
Возвращает (total_seen, new_count); None для ключа, которого нет в counters Возвращает (total_seen, new_count); None для ключа, которого нет в counters
тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE). тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE).
""" """
return _run_result_count(counters), _pick_int(counters, "new_count", "lots_inserted")
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")
def _alert_if_consecutive_failures(db: Session, source: str) -> None: def _alert_if_consecutive_failures(db: Session, source: str) -> None:
"""Отправить Sentry alert если последние CONSECUTIVE_FAILURE_ALERT_THRESHOLD """Sentry alert на серию из CONSECUTIVE_FAILURE_ALERT_THRESHOLD неудач подряд
завершённых запусков для данного source имеют статус 'failed' или 'banned'. (статусы 'failed'/'banned') у данного source.
Anti-spam: алерт срабатывает ТОЛЬКО когда стрик РОВНО равен порогу т.е. запрос Anti-spam: не на каждой неудаче, а по разреженной лестнице вех (см.
возвращает ровно N последних (failed|banned) и (N+1)-й, если существует, НЕ является _streak_alert_due). До #2670 алерт приходил РОВНО на N-й неудаче и дальше не
failed/banned. Это предотвращает повторный алерт на каждой ошибке сверх порога. повторялся никогда: серия, ставшая длиннее порога, замолкала навсегда. На проде
это дало avito_full_load 31 неудача подряд, 34 дня без единого успешного
прогона, один алерт за всё время.
Стрик прерывается любым завершением, кроме failed/banned, по данным прода это
достижимо и достигается (у domclick_city_sweep текущий стрик равен 1 при 47
завершённых прогонах), поэтому лестница не вырождается в постоянный алерт.
Best-effort: весь блок обёрнут в try/except сбой запроса или неинициализированный Best-effort: весь блок обёрнут в try/except сбой запроса или неинициализированный
Sentry НЕ должен нарушать вызывающий mark_* путь. Sentry НЕ должен нарушать вызывающий mark_* путь.
""" """
if sentry_sdk is None:
return
n = CONSECUTIVE_FAILURE_ALERT_THRESHOLD n = CONSECUTIVE_FAILURE_ALERT_THRESHOLD
try: try:
# Берём последние N+1 завершённых (non-running) запусков по source. # Завершённые (non-running) прогоны источника, самые свежие первыми.
# Сортируем по finished_at DESC чтобы самые свежие шли первыми.
rows = db.execute( rows = db.execute(
text( text(
""" """
@ -77,39 +224,116 @@ def _alert_if_consecutive_failures(db: Session, source: str) -> None:
LIMIT :limit LIMIT :limit
""" """
), ),
{"source": source, "limit": n + 1}, {"source": source, "limit": STREAK_SCAN_LIMIT},
).fetchall() ).fetchall()
if len(rows) < n: streak = _leading_streak(rows, lambda r: r.status in ("failed", "banned"))
# Ещё не набралось N завершённых запусков вообще — алерт не нужен. capped = streak >= STREAK_SCAN_LIMIT
if not capped and not _streak_alert_due(streak, n):
return 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( 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).", "manual intervention may be required (expired cookies / ban / broken parser).",
level="error", level="error",
) )
logger.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: except Exception:
pass # sentry_sdk not initialised in dev, or query failed — best-effort only pass # sentry_sdk not initialised in dev, or query failed — best-effort only
def _alert_on_run_id(db: Session, run_id: int) -> None: def _alert_if_consecutive_zero_results(db: Session, source: str) -> None:
"""Вспомогательная обёртка: извлекает source по run_id и вызывает """Отправить Sentry alert если последние CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD
_alert_if_consecutive_failures. Best-effort не бросает исключений. завершённых 'done' запусков для source дали ИЗМЕРЕННЫЙ нулевой результат (#2625).
Отличается от _alert_if_consecutive_failures: статус здесь формально 'done'
(errors_count=0) деградация невидима существующему failed/banned алерту.
Причина обычно капча/пустая выдача источника, у которого нет (или не сработал)
детект блокировки (см. providers/cian/serp.py, providers/yandex/serp.py).
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 путь.
"""
n = CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD
try:
# Те же non-running статусы, что у _alert_if_consecutive_failures — стрик
# 'done'-с-нулём прерывается ЛЮБЫМ другим завершением (failed/banned/done-
# с-результатом/cancelled/прогон без результатной метрики), не только успешным
# сбором. counters, а НЕ колонка total_seen: у колонки DEFAULT 0, по ней
# «не измерено» неотличимо от «ноль» (#2703).
rows = db.execute(
text(
"""
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": STREAK_SCAN_LIMIT},
).fetchall()
if not rows:
return
def _is_zero_done(r: Any) -> bool:
"""Только ИЗМЕРЕННЫЙ ноль. Прогон без результатной метрики стрик ПРЕРЫВАЕТ.
Так недостижимое условие прерывания невозможно по построению: источник,
чей словарь счётчиков сторожу неизвестен, не копит ложный стрик и не
запирает анти-спам «один раз на стрик» в «один раз навсегда».
"""
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
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 {streak} consecutive 'done' runs with zero "
"lots fetched — captcha/layout-change likely undetected "
"(manual check recommended).",
level="error",
)
logger.error(
"sentry alert sent: source=%s has %d consecutive zero-result 'done' runs",
source,
streak,
)
except Exception:
pass # sentry_sdk not initialised in dev, or query failed — best-effort only
def _alert_on_run_id(
db: Session,
run_id: int,
*,
checker: Callable[[Session, str], None] = _alert_if_consecutive_failures,
) -> None:
"""Вспомогательная обёртка: извлекает source по run_id и вызывает `checker`
(default _alert_if_consecutive_failures; mark_done передаёт
_alert_if_consecutive_zero_results #2625). Best-effort — не бросает исключений.
""" """
try: try:
row = db.execute( row = db.execute(
@ -118,22 +342,29 @@ def _alert_on_run_id(db: Session, run_id: int) -> None:
).fetchone() ).fetchone()
if row is None: if row is None:
return return
_alert_if_consecutive_failures(db, str(row.source)) checker(db, str(row.source))
except Exception: except Exception:
pass pass
def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int: 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). Returns run_id (bigint).
""" """
row = db.execute( row = db.execute(
text( text(
""" """
INSERT INTO scrape_runs (source, status, params, started_at, heartbeat_at) 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 RETURNING id
""" """
), ),
@ -145,7 +376,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: 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) и total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и
пишутся в выделенные колонки, чтобы observability не показывала 0 (audit #1926). пишутся в выделенные колонки, чтобы observability не показывала 0 (audit #1926).
@ -156,7 +387,7 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None
text( text(
""" """
UPDATE scrape_runs UPDATE scrape_runs
SET heartbeat_at = NOW(), SET heartbeat_at = clock_timestamp(),
counters = CAST(:counters AS jsonb), counters = CAST(:counters AS jsonb),
total_seen = COALESCE(CAST(:total_seen AS int), total_seen), total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count) new_count = COALESCE(CAST(:new_count AS int), new_count)
@ -173,6 +404,27 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None
db.commit() 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: def is_cancelled(db: Session, run_id: int) -> bool:
"""Проверить status='cancelled' (cooperative cancel в long-running pipeline).""" """Проверить status='cancelled' (cooperative cancel в long-running pipeline)."""
row = db.execute( row = db.execute(
@ -183,7 +435,7 @@ def is_cancelled(db: Session, run_id: int) -> bool:
def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: 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) и пишутся total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся
в выделенные колонки иначе admin/observability показывает 0 (audit #1926). в выделенные колонки иначе admin/observability показывает 0 (audit #1926).
@ -193,7 +445,8 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
text( text(
""" """
UPDATE scrape_runs 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), counters = CAST(:counters AS jsonb),
total_seen = COALESCE(CAST(:total_seen AS int), total_seen), total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count) new_count = COALESCE(CAST(:new_count AS int), new_count)
@ -211,6 +464,10 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
if row is None: if row is None:
logger.warning("mark_done no-op: run_id=%d not in 'running' state", run_id) logger.warning("mark_done no-op: run_id=%d not in 'running' state", run_id)
db.commit() db.commit()
# #2625: N подряд 'done' с нулевым бизнес-результатом — деградация, невидимая
# для failed/banned алерта (капча/пустая выдача под видом успеха). Best-effort,
# после коммита — статус уже персистирован в БД.
_alert_on_run_id(db, run_id, checker=_alert_if_consecutive_zero_results)
def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None: def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None:
@ -228,7 +485,8 @@ def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int])
text( text(
""" """
UPDATE scrape_runs 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), error = :error, counters = CAST(:counters AS jsonb),
total_seen = COALESCE(CAST(:total_seen AS int), total_seen), total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count) new_count = COALESCE(CAST(:new_count AS int), new_count)
@ -251,11 +509,29 @@ def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int])
_alert_on_run_id(db, run_id) _alert_on_run_id(db, run_id)
def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None: def mark_banned(
"""Финализация run: status='banned' (IP заблокирован Avito — 403/captcha). db: Session,
run_id: int,
error: str,
counters: dict[str, int],
*,
ban_kind: str = BAN_KIND_PLATFORM,
) -> None:
"""Финализация run: status='banned' + диагноз ban_kind (#2686).
Per migration 015 'banned' задокументирован как 'Avito вернул 403/captcha'. 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 упала НАША инфраструктура (браузерный сайдкар/прокси).
Значение приходит от места ПОРОЖДЕНИЯ отказа (тип исключения), а не из разбора
текста ошибки. Default 'platform' = историческая семантика статуса, поэтому
вызывающие, которым разводить нечего, не меняются.
Оба исхода одинаково сохраняют чекпоинт они отличаются только диагнозом.
Defensive rollback: если до этого вызова в той же транзакции был ошибочный UPDATE, Defensive rollback: если до этого вызова в той же транзакции был ошибочный UPDATE,
он мог оставить сессию в error state rollback сбрасывает состояние. он мог оставить сессию в error state rollback сбрасывает состояние.
@ -269,8 +545,10 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int])
text( text(
""" """
UPDATE scrape_runs 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), error = :error, counters = CAST(:counters AS jsonb),
ban_kind = :ban_kind,
total_seen = COALESCE(CAST(:total_seen AS int), total_seen), total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
new_count = COALESCE(CAST(:new_count AS int), new_count) new_count = COALESCE(CAST(:new_count AS int), new_count)
WHERE id = :run_id AND status = 'running' WHERE id = :run_id AND status = 'running'
@ -281,6 +559,7 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int])
"run_id": run_id, "run_id": run_id,
"error": error[:1000], "error": error[:1000],
"counters": json.dumps(counters), "counters": json.dumps(counters),
"ban_kind": ban_kind,
"total_seen": total_seen, "total_seen": total_seen,
"new_count": new_count, "new_count": new_count,
}, },
@ -292,13 +571,93 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int])
_alert_on_run_id(db, run_id) _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,
) -> 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`: прогон,
который подтвердил снятие объявлений, работу сделал.
"""
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)
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} попыток (#2674)"
)
logger.error("%s run_id=%d", reason, run_id)
mark_banned(db, run_id, reason, counters)
return
if produced == 0:
reason = (
f"backfill-honest-status: {source} без результата — 0 обогащено из "
f"{attempted} попыток (failed={counters.get('failed', 0)}, "
f"blocked={blocked}) (#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: 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( result = db.execute(
text( text(
""" """
UPDATE scrape_runs UPDATE scrape_runs
SET status = 'cancelled', finished_at = NOW() SET status = 'cancelled', finished_at = clock_timestamp()
WHERE id = :run_id AND status = 'running' WHERE id = :run_id AND status = 'running'
RETURNING id RETURNING id
""" """
@ -366,8 +725,8 @@ def list_all(
db.execute( db.execute(
text( text(
f""" f"""
SELECT id AS run_id, source, run_type, status, params, counters, SELECT id AS run_id, source, status, params, counters,
total_seen, new_count, started_at, finished_at, ban_kind, total_seen, new_count, started_at, finished_at,
heartbeat_at, error AS error_text heartbeat_at, error AS error_text
FROM scrape_runs FROM scrape_runs
WHERE {where_sql} WHERE {where_sql}
@ -381,3 +740,22 @@ def list_all(
.all() .all()
) )
return total, [dict(r) for r in rows] 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]

View file

@ -66,7 +66,6 @@ class RealMatcherAdapter:
*, *,
year_built: int | None = None, year_built: int | None = None,
building_cadastral_number: str | None = None, building_cadastral_number: str | None = None,
cadastral_number: str | None = None,
source_url: str | None = None, source_url: str | None = None,
) -> tuple[int | None, float, str]: ) -> tuple[int | None, float, str]:
# house_id is None when the matcher refuses a numberless address without a # house_id is None when the matcher refuses a numberless address without a
@ -80,7 +79,6 @@ class RealMatcherAdapter:
lon, lon,
year_built=year_built, year_built=year_built,
building_cadastral_number=building_cadastral_number, building_cadastral_number=building_cadastral_number,
cadastral_number=cadastral_number,
source_url=source_url, source_url=source_url,
) )
@ -136,10 +134,6 @@ class RealScraperConfig:
def scraper_proxy_url(self) -> str | None: def scraper_proxy_url(self) -> str | None:
return _settings.scraper_proxy_url return _settings.scraper_proxy_url
@property
def avito_proxy_rotate_url(self) -> str | None:
return _settings.avito_proxy_rotate_url
@property @property
def avito_proxy_max_rotations(self) -> int: def avito_proxy_max_rotations(self) -> int:
return _settings.avito_proxy_max_rotations return _settings.avito_proxy_max_rotations
@ -148,10 +142,6 @@ class RealScraperConfig:
def avito_serp_ekb_only(self) -> bool: def avito_serp_ekb_only(self) -> bool:
return _settings.avito_serp_ekb_only return _settings.avito_serp_ekb_only
@property
def yandex_proxy_rotate_url(self) -> str | None:
return _settings.yandex_proxy_rotate_url
@property @property
def cian_proxy_url(self) -> str | None: def cian_proxy_url(self) -> str | None:
return _settings.cian_proxy_url return _settings.cian_proxy_url
@ -189,10 +179,6 @@ class RealScraperConfig:
def proxy_rotate_attempt_timeout_s(self) -> float: def proxy_rotate_attempt_timeout_s(self) -> float:
return _settings.proxy_rotate_attempt_timeout_s return _settings.proxy_rotate_attempt_timeout_s
@property
def cian_proxy_rotate_url(self) -> str | None:
return _settings.cian_proxy_rotate_url
@property @property
def cian_proxy_max_rotations(self) -> int: def cian_proxy_max_rotations(self) -> int:
return _settings.cian_proxy_max_rotations return _settings.cian_proxy_max_rotations
@ -219,6 +205,11 @@ class RealScraperConfig:
def use_proxy_pool_browser(self) -> bool: def use_proxy_pool_browser(self) -> bool:
return _settings.use_proxy_pool_browser return _settings.use_proxy_pool_browser
# ── #2616 шаг 1: признак окружения для отказа вместо мёртвого env-fallback ──
@property
def environment(self) -> str:
return _settings.environment
class RealProxyProvider: class RealProxyProvider:
"""ProxyProvider-адаптер над `app.services.proxy_pool` (#2163). """ProxyProvider-адаптер над `app.services.proxy_pool` (#2163).
@ -267,6 +258,20 @@ class RealProxyProvider:
finally: finally:
db.close() db.close()
def touch(self, lease: ProxyLease) -> None:
db = _SessionLocal()
try:
_proxy_pool.touch(db, lease.id)
finally:
db.close()
def mark_banned(self, lease: ProxyLease, *, source: str) -> None:
db = _SessionLocal()
try:
_proxy_pool.mark_banned(db, lease.id, source=source)
finally:
db.close()
class RealSessionFactory: class RealSessionFactory:
"""SessionFactory-адаптер над `app.core.db.SessionLocal`.""" """SessionFactory-адаптер над `app.core.db.SessionLocal`."""

View file

@ -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)") where.append("total_floors <= CAST(:fl_total_max AS integer)")
args["fl_total_max"] = params.floors_total_max args["fl_total_max"] = params.floors_total_max
if params.has_kadastr: # Фильтр has_kadastr удалён (#2674): `listings.cadastral_number` (кадастр КВАРТИРЫ)
where.append("cadastral_number IS NOT NULL") # пуст у всех 93 408 объявлений — площадки его не отдают (единственный писатель,
# парсер Циана, читает offer["cadastralNumber"], которого в ответе нет). Предикат
# `cadastral_number IS NOT NULL` мог вернуть только пустую выдачу, т.е. обещал
# качество данных, которого нет. Колонка и её писатель оставлены: если площадка
# начнёт отдавать кадастр, заполнение заработает само — тогда и вернём фильтр.
segment_clause = _SEGMENT_SQL[params.segment] segment_clause = _SEGMENT_SQL[params.segment]
if segment_clause is not None: if segment_clause is not None:

View file

@ -20,12 +20,20 @@ snapshot_listing_sources / import_rosreestr_dkp.
Окно расписания 06:00-07:00 UTC ПОСЛЕ rosreestr_dkp_import (04:00-06:00 UTC), чтобы Окно расписания 06:00-07:00 UTC ПОСЛЕ rosreestr_dkp_import (04:00-06:00 UTC), чтобы
refresh потреблял свежие ДКП-сделки того же дня. refresh потреблял свежие ДКП-сделки того же дня.
SQL derivation ниже БАЙТ-В-БАЙТ та же логика, что seed в data/sql/080_asking_to_sold_ratios.sql SQL derivation ниже повторяет seed в data/sql/080_asking_to_sold_ratios.sql (deal_side /
(deal_side / ask_side / per_bucket + deal_global / ask_global / global_row: трейлинг-12мес ask_side / per_bucket + deal_global / ask_global / global_row: трейлинг-12мес окно, ppm²-полоса
окно, ppm²-полоса [_PPM2_MIN, settings.asking_ratio_ppm2_max] (default [30000,1200000]), [_PPM2_MIN, settings.asking_ratio_ppm2_max] (default [30000,1200000]), порог n_deals>=30 AND
бакет LEAST(GREATEST(rooms,0),4), порог n_deals>=30 AND n_listings>=30 для per_rooms, n_listings>=30 для per_rooms, global -1 строка всегда). ON CONFLICT убран DELETE идёт первым,
global -1 строка всегда). ON CONFLICT убран DELETE идёт первым,
конфликтов нет (повторный прогон в одной tx невозможен, refresh = re-seed по семантике). конфликтов нет (повторный прогон в одной tx невозможен, refresh = re-seed по семантике).
#2620 — ОДНО ПРЕДНАМЕРЕННОЕ РАСХОЖДЕНИЕ с 080: deal_side бакетится по
LEAST(GREATEST(rooms,0),4), а ask_side по _AREA_ROOMS_BUCKET_SQL (площадь, та же формула,
что deals.rooms получает при импорте). Причина deals.rooms НЕ настоящая комнатность
(Росреестр её не отдаёт), это синтетика из площади; сравнивать её с РЕАЛЬНЫМИ комнатами
listings значило сравнивать разные классификации. Замер на проде (2026-08, #2620) показал
миграцию 23-55% объявлений между бакетами при таком сравнении не только в бакете «4+»
(который к тому же обрезан обрезкой ELSE 4, тогда как listings.rooms доходит до 10) и
это и была причина ratio>1 в бакете 4+ (см. _AREA_ROOMS_BUCKET_SQL ниже).
""" """
from __future__ import annotations from __future__ import annotations
@ -41,17 +49,56 @@ from app.services import scrape_runs as runs_mod
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется. # Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
_PPM2_MIN: int = 30_000 _PPM2_MIN: int = 30_000
# #C2 — asking-сторона (listings) покрыта скрейпом ТОЛЬКО по ЕКБ (per-city scrape B1/B2 # #C2 — исторически asking-сторона (listings) была покрыта скрейпом ТОЛЬКО по ЕКБ, а
# ещё нет; в listings даже нет колонки city). Миграция 177 залила ДКП-сделки по всей # миграция 177 залила ДКП-сделки по всей обл.66 (368 городов) → sold-медиана смешивала
# обл.66 (368 городов) → sold-медиана смешивала дешёвую область с ЕКБ-asking и обваливала # дешёвую область с ЕКБ-asking и обваливала ratio (0.877→0.62, «выкупная» 29% системно).
# ratio (0.877→0.62, «выкупная» 29% системно). Скоупим SOLD-сторону (deal_side/deal_global) # Скоупили SOLD-сторону (deal_side/deal_global) на ЕКБ, чтобы sold и asking считались по
# на ЕКБ, чтобы sold и asking считались по ОДНОМУ рынку. Когда появятся oblast-листинги — # ОДНОМУ рынку.
# заменить на per-city ratio через зарезервированный столбец `district` (#647). #
# #2583 H2 (аудит, 2026-08): oblast-развёртки заработали 12 июля — областные объявления
# попали в знаменатель (ask_side/ask_global) без городского скоупа, а sold-сторона
# осталась скоуплена на ЕКБ → асимметрия вернулась с другой стороны (дешёвая область
# занижает ask-медиану → ratio завышен на 2.5-5.3% по всем бакетам, выкупные цены
# системно переплачены). Теперь ask_side/ask_global ТОЖЕ скоупятся этим паттерном
# (предикат `city IS NULL OR city ILIKE :asking_city` — см. комментарий на месте в CTE
# ниже) — симметрично deal-стороне. Когда появится per-city ratio через зарезервированный
# столбец `district` (#647), эта константа станет per-city параметром для обеих сторон.
_ASKING_CITY_PATTERN: str = "%Екатеринбург%" _ASKING_CITY_PATTERN: str = "%Екатеринбург%"
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000). # Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals # QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок. # WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
# #2620 — синтетический "бакет комнат по площади", ИСТОЧНИК ИСТИНЫ:
# tradein-mvp/deploy/import-rosreestr.sh (Росреестр не отдаёт комнатность — deals.rooms
# синтезируется из area_m2 при импорте ровно этим CASE). Три представления ОДНОЙ формулы —
# держи границы (30/44/62/85) в синхроне при правке: shell (import-rosreestr.sh) → SQL
# (эта константа, ask_side ниже) → Python (area_bucket() ниже, estimator.py rekey #2620-2).
_AREA_ROOMS_BUCKET_SQL = (
"CASE WHEN area_m2 < 30 THEN 0 WHEN area_m2 < 44 THEN 1 "
"WHEN area_m2 < 62 THEN 2 WHEN area_m2 < 85 THEN 3 ELSE 4 END"
)
def area_bucket(area_m2: float) -> int:
"""Python-двойник _AREA_ROOMS_BUCKET_SQL (границы ИДЕНТИЧНЫ, #2620).
Используется estimator.py при ПРИМЕНЕНИИ ratio (не только при расчёте здесь)
ratio_resolver должен ключевать по ТОМУ ЖЕ area-бакету, что и ask_side при
деривации, иначе mismatch просто переезжает из расчёта в применение (прод-замер
ревьюера #2620: 310/1038 = 29.9% исторических запросов легли бы в другой бакет
при rooms-ключе vs area-ключе).
"""
if area_m2 < 30:
return 0
if area_m2 < 44:
return 1
if area_m2 < 62:
return 2
if area_m2 < 85:
return 3
return 4
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── True-mirror cleanup: drop all #648 rows before re-derivation ────────────── # ── True-mirror cleanup: drop all #648 rows before re-derivation ──────────────
@ -69,15 +116,18 @@ _DELETE_SQL = text(
# deal_side / ask_side / per_bucket + deal_global / ask_global / global_row: # deal_side / ask_side / per_bucket + deal_global / ask_global / global_row:
# sold_median = percentile_cont(0.5) по deals.price_per_m2 (source='rosreestr', # sold_median = percentile_cont(0.5) по deals.price_per_m2 (source='rosreestr',
# ppm² ∈ [_PPM2_MIN, settings.asking_ratio_ppm2_max], deal_date >= CURRENT_DATE 12 months), # ppm² ∈ [_PPM2_MIN, settings.asking_ratio_ppm2_max], deal_date >= CURRENT_DATE 12 months),
# бакет LEAST(GREATEST(rooms,0),4). # бакет LEAST(GREATEST(rooms,0),4) (rooms уже синтетика-из-площади при импорте, см. #2620
# комментарий у _AREA_ROOMS_BUCKET_SQL выше).
# ask_median = percentile_cont(0.5) по listings.price_per_m2 # ask_median = percentile_cont(0.5) по listings.price_per_m2
# (is_active, та же ppm²-полоса [_PPM2_MIN, asking_ratio_ppm2_max]). # (is_active, та же ppm²-полоса [_PPM2_MIN, asking_ratio_ppm2_max], тот же город что
# SOLD-сторона — city IS NULL OR city ILIKE :asking_city, #2583 H2). Бакет —
# _AREA_ROOMS_BUCKET_SQL (площадь, #2620), НЕ listings.rooms — см. комментарий там.
# per_rooms строки — только при n_deals>=30 AND n_listings>=30 AND ask>0 AND sold>0. # per_rooms строки — только при n_deals>=30 AND n_listings>=30 AND ask>0 AND sold>0.
# global -1 строка (basis='global_fallback') — всегда (если ask>0 AND sold>0). window_months=12. # global -1 строка (basis='global_fallback') — всегда (если ask>0 AND sold>0). window_months=12.
# Порог/окно — литералы; ppm²-полоса передаётся bind-параметрами :ppm2_min/:ppm2_max # Порог/окно — литералы; ppm²-полоса передаётся bind-параметрами :ppm2_min/:ppm2_max
# (безопасно от SQL-инъекций; CAST не нужен — psycopg v3 передаёт int напрямую). # (безопасно от SQL-инъекций; CAST не нужен — psycopg v3 передаёт int напрямую).
_REDERIVE_SQL = text( _REDERIVE_SQL = text(
""" f"""
WITH WITH
-- SOLD медианы по бакетам комнат за трейлинг-12мес (ДКП Росреестра). -- SOLD медианы по бакетам комнат за трейлинг-12мес (ДКП Росреестра).
deal_side AS ( deal_side AS (
@ -93,19 +143,37 @@ _REDERIVE_SQL = text(
AND deal_date >= CURRENT_DATE - INTERVAL '12 months' AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY LEAST(GREATEST(rooms, 0), 4) GROUP BY LEAST(GREATEST(rooms, 0), 4)
), ),
-- ASKING медианы по бакетам комнат среди ТЕКУЩИХ активных объявлений. -- ASKING медианы по ТОМУ ЖЕ area-бакету, что deal_side (#2620) — НЕ по listings.rooms.
-- deals.rooms синтетика из площади (Росреестр её не отдаёт), listings.rooms реальная
-- комнатность; сравнение area-бакета с area-бакетом (не area-бакета с real-rooms-бакетом)
-- убирает миграцию объявлений между бакетами (23-55% строк на проде, 2026-08, #2620) —
-- включая инверсию ratio>1 в бакете «4+» (deals.rooms обрезан ELSE 4, а listings.rooms
-- нет: 110/782 пяти- и более комнатных объявлений раньше схлопывались в бакет 4).
ask_side AS ( ask_side AS (
SELECT SELECT
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket, {_AREA_ROOMS_BUCKET_SQL} AS rooms_bucket,
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median, percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
COUNT(*) AS n_listings COUNT(*) AS n_listings
FROM listings FROM listings
WHERE is_active WHERE is_active
AND rooms IS NOT NULL AND rooms IS NOT NULL
-- #2620 hardening: area_m2 IS NULL falls into the CASE ELSE branch (bucket 4)
-- of _AREA_ROOMS_BUCKET_SQL a latent "everything unmeasured looks like a big
-- flat" trap. Excluded explicitly instead of relying on ELSE-as-junk-drawer.
AND area_m2 IS NOT NULL
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011 -- novostroyki guard (#1186): NULL = legacy вторичка до м.011
AND (listing_segment IS NULL OR listing_segment = 'vtorichka') AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
GROUP BY LEAST(GREATEST(rooms, 0), 4) -- #2583 H2: скоупим ASKING-сторону на тот же город, что и SOLD-сторона
-- (симметрично deal_side выше) иначе дешёвые oblast-объявления (развёртки
-- с 12 июля) занижают ask-медиану и завышают ratio. city IS NULL считается
-- "своим" (не отбрасывается) НАМЕРЕННО: listings.city заполнена пока только у
-- Авито (Циан/Домклик/Яндекс NULL, #2598/#2606), симметричный
-- `city ILIKE :asking_city` без IS NULL выбросил бы ~70% выборки. По мере
-- роста покрытия колонки этот предикат сам ужесточается без правок кода; когда
-- покрытие станет полным заменить на строго симметричный `city ILIKE :asking_city`.
AND (city IS NULL OR city ILIKE :asking_city)
GROUP BY {_AREA_ROOMS_BUCKET_SQL}
), ),
-- Per-rooms строки: только бакеты с обеими сторонами, прошедшие порог 30/30 и ask>0. -- Per-rooms строки: только бакеты с обеими сторонами, прошедшие порог 30/30 и ask>0.
-- Тонкие бакеты (n<30) сюда НЕ попадают estimator делает fallback на -1. -- Тонкие бакеты (n<30) сюда НЕ попадают estimator делает fallback на -1.
@ -149,9 +217,15 @@ _REDERIVE_SQL = text(
FROM listings FROM listings
WHERE is_active WHERE is_active
AND rooms IS NOT NULL AND rooms IS NOT NULL
-- #2620 hardening: same area_m2 IS NOT NULL as ask_side — keeps the global-row
-- population consistent with the per-bucket rows it's a fallback for.
AND area_m2 IS NOT NULL
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011 -- novostroyki guard (#1186): NULL = legacy вторичка до м.011
AND (listing_segment IS NULL OR listing_segment = 'vtorichka') AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
-- #2583 H2: тот же городской скоуп, что и ask_side выше (см. комментарий там
-- про причину city IS NULL == "свой" и #2598/#2606).
AND (city IS NULL OR city ILIKE :asking_city)
), ),
-- Global fallback строка rooms_bucket=-1 (пишется всегда, если ask>0). -- Global fallback строка rooms_bucket=-1 (пишется всегда, если ask>0).
global_row AS ( global_row AS (
@ -210,6 +284,18 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters. Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
LIMITATION (#2620, честно задокументировано — не гард, а факт данных): sold-сторона
(deals) НЕ имеет маркера новостройка/вторичка Росреестр таким свойством ДКП не
делится, а listing_segment (гард #1186) существует только у listings. ask_side/ask_global
отфильтрованы на вторичку, deal_side/deal_global нет. Замер на проде (2026-08, #2620):
доля сделок с year_built >= 2020 (грубый прокси новостройки) 44.1% в бакете «4+» против
23.1% в бакетах 1-3 заметный перекос, но year_built НЕ идентифицирует первичку/вторичку
(продажа квартиры 2021 года постройки в 2026м легитимная вторичка), поэтому фильтр по
году НЕ добавлен (создал бы новую, столь же спекулятивную асимметрию). Area-бакет-фикс
ниже (см. _AREA_ROOMS_BUCKET_SQL) сам по себе убрал инверсию ratio>1 в бакете «4+»
(0.8315 на замере прод-данных 2026-08, было 1.0257) снятие миграции между бакетами было
root cause, а не новостройки.
Returns {"rows_written": N, "per_rooms_rows": M, "used_global_fallback": 0|1}. Returns {"rows_written": N, "per_rooms_rows": M, "used_global_fallback": 0|1}.
""" """
counters: dict[str, int] = { counters: dict[str, int] = {

View file

@ -10,8 +10,9 @@ Legacy listings (older than 2h or outside radius) are never enriched.
Solution: single snapshot SELECT at start (guarantees termination), same proxy Solution: single snapshot SELECT at start (guarantees termination), same proxy
session path as the detail-phase of `run_avito_city_sweep` session path as the detail-phase of `run_avito_city_sweep`
(scraper_kit.orchestration.pipeline). Block handling mirrors that phase: (scraper_kit.orchestration.pipeline). Block handling mirrors that phase:
rotate IP on every block, abort after max_consecutive_blocks (mark_done not rotate IP on every block, abort after max_consecutive_blocks. Статус оборванного
mark_failed -- block is temporary, retry next night via NULL detail_enriched_at). блоками прогона 'banned' (#2674, runs.mark_backfill_finished): работу он не
доделал, остаток снапшота уедет в следующую ночь через NULL detail_enriched_at.
""" """
from __future__ import annotations from __future__ import annotations
@ -30,6 +31,7 @@ from scraper_kit.avito_exceptions import (
AvitoRateLimitedError, AvitoRateLimitedError,
) )
from scraper_kit.browser_fetcher import BrowserFetcher from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.orchestration.pipeline import CITY_LOCATIONS
# #2397 slice B (эпик #2277 decommission scrape_pipeline.py, Part E): раньше # #2397 slice B (эпик #2277 decommission scrape_pipeline.py, Part E): раньше
# _CHROME_HEADERS/_avito_proxies() импортировались из app.services.scrape_pipeline. # _CHROME_HEADERS/_avito_proxies() импортировались из app.services.scrape_pipeline.
@ -46,6 +48,7 @@ from scraper_kit.providers.avito.detail import (
save_detail_enrichment, save_detail_enrichment,
) )
from scraper_kit.providers.avito.serp import AvitoScraper from scraper_kit.providers.avito.serp import AvitoScraper
from scraper_kit.snapshot_writer import upsert_listing_snapshot
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -70,6 +73,31 @@ __all__ = [
"run_avito_detail_backfill", "run_avito_detail_backfill",
] ]
# #2576 этап B: oblast-города (region 66, вне ЕКБ) уже дают листинги (Каменск-
# Уральский), но snapshot-SELECT ниже раньше фильтровал ЖЁСТКО '%/ekaterinburg/%' —
# у всех остальных detail_enriched_at оставался NULL навсегда (без detail-страницы
# нет lat/lon -> листинг молча выпадает из подбора аналогов по радиусу).
# CITY_LOCATIONS.avito_slug — единственный источник правды для avito URL-слага
# города (может отличаться от нашего city_slug: kamensk-uralskiy через дефис,
# verhnyaya_pyshma без "kh") -- дублировать список тут вместо импорта было бы
# risk дрейфа при добавлении новых oblast-городов.
#
# #2578 review: Postgres LIKE трактует '_' как wildcard "один любой символ" (не
# литерал) и '%' как wildcard "любая последовательность" -- два слага из пяти
# (nizhniy_tagil, verhnyaya_pyshma) содержат '_', без экранирования это латентная
# дыра: город с похожим слагом (напр. nizhniyXtagil) молча совпал бы. Сегодня
# коллизий нет (проверено на проде: raw vs escaped паттерны дают одинаковые 776
# совпадений), но экранируем сейчас, а не когда появится реальная коллизия.
# LIKE по умолчанию использует '\' как escape-символ (без явного ESCAPE) —
# подтверждено на живом Postgres 16.4 (см. коммит #2578-fixup): 'nizhniyXtagil'
# матчит неэкранированный '%/nizhniy_tagil/%' (LIKE default '_'=wildcard) и НЕ
# матчит экранированный '%/nizhniy\_tagil/%' (LIKE '\_' = литерал '_'); точный
# слаг 'nizhniy_tagil' матчит оба варианта -- позитивный кейс не сломан.
_OBLAST_AVITO_URL_PATTERNS = tuple(
"%/" + loc.avito_slug.replace("\\", "\\\\").replace("_", "\\_").replace("%", "\\%") + "/%"
for loc in CITY_LOCATIONS.values()
)
@dataclass @dataclass
class AvitoDetailBackfillResult: class AvitoDetailBackfillResult:
@ -102,15 +130,22 @@ async def run_avito_detail_backfill(
"""Backfill detail_enriched_at for legacy avito listings via mobile proxy. """Backfill detail_enriched_at for legacy avito listings via mobile proxy.
Params (from default_params jsonb in scrape_schedules): Params (from default_params jsonb in scrape_schedules):
batch_size: int -- snapshot size (SELECT LIMIT), default 800. batch_size: int -- ЕКБ snapshot size (SELECT LIMIT), default 800 (unchanged,
#2576 -- volume/order for ЕКБ stay byte-identical to pre-oblast behaviour).
oblast_batch_size: int -- ДОПОЛНИТЕЛЬНАЯ reserved-квота для листингов
области (#2576), default 100. Отдельный LIMIT, НЕ отъедает от batch_size
ЕКБ -- гарантирует области честную обработку и одновременно не даёт
всплеску свежих oblast-листингов вытеснить ЕКБ из top-N по scraped_at.
budget_sec: float -- wall-clock budget per run, default 3600s. budget_sec: float -- wall-clock budget per run, default 3600s.
request_delay_sec: float -- delay between listings, default 6.0s. request_delay_sec: float -- delay between listings, default 6.0s.
max_consecutive_blocks: int -- abort threshold, default 5. max_consecutive_blocks: int -- abort threshold, default 5.
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard -> 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)) 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)) budget_sec = float(params.get("budget_sec", 3600))
request_delay_sec = float(params.get("request_delay_sec", 6.0)) request_delay_sec = float(params.get("request_delay_sec", 6.0))
max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5)) max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5))
@ -141,7 +176,7 @@ async def run_avito_detail_backfill(
# kit AvitoScraper требует ScraperConfig позиционно (Strangler-инжекция #2133) — # kit AvitoScraper требует ScraperConfig позиционно (Strangler-инжекция #2133) —
# RealScraperConfig проксирует settings.* так же, как читал legacy-конструктор без # RealScraperConfig проксирует settings.* так же, как читал legacy-конструктор без
# аргументов (avito_proxy_rotate_url и т.д. для _rotate_ip()). # аргументов (scraper_proxy_url и т.д. для _build_cffi_session()/_rotate_ip()).
scraper = AvitoScraper(RealScraperConfig()) scraper = AvitoScraper(RealScraperConfig())
start = time.monotonic() start = time.monotonic()
@ -189,30 +224,59 @@ async def run_avito_detail_backfill(
runs_mod.update_heartbeat(db, run_id, current_counters) runs_mod.update_heartbeat(db, run_id, current_counters)
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop. # SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
# Scope (#1814): только активные ЕКБ-листинги. region_code на insert # Scope (#1814, расширено #2576): активные листинги ЕКБ + известных oblast-
# хардкодится в 66 (base.py) → НЕ дискриминирует legacy не-ЕКБ; реальный # городов (region 66). region_code на insert хардкодится в 66 (base.py) →
# признак региона у Avito — путь URL (/ekaterinburg/ для ЕКБ; legacy # НЕ дискриминирует город; реальный признак города у Avito — путь URL
# Москва/СПб/Тюмень — /moskva//sankt-peterburg//tyumen/). browser-fetch # (/ekaterinburg/ для ЕКБ; legacy Москва/СПб/Тюмень — /moskva//sankt-
# на legacy не-ЕКБ спотыкается → curl-fallback → 429-бан curl-фингерпринта. # peterburg//tyumen/ — те по-прежнему вне scope, НЕ входят ни в ekb, ни в
# Не тратим фетчи на мёртвые (is_active) и не-ЕКБ. # oblast CTE). browser-fetch на legacy не-ЕКБ/не-oblast спотыкается →
# curl-fallback → 429-бан curl-фингерпринта. Не тратим фетчи на мёртвые
# (is_active) и на регионы вне scope.
#
# Два CTE вместо одного WHERE ... OR ...: ekb сохраняет ТОЧНО прежний
# LIMIT/ORDER (#2576 требование "ЕКБ не деградирует") -- oblast НЕ может
# вытеснить ЕКБ из batch_size ни при каком всплеске свежих oblast-строк
# (ORDER BY ... scraped_at DESC в общем WHERE отдал бы приоритет самым
# свежим независимо от города). oblast получает отдельную честную квоту
# oblast_batch_size, добавленную ПОСЛЕ ekb-квоты (не вычтенную из неё).
snapshot = ( snapshot = (
db.execute( db.execute(
text( text(
""" """
SELECT id, source_url WITH ekb AS (
SELECT id, source_url, price_rub, 'ekb' AS city_scope
FROM listings FROM listings
WHERE source = 'avito' WHERE source = 'avito'
AND detail_enriched_at IS NULL AND detail_enriched_at IS NULL
AND source_url IS NOT NULL AND source_url IS NOT NULL
AND is_active = TRUE AND is_active = TRUE
AND source_url LIKE '%/ekaterinburg/%' AND source_url LIKE '%/ekaterinburg/%'
-- сперва листинги без координат (#1967 — detail-страница даёт -- сперва листинги без координат (#1967 — detail-страница
-- координаты здания), затем по свежести -- даёт координаты здания), затем по свежести
ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST
LIMIT CAST(:batch_size AS int) LIMIT CAST(:batch_size AS int)
),
oblast AS (
SELECT id, source_url, price_rub, 'oblast' AS city_scope
FROM listings
WHERE source = 'avito'
AND detail_enriched_at IS NULL
AND source_url IS NOT NULL
AND is_active = TRUE
AND source_url LIKE ANY(CAST(:oblast_patterns AS text[]))
ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST
LIMIT CAST(:oblast_batch_size AS int)
)
SELECT id, source_url, price_rub, city_scope FROM ekb
UNION ALL
SELECT id, source_url, price_rub, city_scope FROM oblast
""" """
), ),
{"batch_size": batch_size}, {
"batch_size": batch_size,
"oblast_patterns": list(_OBLAST_AVITO_URL_PATTERNS),
"oblast_batch_size": oblast_batch_size,
},
) )
.mappings() .mappings()
.all() .all()
@ -227,11 +291,16 @@ async def run_avito_detail_backfill(
runs_mod.mark_done(db, run_id, current_counters) runs_mod.mark_done(db, run_id, current_counters)
return counters return counters
# #2576: разбивка ekb/oblast только для наблюдаемости -- .get() консервативен
# (city_scope нет в mock-снапшотах старых тестов, дефолт "ekb" их не ломает).
oblast_count = sum(1 for row in snapshot if row.get("city_scope") == "oblast")
logger.info( logger.info(
"avito_detail_backfill: run_id=%d snapshot=%d (budget=%.0fs " "avito_detail_backfill: run_id=%d snapshot=%d (ekb=%d oblast=%d, "
"delay=%.1fs max_blocks=%d mode=%s)", "budget=%.0fs delay=%.1fs max_blocks=%d mode=%s)",
run_id, run_id,
len(snapshot), len(snapshot),
len(snapshot) - oblast_count,
oblast_count,
budget_sec, budget_sec,
request_delay_sec, request_delay_sec,
max_consecutive_blocks, max_consecutive_blocks,
@ -239,6 +308,7 @@ async def run_avito_detail_backfill(
) )
consecutive_blocks = 0 consecutive_blocks = 0
aborted_by_blocks = False
do_sleep = False do_sleep = False
items_since_warm = 0 items_since_warm = 0
@ -376,6 +446,21 @@ async def run_avito_detail_backfill(
text("UPDATE listings SET is_active = FALSE WHERE id = :id"), text("UPDATE listings SET is_active = FALSE WHERE id = :id"),
{"id": row["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: except Exception:
logger.warning( logger.warning(
"avito_detail_backfill: run_id=%d failed to mark listing %s " "avito_detail_backfill: run_id=%d failed to mark listing %s "
@ -416,6 +501,7 @@ async def run_avito_detail_backfill(
counters.enriched, counters.enriched,
counters.attempted, counters.attempted,
) )
aborted_by_blocks = True
break break
# МГТС sticky-IP: один фикс. exit-IP, per-connection ротации нет (проверено: # МГТС sticky-IP: один фикс. exit-IP, per-connection ротации нет (проверено:
# 6/6 свежих сессий = тот же IP 109.252.125.80; ротация только вручную # 6/6 свежих сессий = тот же IP 109.252.125.80; ротация только вручную
@ -488,9 +574,15 @@ async def run_avito_detail_backfill(
counters.duration_sec = time.monotonic() - start counters.duration_sec = time.monotonic() - start
current_counters = counters.to_dict() 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,
)
logger.info( 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", "blocked=%d gone=%d failed=%d duration=%.1fs",
run_id, run_id,
counters.attempted, counters.attempted,

View file

@ -10,6 +10,26 @@
Парсинг адреса _parse_street_house из app.services.geocoder (готовый парсер), Парсинг адреса _parse_street_house из app.services.geocoder (готовый парсер),
работающий с формами «г. Екатеринбург, ул. Малышева, 30, кв. 28». работающий с формами «г. Екатеринбург, ул. Малышева, 30, кв. 28».
Городской гейт (#2583, находка H3; расширен #2594 шаг 2/3): `ekb_geoportal_buildings` —
EKB-only реестр: улица+дом могут буквально совпасть между Екатеринбургом и другим городом
области (например, «проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле). Без проверки
города такой листинг получает екатеринбургские координаты, хотя находится в другом городе.
Гейт ДВЕ проверки перед вызовом _geoportal_house_match:
1. Колонка `listings.city` (#2594, миграция 196) — если проставлена НЕ-Екатеринбургом,
листинг пропускается сразу, без обращения к тексту адреса. Это надёжный сигнал из
контекста развёртки (скрапер знает город явно), тогда как текстовый гейт полагается
на то, что город явно упомянут в самом тексте адреса.
2. _names_non_ekb_city(address) (та же функция, что гейтит EKB-only тиры внутри
geocoder.geocode()) СОХРАНЕНА как fallback для листингов, у которых city IS NULL
(записаны до миграции 196 или путём, ещё не проставляющим город, например admin
ad-hoc /admin/scrape) там единственный сигнал о городе текст адреса.
Оба пути пропуска считаются в skipped_non_ekb (адрес остаётся lat IS NULL для
geocode_missing_listings, oblast-aware Nominatim/Yandex, окно 06:00-09:00 UTC).
Прямой вызов _geoportal_house_match (а не полноценный geocode()) оставлен намеренно
это pure local-DB матч без единого внешнего HTTP-запроса; полноценный geocode() на каждый
non-EKB адрес добавил бы Nominatim/Yandex вызов на весь backlog (сотни-тысячи строк за
ночь) лишняя нагрузка на и так ограниченный Nominatim (Yandex сейчас 403, #2585).
Запуск: Запуск:
python -m app.tasks.backfill_listings_coords_geoportal python -m app.tasks.backfill_listings_coords_geoportal
python -m app.tasks.backfill_listings_coords_geoportal --limit 5000 --batch-size 200 python -m app.tasks.backfill_listings_coords_geoportal --limit 5000 --batch-size 200
@ -19,7 +39,17 @@ migration 171) — run_geoportal_coords_backfill(). Local exact match, ника
HTTP/rate-limit, поэтому окно ставится ПЕРЕД geocode_missing_listings (Nominatim/Yandex, HTTP/rate-limit, поэтому окно ставится ПЕРЕД geocode_missing_listings (Nominatim/Yandex,
coarse city-centroid fallback): точный house-level матч должен получить шанс первым, coarse city-centroid fallback): точный house-level матч должен получить шанс первым,
иначе Nominatim успевает проставить грубые coords и адрес выпадает из WHERE lat IS NULL иначе Nominatim успевает проставить грубые coords и адрес выпадает из WHERE lat IS NULL
(#1967 — было единичным manual-прогоном #1841, здесь становится recurring). (#1967 — было единичным manual-прогоном #1841, здесь становится recurring). С городским
гейтом (#2583) порядок окон остаётся корректным: не-ЕКБ адреса больше не матчатся здесь
вообще, поэтому «победа в гонке» больше не портит их координаты они просто ждут
geocode_missing_listings в следующем окне, как и раньше для адресов без geoportal-матча.
geo_precision: этот тир всегда даёт house-level точный матч (не city-centroid), поэтому
_update_listing_coords НЕ проставляет geo_precision он остаётся NULL, что в текущей
конвенции (089_listings_geo_precision.sql, geocode_missing.py) означает «не coarse»
(тот же смысл, что и geo_precision=None для precise-адресов в geocode_missing_listings).
Downstream-фильтры (`geo_precision IS DISTINCT FROM 'city'`) корректно НЕ исключают такие
строки исключать нужно только 'city'-fallback, а не «пока не размечено».
Идемпотентность: UPDATE применяется только к строкам с lat IS NULL (WHERE id=:id AND lat IS NULL). Идемпотентность: UPDATE применяется только к строкам с lat IS NULL (WHERE id=:id AND lat IS NULL).
Повторный прогон не затирает уже проставленные координаты. Повторный прогон не затирает уже проставленные координаты.
@ -37,7 +67,7 @@ from sqlalchemy.orm import Session
from app.core.db import SessionLocal from app.core.db import SessionLocal
from app.services import scrape_runs as runs_mod from app.services import scrape_runs as runs_mod
from app.services.geocoder import _geoportal_house_match, _parse_street_house from app.services.geocoder import _geoportal_house_match, _names_non_ekb_city, _parse_street_house
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -55,6 +85,17 @@ class BackfillCoordsResult:
updated: int = 0 # реально обновлено (UPDATE rowcount) updated: int = 0 # реально обновлено (UPDATE rowcount)
no_address: int = 0 # listing.address IS NULL / не распарсился no_address: int = 0 # listing.address IS NULL / не распарсился
no_match: int = 0 # адрес распарсился, но в реестре здания нет no_match: int = 0 # адрес распарсился, но в реестре здания нет
skipped_non_ekb: int = 0 # non-ЕКБ гейт ВСЕГО: колонка city (#2594) ИЛИ текст (#2583)
# Подмножество skipped_non_ekb — только те, кого отсёк гейт по КОЛОНКЕ city
# (#2603). Зачем отдельный счётчик: колоночный гейт стоит ПЕРЕД парсером
# адреса, поэтому по мере раскатки областных развёрток (#2598) строки, которые
# сейчас падают в no_address (город неизвестен, адрес не парсится), начнут
# перетекать в skipped_non_ekb — и общий счётчик поменяет смысл ровно тогда,
# когда по нему хотят валидировать раскатку. Разность
# skipped_non_ekb - skipped_non_ekb_by_column = вклад ТЕКСТОВОГО гейта, т.е.
# старая метрика #2583 остаётся вычислимой. Обратная совместимость:
# skipped_non_ekb продолжает означать то же, что и раньше (гейт целиком).
skipped_non_ekb_by_column: int = 0
errors: int = 0 # исключения при обработке отдельной записи errors: int = 0 # исключения при обработке отдельной записи
duration_sec: float = field(default=0.0) duration_sec: float = field(default=0.0)
@ -65,6 +106,8 @@ class BackfillCoordsResult:
"updated": self.updated, "updated": self.updated,
"no_address": self.no_address, "no_address": self.no_address,
"no_match": self.no_match, "no_match": self.no_match,
"skipped_non_ekb": self.skipped_non_ekb,
"skipped_non_ekb_by_column": self.skipped_non_ekb_by_column,
"errors": self.errors, "errors": self.errors,
"duration_sec": int(self.duration_sec), "duration_sec": int(self.duration_sec),
} }
@ -153,7 +196,7 @@ def backfill_coords_from_geoportal(
rows = ( rows = (
db.execute( db.execute(
text(f""" text(f"""
SELECT id, address SELECT id, address, city
FROM listings FROM listings
WHERE lat IS NULL WHERE lat IS NULL
AND geom IS NULL AND geom IS NULL
@ -184,6 +227,32 @@ def backfill_coords_from_geoportal(
res.no_address += 1 res.no_address += 1
continue continue
# Городской гейт по колонке (#2594 шаг 2/3) — ПЕРЕД матчем и ПЕРЕД
# текстовым гейтом. listings.city (миграция 196) проставляется из
# контекста развёртки скрапером — надёжнее текста адреса. Голый
# тагильский адрес без города в тексте ("ул. Победы, 30") раньше
# проходил только текстовый гейт и мог ложно сматчиться с
# одноимённым екатеринбургским домом в EKB-only реестре. Это окно
# идёт ПЕРЕД geocode_missing_listings — без гейта по колонке оно
# успевает испортить координаты первым.
city: str | None = row.get("city")
if city is not None and city != "Екатеринбург":
res.skipped_non_ekb += 1
# Отдельный срез (#2603) — общий skipped_non_ekb смешивает
# колоночный и текстовый гейты, а по мере раскатки #2598
# колоночный будет забирать строки из no_address.
res.skipped_non_ekb_by_column += 1
continue
# Текстовый гейт (#2583, H3) — fallback для листингов, у которых
# колонка city пуста (записаны до миграции 196 либо путём, ещё не
# проставляющим город, напр. admin ad-hoc /admin/scrape). Адрес,
# явно называющий другой город региона, пропускаем — остаётся
# lat IS NULL для oblast-aware geocode_missing_listings.
if _names_non_ekb_city(address):
res.skipped_non_ekb += 1
continue
# Парсинг адреса — переиспользуем парсер geocoder'а # Парсинг адреса — переиспользуем парсер geocoder'а
parsed = _parse_street_house(address) parsed = _parse_street_house(address)
if parsed is None: if parsed is None:
@ -259,12 +328,15 @@ def backfill_coords_from_geoportal(
logger.info( logger.info(
"backfill_coords: DONE — candidates=%d matched=%d updated=%d " "backfill_coords: DONE — candidates=%d matched=%d updated=%d "
"no_address=%d no_match=%d errors=%d duration=%.1fs", "no_address=%d no_match=%d skipped_non_ekb=%d (by_column=%d) "
"errors=%d duration=%.1fs",
res.candidates, res.candidates,
res.matched, res.matched,
res.updated, res.updated,
res.no_address, res.no_address,
res.no_match, res.no_match,
res.skipped_non_ekb,
res.skipped_non_ekb_by_column,
res.errors, res.errors,
res.duration_sec, res.duration_sec,
) )
@ -314,13 +386,16 @@ def run_geoportal_coords_backfill(
runs_mod.mark_done(db, run_id, counters) runs_mod.mark_done(db, run_id, counters)
logger.info( logger.info(
"run_geoportal_coords_backfill: run_id=%d DONE candidates=%d matched=%d " "run_geoportal_coords_backfill: run_id=%d DONE candidates=%d matched=%d "
"updated=%d no_address=%d no_match=%d errors=%d duration=%.1fs", "updated=%d no_address=%d no_match=%d skipped_non_ekb=%d (by_column=%d) "
"errors=%d duration=%.1fs",
run_id, run_id,
res.candidates, res.candidates,
res.matched, res.matched,
res.updated, res.updated,
res.no_address, res.no_address,
res.no_match, res.no_match,
res.skipped_non_ekb,
res.skipped_non_ekb_by_column,
res.errors, res.errors,
res.duration_sec, res.duration_sec,
) )
@ -376,12 +451,13 @@ def main() -> None:
logger.info( logger.info(
"Готово: кандидатов=%d сматчено=%d обновлено=%d " "Готово: кандидатов=%d сматчено=%d обновлено=%d "
"без_адреса=%d без_матча=%d ошибок=%d время=%.1fs", "без_адреса=%d без_матча=%d не_ЕКБ=%d ошибок=%d время=%.1fs",
result.candidates, result.candidates,
result.matched, result.matched,
result.updated, result.updated,
result.no_address, result.no_address,
result.no_match, result.no_match,
result.skipped_non_ekb,
result.errors, result.errors,
result.duration_sec, result.duration_sec,
) )

View file

@ -15,9 +15,18 @@ APPROXIMATION (deliberate first increment):
This is a GEO-NEAREST match a street-level-geocoded listing is matched to the nearest This is a GEO-NEAREST match a street-level-geocoded listing is matched to the nearest
cadastral building within `threshold_m`, NOT necessarily its exact cadastral building. cadastral building within `threshold_m`, NOT necessarily its exact cadastral building.
The threshold is always logged. Exact cadastral resolution + parcel-containment are The threshold is always logged. Exact cadastral resolution + parcel-containment are
deferred (cad_parcels FDW not exposed). Tier-0 house matching in the estimator already deferred (cad_parcels FDW not exposed).
treats building_cadastral_number as a hint, not ground truth, so an approximate fill is
a net win over 0% coverage. ЭТО HINT, И ТОЛЬКО HINT (#2674 — правка прежнего утверждения в этой шапке).
Раньше здесь было написано, что Tier-0 матчинга домов «уже трактует
building_cadastral_number как подсказку»; это неверно Tier 0 в
`app/services/matching/houses.py` отдаёт confidence 1.0, т.е. точное совпадение.
Замер на проде 2026-08-05: 656 из 3 260 заполненных здесь значений накрывают более
одного здания ГАР (20.1%), а 751 из 2 864 зданий ГАР получают более одного значения
(26.2%) ключ не инъективен ни в одну сторону. Поэтому подавать эту колонку в Tier 0
(ни на пере-скрейпе, ни бэкфиллом в houses.cadastral_number) НЕЛЬЗЯ: это склеит разные
здания с максимальной уверенностью. Колонка годится как признак/подсказка, не как
идентичность здания.
Pipeline (one combined run, scheduler source='cadastral_geo_match'): Pipeline (one combined run, scheduler source='cadastral_geo_match'):
1. refresh_cad_buildings_local(db) TRUNCATE + bulk INSERT from FDW (one scan). 1. refresh_cad_buildings_local(db) TRUNCATE + bulk INSERT from FDW (one scan).

View file

@ -18,6 +18,14 @@
Requires migration 071_houses_cian_zhk_url.sql (cian_zhk_url column). Requires migration 071_houses_cian_zhk_url.sql (cian_zhk_url column).
Rate limit: scraper_settings.get_scraper_delay('cian') between requests. Rate limit: scraper_settings.get_scraper_delay('cian') between requests.
Сигнал живости (#2725): батч дёргает `on_progress` на КАЖДОЙ сущности — caller
переливает это в scrape_runs.heartbeat_at. Пока колбэка не было, планировщик слал
heartbeat один раз ДО батча, а `reap_zombies` меряет ровно heartbeat с порогом 6 ч
и добивал живые прогоны строго на 6-м часу (6 прод-прогонов, у пятерых внутри окна
писались строки offer_price_history, у одного до 5.4 ч после старта). Ослаблять
критерий нельзя: пометка 'zombie' снимает running-блокировку источника
(`has_running_run`), без неё зависший прогон запер бы источник навсегда.
""" """
from __future__ import annotations from __future__ import annotations
@ -25,6 +33,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import time import time
from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from scraper_kit.browser_fetcher import BrowserFetcher from scraper_kit.browser_fetcher import BrowserFetcher
@ -68,6 +77,7 @@ async def backfill_cian_history(
do_houses: bool = True, do_houses: bool = True,
do_valuations: bool = False, do_valuations: bool = False,
dry_run: bool = False, dry_run: bool = False,
on_progress: Callable[[CianBackfillResult], None] | None = None,
) -> CianBackfillResult: ) -> CianBackfillResult:
"""Iterate Cian listings + houses with missing history, fetch+save. """Iterate Cian listings + houses with missing history, fetch+save.
@ -82,6 +92,10 @@ async def backfill_cian_history(
do_valuations: process Cian Valuation Calculator batch (external_valuations backfill). do_valuations: process Cian Valuation Calculator batch (external_valuations backfill).
Default False opt-in because each call hits Cian auth-gated API. Default False opt-in because each call hits Cian auth-gated API.
dry_run: skip all fetch+save; only count and log pending rows. dry_run: skip all fetch+save; only count and log pending rows.
on_progress: колбэк живости (#2725) — вызывается на каждой сущности ЛЮБОГО из
трёх блоков, до её обработки, с текущим (мутируемым) result. Caller пишет
heartbeat; исключения колбэка на его совести (планировщик глушит их сам),
здесь они прервали бы батч.
Returns: Returns:
CianBackfillResult with per-domain counters + total wall-clock duration. CianBackfillResult with per-domain counters + total wall-clock duration.
@ -120,6 +134,8 @@ async def backfill_cian_history(
listing_id: int = row["id"] listing_id: int = row["id"]
source_url: str = row["source_url"] source_url: str = row["source_url"]
result.listings_processed += 1 result.listings_processed += 1
if on_progress is not None:
on_progress(result)
enrichment = None enrichment = None
try: try:
@ -209,6 +225,8 @@ async def backfill_cian_history(
house_id: int = hrow["id"] house_id: int = hrow["id"]
zhk_url: str = hrow["cian_zhk_url"] zhk_url: str = hrow["cian_zhk_url"]
result.houses_processed += 1 result.houses_processed += 1
if on_progress is not None:
on_progress(result)
enrichment = None enrichment = None
try: try:
@ -291,6 +309,8 @@ async def backfill_cian_history(
else: else:
for row in rows: for row in rows:
result.valuations_processed += 1 result.valuations_processed += 1
if on_progress is not None:
on_progress(result)
try: try:
cval = await estimate_via_cian_valuation( cval = await estimate_via_cian_valuation(
db, db,

View file

@ -9,6 +9,9 @@
TTL=30. novostroyki (9659 активных первичных строк) и NULL-сегмент не трогаем. TTL=30. novostroyki (9659 активных первичных строк) и NULL-сегмент не трогаем.
- avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений. - avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений.
- Строки НЕ удаляются -- история нужна для бэктеста (#667). - Строки НЕ удаляются -- история нужна для бэктеста (#667).
- #2674: деактивация в той же транзакции пишет снимок listings_snapshots со статусом
'stale' за текущую дату -- «мы N суток не видели». Жёсткое 'closed' (площадка
ответила 404) пишет только avito_detail_backfill: смешивать факт с догадкой дорого.
Задача синхронная (DB-only, никаких внешних HTTP-вызовов) -- запускается kit-scheduler'ом Задача синхронная (DB-only, никаких внешних HTTP-вызовов) -- запускается kit-scheduler'ом
через product_handlers._job_deactivate_stale (wildcard-handler deactivate_stale_*), через product_handlers._job_deactivate_stale (wildcard-handler deactivate_stale_*),
@ -41,21 +44,126 @@ logger = logging.getLogger(__name__)
# только реальный скрейп). # только реальный скрейп).
_ALLOWED_STALENESS_COLUMNS = frozenset({"last_seen_at", "scraped_at"}) _ALLOWED_STALENESS_COLUMNS = frozenset({"last_seen_at", "scraped_at"})
# ── Снимок «протухло» в дневной истории (#2674) ───────────────────────────────
# listings_snapshots.status до этого фикса был константой 'active' у всех строк
# (394 299 на момент находки) — оба места вызова upsert_listing_snapshot передавали
# литерал 'active', и это честно: там объявление ДЕЙСТВИТЕЛЬНО видели. А деактивация
# TTL-задачей не оставляла в истории вообще никакого следа. Из-за этого дата снятия
# объявления (лучший доступный сигнал «скорее всего продано») не запрашивалась из
# истории, а восстанавливалась на глаз: последний показ + предполагаемый срок жизни.
#
# ПОЧЕМУ 'stale', А НЕ 'closed'. Эта задача НЕ знает, что объявление снято, — она
# знает только, что МЫ его N суток не видели, а это разные факты, когда TTL короче
# простоя обхода. Замер: прогон по домклику 02.08 снял 6131 объявление за раз (TTL
# 14 суток против 12 суток простоя обхода) — с общим статусом это были бы 6131
# фальшивая «дата продажи» одной датой. Продукт про цены, смешивать факт с догадкой
# дорого. Поэтому:
# 'closed' — только путь 404: площадка ответила «нет» (avito_detail_backfill);
# 'stale' — этот путь: «мы N суток не смотрели».
# Дата всё равно фиксируется, но читатель отличает одно от другого. Ограничения
# CHECK на колонке нет (проверено на проде), миграция не нужна — только COMMENT.
#
# Пишем снимок в ТОЙ ЖЕ транзакции, что и UPDATE флага: деактивация без снимка (или
# наоборот) невозможна по построению — один statement, data-modifying CTE.
# 1:1 по строкам: `stale` возвращает уникальные listings.id (PK), каждая даёт ровно
# одну затронутую строку listings_snapshots (INSERT либо DO UPDATE — оба считаются
# в rowcount), поэтому rowcount statement'а по-прежнему равен числу деактивированных.
# price_rub берём из listings (NOT NULL в схеме) — это последняя известная цена.
# ON CONFLICT: если снимок за сегодня уже есть (объявление видели активным утром,
# а вечером сработал TTL) — только переводим статус в 'stale', цену не переписываем.
_STALE_SNAPSHOT_TAIL = """
INSERT INTO listings_snapshots
(listing_id, snapshot_date, run_id, price_rub, status, observed_at)
SELECT id, CURRENT_DATE, CAST(:run_id AS bigint), price_rub, 'stale', NOW()
FROM stale
ON CONFLICT (listing_id, snapshot_date) DO UPDATE SET
status = 'stale',
observed_at = EXCLUDED.observed_at
"""
# ── Гейт по здоровью сбора (#2659) ────────────────────────────────────────────
# TTL отвечает на вопрос «объявление сняли?», а меряет «мы его давно не видели».
# Пока обход здоров, разница мала. Когда обход лёг — разница равна всему инвентарю.
#
# Замер на проде, из-за которого этот гейт существует. Авито 10.07-26.07.2026:
# 17 суток подряд без единой успешно собранной страницы, TTL=10 снял за этот отрезок
# 9 033 строки; 1 270 из них потом доказанно вернулись живыми (снимки
# listing_source_snapshots + текущий last_seen_at) — сбор восстановился, и объявления
# оказались на месте. То есть «мы не смогли зайти» было прочитано как «объявление снято».
#
# ПОЧЕМУ НЕ ПО СТАТУСУ ban. Соблазн взять scrape_runs.status='banned' — ловушка:
# Яндекс 18.07-30.07 — 5 прогонов в сутки, ВСЕ 'done', НОЛЬ 'banned', total_seen=0
# 13 суток подряд (снято ~839 строк vtorichka);
# Домклик 20.07-30.07 — то же самое, 11 суток 'done' с total_seen=0, а 02.08 TTL
# снял 6 131 строку разом (см. комментарий про 'stale' выше).
# Оба провала для ban-детектора невидимы. Поэтому здоровье меряем НЕ статусом прогона,
# а результатом: сколько строк источник реально подтвердил свежими за последние сутки.
#
# МЕТРИКА: count(*) по той же колонке свежести, что и сам TTL (last_seen_at или
# scraped_at) и по тому же срезу source+segment, что и UPDATE. Одна колонка на обе
# стороны — гейт нельзя обмануть bulk-touch'ем, который не двигает scraped_at (#2204).
#
# ПОРОГ. Ряд «подтверждений за 3 суток» по дням (восстановлен из listing_source_snapshots):
# avito здоровые сутки 3542..6079, провал 10.07-26.07 — 0..970 → порог 1500;
# yandex vtorichka здоровые 897..2206, провал — 0 → порог 500;
# cian vtorichka 748..4329, провала не было → порог 500;
# domklik по scraped_at сейчас 62/3 суток (сбор фактически стоит) → порог 200.
# Пороги живут в default_params расписания (миграция 219), здесь только страховка
# на случай незасеянного расписания. Асимметрия цены ошибки намеренная: пропущенная
# деактивация чинится следующим прогоном, ложная — только повторным сбором, которого
# может не быть. Поэтому при сомнении — пропускаем прогон.
#
# ПОТОЛОК: окно 3 суток годится, пока свипы источника ходят не реже чем раз в 3 дня.
# Источник с более редкой каденцией будет блокироваться всегда — тогда окно нужно
# растить до каденции, а не понижать порог.
_HEALTH_WINDOW_DAYS = 3
# Страховка для расписаний без явного min_confirmations в default_params: ловит
# полный ноль и близкое к нулю, но НЕ ловит частичный провал вроде avito 936-970 —
# для этого нужен посчитанный по источнику порог из миграции 219.
DEFAULT_MIN_CONFIRMATIONS = 500
_CONFIRMATIONS_SEGMENT_FILTER = "\n AND listing_segment = ANY(CAST(:segments AS text[]))"
def _build_confirmations_sql(staleness_column: str, *, with_segments: bool) -> Any:
"""SELECT count(*) подтверждённых за окно строк — тот же срез, что и у UPDATE.
staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings.
Значения (:listing_source, :health_window_days, :segments) param-binding,
psycopg v3 safe (CAST(... AS ...), никаких :param::type).
"""
segment_filter = _CONFIRMATIONS_SEGMENT_FILTER if with_segments else ""
return text(
f"""
SELECT count(*)
FROM listings
WHERE source = :listing_source
AND {staleness_column}
> NOW() - CAST(:health_window_days || ' days' AS interval){segment_filter}
"""
)
def _build_all_segments_sql(staleness_column: str) -> Any: def _build_all_segments_sql(staleness_column: str) -> Any:
"""UPDATE без фильтра по сегменту: все сегменты для данного source. """UPDATE без фильтра по сегменту: все сегменты для данного source.
staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings, staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings,
поэтому f-string-подстановка имени колонки безопасна. Значения (:listing_source, поэтому f-string-подстановка имени колонки безопасна. Значения (:listing_source,
:ttl_days) остаются param-binding psycopg v3 safe (никаких :param::type). :ttl_days, :run_id) остаются param-binding psycopg v3 safe (никаких :param::type).
""" """
return text( return text(
f""" f"""
WITH stale AS (
UPDATE listings UPDATE listings
SET is_active = false SET is_active = false
WHERE source = :listing_source WHERE source = :listing_source
AND is_active = true AND is_active = true
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
RETURNING id, price_rub
)
{_STALE_SNAPSHOT_TAIL}
""" """
) )
@ -68,12 +176,16 @@ def _build_segments_sql(staleness_column: str) -> Any:
""" """
return text( return text(
f""" f"""
WITH stale AS (
UPDATE listings UPDATE listings
SET is_active = false SET is_active = false
WHERE source = :listing_source WHERE source = :listing_source
AND is_active = true AND is_active = true
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
AND listing_segment = ANY(CAST(:segments AS text[])) AND listing_segment = ANY(CAST(:segments AS text[]))
RETURNING id, price_rub
)
{_STALE_SNAPSHOT_TAIL}
""" """
) )
@ -95,6 +207,8 @@ def deactivate_stale_listings(
ttl_days: int, ttl_days: int,
segments: list[str] | None = None, segments: list[str] | None = None,
staleness_column: str = "last_seen_at", staleness_column: str = "last_seen_at",
min_confirmations: int = 0,
health_window_days: int = _HEALTH_WINDOW_DAYS,
) -> dict[str, int]: ) -> dict[str, int]:
"""Пометить is_active=false объявления, чья свежесть старше ttl_days дней. """Пометить is_active=false объявления, чья свежесть старше ttl_days дней.
@ -109,11 +223,20 @@ def deactivate_stale_listings(
last_seen_at. Для domklik (#2204) — scraped_at: нетрекаемый bulk-touch last_seen_at. Для domklik (#2204) — scraped_at: нетрекаемый bulk-touch
двигает last_seen_at всем строкам одним timestamp, поэтому честная двигает last_seen_at всем строкам одним timestamp, поэтому честная
свежесть = scraped_at (двигается только реальным скрейпом). свежесть = scraped_at (двигается только реальным скрейпом).
min_confirmations: гейт по здоровью сбора (#2659). Сколько строк источник
должен был подтвердить свежими за health_window_days суток, чтобы
деактивации вообще разрешалось исполниться. 0 -> гейт выключен (так
вызывают старые тесты и совместимая обёртка); реальные значения приходят
из default_params расписания, см. миграцию 219 и комментарий выше.
health_window_days: окно подтверждений для гейта, суток. Дефолт 3.
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources). Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
Один UPDATE в транзакции. Финализирует scrape_runs (mark_done / mark_failed). Один statement в транзакции: UPDATE флага + снимок 'stale' в listings_snapshots
(data-modifying CTE, #2674). Финализирует scrape_runs (mark_done / mark_failed).
Returns {"deactivated": N} -- количество обновлённых строк. Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками).
Если гейт не пропустил прогон: {"deactivated": 0, "confirmations": N,
"skipped_unhealthy": 1} и НИ ОДНА строка не тронута.
Raises: Raises:
ValueError: если staleness_column не входит в whitelist (проверка ДО SQL, ValueError: если staleness_column не входит в whitelist (проверка ДО SQL,
@ -131,6 +254,44 @@ def deactivate_stale_listings(
f"allowed: {sorted(_ALLOWED_STALENESS_COLUMNS)}" f"allowed: {sorted(_ALLOWED_STALENESS_COLUMNS)}"
) )
# Гейт по здоровью сбора (#2659) — ДО любого UPDATE. Деактивация необратима
# на практике (вернуть «живость» может только повторный сбор), поэтому
# проверяем ПЕРЕД записью, а не откатываем после.
if min_confirmations > 0:
health_params: dict[str, Any] = {
"listing_source": listing_source,
"health_window_days": health_window_days,
}
if segments is not None:
health_params["segments"] = segments
confirmations = (
db.execute(
_build_confirmations_sql(staleness_column, with_segments=segments is not None),
health_params,
).scalar()
or 0
)
counters["confirmations"] = int(confirmations)
if confirmations < min_confirmations:
counters["skipped_unhealthy"] = 1
# Ничего не писали (был только SELECT) — rollback закрывает транзакцию
# чисто, чтобы mark_done стартовал со своей.
db.rollback()
runs_mod.mark_done(db, run_id, counters)
logger.warning(
"deactivate_stale source=%s run_id=%d SKIPPED: сбор нездоров — "
"подтверждений за %d сут %d < порога %d "
"(segments=%r, staleness_column=%s); ни одна строка не тронута",
listing_source,
run_id,
health_window_days,
confirmations,
min_confirmations,
segments,
staleness_column,
)
return counters
# segments is None -> все сегменты (поведение avito). segments=[...] -> только # segments is None -> все сегменты (поведение avito). segments=[...] -> только
# перечисленные сегменты. Используем `is not None` (НЕ truthy): пустой список [] # перечисленные сегменты. Используем `is not None` (НЕ truthy): пустой список []
# означает "ни один сегмент" (= ANY(ARRAY[]) ничего не матчит, деактивирует 0), # означает "ни один сегмент" (= ANY(ARRAY[]) ничего не матчит, деактивирует 0),
@ -140,10 +301,15 @@ def deactivate_stale_listings(
"listing_source": listing_source, "listing_source": listing_source,
"ttl_days": ttl_days, "ttl_days": ttl_days,
"segments": segments, "segments": segments,
"run_id": run_id,
} }
result = db.execute(_build_segments_sql(staleness_column), params) result = db.execute(_build_segments_sql(staleness_column), params)
else: else:
params = {"listing_source": listing_source, "ttl_days": ttl_days} params = {
"listing_source": listing_source,
"ttl_days": ttl_days,
"run_id": run_id,
}
result = db.execute(_build_all_segments_sql(staleness_column), params) result = db.execute(_build_all_segments_sql(staleness_column), params)
counters["deactivated"] = result.rowcount or 0 counters["deactivated"] = result.rowcount or 0

View file

@ -0,0 +1,161 @@
"""Daily recompute of per-city ppm² plausible-deal guard-bands (#2576 Stage B).
ПРОБЛЕМА: deal_city_price_bands (migration 178, tier-схема migration 194)
засеяна ON CONFLICT DO UPDATE derivation-запросом. По мере ночного импорта новых
ДКП-сделок (rosreestr_dkp_import) города переходят между tier ('region_fallback'
N<10 'rough' N 10-29 'full' N>=30), а перцентили внутри tier дрейфуют нужен
периодический пересчёт по той же derivation.
Задача синхронная (DB-only, никаких внешних HTTP-вызовов) запускается
kit-scheduler'ом через product_handlers._job_deal_city_price_bands_refresh
(run_in_executor), по образцу asking_to_sold_ratio.py / snapshot_listing_sources.
Окно расписания 07:00-08:00 UTC ПОСЛЕ rosreestr_dkp_import (04:00-06:00 UTC) И
asking_to_sold_ratio_refresh (06:00-07:00 UTC), чтобы бэнды считались по тому же
свежему срезу deals, что и ratio-таблица того же дня.
SQL derivation ниже БАЙТ-В-БАЙТ та же логика, что seed в
data/sql/194_deal_city_price_bands_tiers.sql (region_stats / city_stats / tiered:
трёхуровневая схема full N>=30 / rough N 10-29 / region_fallback N 1-9, см.
комментарий в 194 для полного обоснования тиров и hard floor/ceiling клампов).
Нет DELETE перед re-derive (в отличие от asking_to_sold_ratio.py true-mirror
паттерна) множество городов монотонно растёт (rosreestr_dkp_import только
INSERT/ON CONFLICT DO UPDATE, никогда не удаляет сделки), поэтому merge-по-city
(ON CONFLICT DO UPDATE) достаточен: город, перешедший в другой tier, просто
перезаписывается на следующем refresh. Екатеринбург НЕ включён (WHERE city <>
'Екатеринбург') estimator.py fallback на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2
для ЕКБ остаётся byte-identical (invariant из 178/194 сохранён).
"""
from __future__ import annotations
import logging
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__)
# ── Derivation + re-seed (БАЙТ-В-БАЙТ из 194) ─────────────────────────────────
_REDERIVE_SQL = text(
"""
WITH region_stats AS (
SELECT GREATEST(
round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int,
8000
) AS region_ppm2_min
FROM deals
WHERE source = 'rosreestr'
AND price_per_m2 IS NOT NULL
AND city IS NOT NULL
AND city <> 'Екатеринбург'
),
city_stats AS (
SELECT
city,
GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000)
AS ppm2_p1,
LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000)
AS ppm2_p99,
count(*) AS n_deals
FROM deals
WHERE source = 'rosreestr'
AND price_per_m2 IS NOT NULL
AND city IS NOT NULL
AND city <> 'Екатеринбург'
GROUP BY city
),
tiered AS (
SELECT city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals,
'full'::text AS tier
FROM city_stats
WHERE n_deals >= 30
AND ppm2_p99 >= 8000
UNION ALL
SELECT city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max, n_deals,
'rough'::text AS tier
FROM city_stats
WHERE n_deals BETWEEN 10 AND 29
UNION ALL
SELECT c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max, c.n_deals,
'region_fallback'::text AS tier
FROM city_stats c
CROSS JOIN region_stats r
WHERE c.n_deals < 10
)
INSERT INTO deal_city_price_bands (city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at)
SELECT city, ppm2_min, ppm2_max, n_deals, tier, now()
FROM tiered
ON CONFLICT (city) DO UPDATE
SET ppm2_min = EXCLUDED.ppm2_min,
ppm2_max = EXCLUDED.ppm2_max,
n_deals = EXCLUDED.n_deals,
tier = EXCLUDED.tier,
refreshed_at = EXCLUDED.refreshed_at
"""
)
# ── Post-insert counters ──────────────────────────────────────────────────────
_COUNTERS_SQL = text(
"""
SELECT
COUNT(*) AS rows_written,
COUNT(*) FILTER (WHERE tier = 'full') AS full_rows,
COUNT(*) FILTER (WHERE tier = 'rough') AS rough_rows,
COUNT(*) FILTER (WHERE tier = 'region_fallback') AS region_fallback_rows
FROM deal_city_price_bands
"""
)
def refresh_deal_city_price_bands(db: Session, run_id: int) -> dict[str, int]:
"""Пересчитать deal_city_price_bands (#2576 Stage B — tier-aware refresh).
Sync (вызывается scheduler-триггером в executor, как recompute_asking_to_sold_ratios).
Одна транзакция: re-derive INSERT ... ON CONFLICT DO UPDATE (нет DELETE см.
module docstring), затем counters из таблицы, commit, mark_done.
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
Returns {"rows_written": N, "full_rows": .., "rough_rows": .., "region_fallback_rows": ..}.
"""
counters: dict[str, int] = {
"rows_written": 0,
"full_rows": 0,
"rough_rows": 0,
"region_fallback_rows": 0,
}
try:
db.execute(_REDERIVE_SQL)
row = db.execute(_COUNTERS_SQL).mappings().first()
if row is not None:
counters["rows_written"] = int(row["rows_written"] or 0)
counters["full_rows"] = int(row["full_rows"] or 0)
counters["rough_rows"] = int(row["rough_rows"] or 0)
counters["region_fallback_rows"] = int(row["region_fallback_rows"] or 0)
db.commit()
runs_mod.mark_done(db, run_id, counters)
logger.info(
"refresh_deal_city_price_bands run_id=%d done: "
"rows_written=%d full=%d rough=%d region_fallback=%d",
run_id,
counters["rows_written"],
counters["full_rows"],
counters["rough_rows"],
counters["region_fallback_rows"],
)
return counters
except Exception as exc:
logger.exception("refresh_deal_city_price_bands run_id=%d failed", run_id)
db.rollback()
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise

View file

@ -142,7 +142,9 @@ def check_deals_freshness(
row = db.execute(_LATEST_DEAL_DATE_SQL).first() row = db.execute(_LATEST_DEAL_DATE_SQL).first()
latest: date | None = row.latest if row is not None else None latest: date | None = row.latest if row is not None else None
if latest is None: if latest is None:
logger.warning( # ERROR (#2674): монитор не может выполнить работу — сбой, а не наблюдение.
# Соседняя ветка (overdue) писала ERROR с самого начала; эта расходилась.
logger.error(
"deals freshness: таблица deals пуста/недоступна — оценить свежесть нельзя" "deals freshness: таблица deals пуста/недоступна — оценить свежесть нельзя"
) )
runs_mod.mark_failed(db, run_id, "deals empty or unavailable", counters) runs_mod.mark_failed(db, run_id, "deals empty or unavailable", counters)

View file

@ -36,10 +36,11 @@ one BrowserFetcher is constructed per run.
Exception triad differs from Avito: Exception triad differs from Avito:
- DomClickBlockedError (QRATOR challenge page OR any browser-fetch failure) -- - DomClickBlockedError (QRATOR challenge page OR any browser-fetch failure) --
increments consecutive_blocks, abort via mark_done (NOT mark_failed) once increments consecutive_blocks, abort once max_consecutive_blocks is hit.
max_consecutive_blocks is hit -- a block-abort is an expected operational Статус такого прогона 'banned' (#2674, см. runs.mark_backfill_finished):
outcome (QRATOR reputation burn), not a task failure. Mirrors Avito's блок это external constraint, не наш баг, но и НЕ успех раньше здесь стоял
AvitoBlockedError handling. No IP-rotation/cooldown recovery step exists here mark_done, и 24 из 30 прогонов с нулём обогащений назывались успешными.
No IP-rotation/cooldown recovery step exists here
(DomClick uses one dedicated residential proxy, not a rotating pool) -- an (DomClick uses one dedicated residential proxy, not a rotating pool) -- an
aborted run simply retries the remaining backlog next window. aborted run simply retries the remaining backlog next window.
- DomClickParseError (__SSR_STATE__ missing/malformed -- schema drift, NOT a - DomClickParseError (__SSR_STATE__ missing/malformed -- schema drift, NOT a
@ -52,10 +53,16 @@ Exception triad differs from Avito:
Cookie injection is mandatory wiring, not optional: cookies are loaded ONCE per run. Cookie injection is mandatory wiring, not optional: cookies are loaded ONCE per run.
If None (no valid session uploaded / expired) -- the run still proceeds (cookie- If None (no valid session uploaded / expired) -- the run still proceeds (cookie-
injection is a QRATOR-defeat mechanism, not a hard requirement; organic SERP-origin injection is a QRATOR-defeat mechanism, not a hard requirement; organic SERP-origin
navigation from PR #2430 still applies) but a warning is logged once at run start so navigation from PR #2430 still applies) but an ERROR is logged once at run start so
operators notice the test-account session needs refreshing via operators notice the test-account session needs refreshing via
`POST /scrape/domclick/upload-cookies` (no auto-login -- documented MVP limitation, `POST /scrape/domclick/upload-cookies` (no auto-login -- documented MVP limitation,
see app/services/domclick_session.py module docstring). see app/services/domclick_session.py module docstring).
#2674: раньше это был WARNING, который в скрапер-контейнере событием не становится
(LoggingIntegration event_level=ERROR) куки протухли 2026-08-03 и об этом никто не
узнал. Теперь два сигнала вместо одного: ERROR по факту (_alert_domclick_cookies) и
ERROR ЗАРАНЕЕ, пока куки ещё живы (_warn_before_domclick_cookies_expire) по образцу
#2658 для Циана, ручное обновление кук требует запаса времени.
""" """
from __future__ import annotations from __future__ import annotations
@ -65,6 +72,7 @@ import logging
import random import random
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from scraper_kit.browser_fetcher import BrowserFetcher from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
@ -85,6 +93,60 @@ __all__ = [
] ]
def _alert_domclick_cookies(db: Session, run_id: int) -> None:
"""Громкий сигнал «обогащение идёт без кук» — logger.error, не warning (#2674).
В контейнере скрапера GlitchTip поднят с LoggingIntegration(event_level=ERROR)
(scheduler_main.py), поэтому прежний WARNING событием не становился: куки протухли
на проде 2026-08-03, и единственным следом была строка в docker-логе, которая
теряется при редеплое. Прогон при этом НЕ прерываем cookie-инъекция это
механизм обхода QRATOR, а не жёсткое требование (см. докстринг модуля), но
состояние требует ручного действия человека, значит должно быть событием.
Причину различаем так же, как #2658 у Циана: «кук нет вовсе» и «протухли N дней
назад» лечатся одинаково, но диагностируются по-разному.
"""
expires_at = domclick_session_svc.session_expires_at(db)
now = datetime.now(tz=UTC)
if expires_at is None:
detail = "кук DomClick нет в БД"
elif expires_at <= now:
detail = (
f"куки DomClick протухли {expires_at:%Y-%m-%d} "
f"({(now - expires_at).days} дн. назад)"
)
else:
detail = "куки DomClick помечены невалидными (last_invalid_at)"
logger.error(
"domclick_detail_backfill: run_id=%d%s; обогащение идёт БЕЗ cookie-инъекции "
"(QRATOR-обход деградировал до organic SERP-origin навигации, PR #2430). "
"Перезалейте сессию test-аккаунта: POST /scrape/domclick/upload-cookies",
run_id,
detail,
)
def _warn_before_domclick_cookies_expire(db: Session, run_id: int) -> None:
"""Предупредить ЗАРАНЕЕ, пока куки ещё рабочие (#2674, образец — #2658 для Циана).
Сигнал по факту протухания приходит, когда обогащение уже встало; обновление кук
ручное, человеку нужен запас. valid_only=True срок ИМЕННО той записи, которую
взял load_session (при нескольких аккаунтах свежайшая-любая может быть чужой).
"""
expires_at = domclick_session_svc.session_expires_at(db, valid_only=True)
if expires_at is None:
return
left = expires_at - datetime.now(tz=UTC)
if left <= timedelta(days=domclick_session_svc.COOKIE_EXPIRY_WARN_DAYS):
logger.error(
"domclick_detail_backfill: run_id=%d — куки DomClick протухнут %s "
"(осталось %.1f дн.); обновите заранее, иначе обогащение деградирует молча",
run_id,
expires_at.date().isoformat(),
left.total_seconds() / 86400,
)
@dataclass @dataclass
class DomClickDetailBackfillResult: class DomClickDetailBackfillResult:
"""Counters for one backfill run.""" """Counters for one backfill run."""
@ -120,7 +182,8 @@ async def run_domclick_detail_backfill(
max_consecutive_blocks: int -- abort threshold, default 3. max_consecutive_blocks: int -- abort threshold, default 3.
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard -> 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", 200)) batch_size = int(params.get("batch_size", 200))
budget_sec = float(params.get("budget_sec", 3600)) budget_sec = float(params.get("budget_sec", 3600))
@ -135,16 +198,13 @@ async def run_domclick_detail_backfill(
try: try:
# Cookie injection (#2000 PR #2433) -- loaded ONCE per run, threaded into every # Cookie injection (#2000 PR #2433) -- loaded ONCE per run, threaded into every
# fetch_detail() call below. None is a valid (degraded) state, not an error. # fetch_detail() call below. Прогон продолжается и без кук (см. докстринг), но
# это состояние требует ЧЕЛОВЕКА: обновление сессии — ручная операция.
cookies = domclick_session_svc.load_session(db) cookies = domclick_session_svc.load_session(db)
if cookies is None: if cookies is None:
logger.warning( _alert_domclick_cookies(db, run_id)
"domclick_detail_backfill: run_id=%d -- no valid DomClick session cookies " else:
"in DB; proceeding WITHOUT cookie-injection (QRATOR-defeat degraded to " _warn_before_domclick_cookies_expire(db, run_id)
"organic SERP-origin navigation only, PR #2430). Refresh test-account "
"session via POST /scrape/domclick/upload-cookies.",
run_id,
)
runs_mod.update_heartbeat(db, run_id, current_counters) runs_mod.update_heartbeat(db, run_id, current_counters)
@ -193,6 +253,7 @@ async def run_domclick_detail_backfill(
) )
consecutive_blocks = 0 consecutive_blocks = 0
aborted_by_blocks = False
do_sleep = False do_sleep = False
# Exactly ONE BrowserFetcher per run (no curl fallback for DomClick, see # Exactly ONE BrowserFetcher per run (no curl fallback for DomClick, see
@ -275,6 +336,7 @@ async def run_domclick_detail_backfill(
counters.enriched, counters.enriched,
counters.attempted, counters.attempted,
) )
aborted_by_blocks = True
break break
except Exception as e: except Exception as e:
@ -296,9 +358,15 @@ async def run_domclick_detail_backfill(
counters.duration_sec = time.monotonic() - start counters.duration_sec = time.monotonic() - start
current_counters = counters.to_dict() 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="domclick_detail_backfill",
aborted_by_blocks=aborted_by_blocks,
)
logger.info( logger.info(
"domclick_detail_backfill: run_id=%d DONE -- attempted=%d enriched=%d " "domclick_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "
"blocked=%d failed=%d duration=%.1fs", "blocked=%d failed=%d duration=%.1fs",
run_id, run_id,
counters.attempted, counters.attempted,

View file

@ -5,15 +5,29 @@
- Scheduled: nightly via scrape_schedules (source='geocode_missing_listings', migration 110) - Scheduled: nightly via scrape_schedules (source='geocode_missing_listings', migration 110)
wired into in-app scheduler, window 06:00-09:00 UTC. wired into in-app scheduler, window 06:00-09:00 UTC.
Pattern: dedup по address (1 unique address 1 geocode call UPDATE all listings). Pattern: dedup по паре (address, city) 1 уникальная пара 1 geocode call UPDATE
Rate limit: Nominatim 1 req/sec. Yandex 25K/day если YANDEX_GEOCODER_API_KEY set. всех listings с этим address+city (#2594 шаг 2/3: listings.city теперь заполняется
скрапером из контекста развёртки один и тот же текст адреса в разных городах
(«ул. Победы, 30» в ЕКБ и в Нижнем Тагиле) должен получать РАЗНЫЕ координаты, а
не схлопываться в один geocode-вызов и один UPDATE по тексту адреса).
Rate limit: Nominatim 1 req/sec (#2593: Yandex Geocoder tier удалён из geocoder).
SELECT фильтрует `is_active` (#2604 п.1): на проде очередь была на 98.5% забита
мёртвыми объявлениями чужих регионов (Новосибирск/Казань/Челябинск/) без is_active
`ORDER BY listings_count DESC` ставил их В НАЧАЛО (у мусорного адреса вида
«Новосибирская обл.,Новосибирск» сотни listings, у реального адреса 1-2), поэтому
весь batch-бюджет (Nominatim 1 req/sec) съедался мусором и до настоящих адресов дело
не доходило (8 ночных прогонов подряд: saved=0). UPDATE после успешного/неуспешного
geocode НЕ фильтрует is_active см. комментарии у соответствующих UPDATE ниже.
Отличие от /admin/geocode-missing (per-ID): Отличие от /admin/geocode-missing (per-ID):
- Этот модуль группирует по address меньше API calls (dedup). - Этот модуль группирует по (address, city) меньше API calls (dedup), но не
схлопывает разные города с одинаковым текстом адреса.
- Поддерживает all sources включая Avito (после PR #487 убрали jitter). - Поддерживает all sources включая Avito (после PR #487 убрали jitter).
- Возвращает GeocodeBackfillResult с детальными counters. - Возвращает GeocodeBackfillResult с детальными counters.
- Loop-safe: SELECT фильтрует geocode_tried_at IS NULL OR tried_at < 7 days; - Loop-safe: SELECT фильтрует geocode_tried_at IS NULL OR tried_at < 7 days;
при geocode failure помечает tried_at=NOW() адрес не переотбирается в этом же run. при geocode failure помечает tried_at=NOW() пара (address, city) не
переотбирается в этом же run.
""" """
from __future__ import annotations from __future__ import annotations
@ -27,7 +41,7 @@ from sqlalchemy.orm import Session
from app.services import scrape_runs as runs_mod from app.services import scrape_runs as runs_mod
from app.services.estimator import _geocode_is_coarse from app.services.estimator import _geocode_is_coarse
from app.services.geocoder import geocode from app.services.geocoder import geocode, known_city_hint
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -53,13 +67,24 @@ async def geocode_missing_listings(
"""Geocode listings с NULL coords (любой source). """Geocode listings с NULL coords (любой source).
Steps: Steps:
1. SELECT DISTINCT address FROM listings WHERE lat IS NULL AND address IS NOT NULL 1. SELECT address, city FROM listings WHERE lat IS NULL AND is_active
GROUP BY address ORDER BY COUNT(*) DESC LIMIT batch_size AND address IS NOT NULL GROUP BY address, city ORDER BY COUNT(*) DESC
(приоритет адресам с большим числом listings больший ROI per geocode call) LIMIT batch_size
(приоритет парам address+city с большим числом listings больший ROI per
geocode call; группировка по паре, НЕ только по address #2594 шаг 2/3:
один и тот же текст адреса в разных городах разные записи. `is_active`
#2604 п.1: не тратим Nominatim-бюджет на мёртвые объявления, которые никогда
не попадут в выдачу пользователю)
2. Для каждого address: 2. Для каждой пары (address, city):
- geocode(address, db) auto-cache (hit или miss) - geocode(address, db, city_hint=known_city_hint(city)) auto-cache
- Если есть результат: UPDATE listings SET lat, lon WHERE address = :addr AND lat IS NULL (hit или miss); хинт гейтится словарём городов области (#2603)
- Если есть результат: UPDATE listings SET lat, lon
WHERE address = :addr AND city IS NOT DISTINCT FROM :city AND lat IS NULL
(IS NOT DISTINCT FROM, а не `=` стандартная SQL NULL-семантика: `city = NULL`
никогда не true, поэтому обычным `=` группа с city IS NULL не обновилась бы
вообще ни для одной строки; `IS NOT DISTINCT FROM` трактует NULL=NULL как
совпадение, оставаясь строгим при непустом city нужная нам симметрия)
- PostGIS trigger (listings_set_geom_trg) автоматически обновит geom - PostGIS trigger (listings_set_geom_trg) автоматически обновит geom
3. Log progress каждые 50 addresses. 3. Log progress каждые 50 addresses.
@ -74,25 +99,39 @@ async def geocode_missing_listings(
start = time.monotonic() start = time.monotonic()
result = GeocodeBackfillResult() result = GeocodeBackfillResult()
# 1. Найти top-N адресов с NULL coords (DESC by occurrence count). # 1. Найти top-N пар (address, city) с NULL coords (DESC by occurrence count).
# Фильтруем адреса, по которым геокодер уже пробовал и не нашёл — они помечены # Группировка по паре, а не только по address (#2594 шаг 2/3) — один и тот же
# текст адреса в разных городах (напр. «ул. Победы, 30» в ЕКБ и в Нижнем Тагиле)
# это разные записи с разными координатами, их нельзя схлопывать в один
# geocode-вызов. GROUP BY address, city трактует NULL city как отдельную
# группу (стандартная SQL-семантика группировки NULL как равных друг другу).
# Фильтруем пары, по которым геокодер уже пробовал и не нашёл — они помечены
# geocode_tried_at. Повторяем попытку только если tried_at старше 7 дней (возможен # geocode_tried_at. Повторяем попытку только если tried_at старше 7 дней (возможен
# переезд адреса в кэше или смена провайдера), либо tried_at IS NULL (ещё не пробовали). # переезд адреса в кэше или смена провайдера), либо tried_at IS NULL (ещё не пробовали).
# Это делает функцию loop-safe: при вызове несколько раз в одном прогоне # Это делает функцию loop-safe: при вызове несколько раз в одном прогоне
# failed-адреса не переотбираются бесконечно. # failed-пары не переотбираются бесконечно.
#
# AND is_active (#2604 п.1) — очередь без этого фильтра на 98.5% состояла из
# is_active=false объявлений чужих регионов (Новосибирск/Казань/Челябинск/…),
# а ORDER BY listings_count DESC ставил самый мусорный адрес («Новосибирская
# обл.,Новосибирск», сотни listings) В НАЧАЛО — весь batch съедался мусором,
# который пользователь никогда не увидит (is_active=false), 8 ночных прогонов
# подряд saved=0. Активные объявления с валидным адресом почти всегда попадают
# в topN только теперь, когда мусор не конкурирует за место в LIMIT.
rows = ( rows = (
db.execute( db.execute(
text( text(
""" """
SELECT address, COUNT(*) AS listings_count SELECT address, city, COUNT(*) AS listings_count
FROM listings FROM listings
WHERE lat IS NULL WHERE lat IS NULL
AND is_active
AND address IS NOT NULL AND address IS NOT NULL
AND length(trim(address)) >= 5 AND length(trim(address)) >= 5
AND (geocode_tried_at IS NULL AND (geocode_tried_at IS NULL
OR geocode_tried_at < NOW() - INTERVAL '7 days') OR geocode_tried_at < NOW() - INTERVAL '7 days')
GROUP BY address GROUP BY address, city
ORDER BY listings_count DESC, address ASC ORDER BY listings_count DESC, address ASC, city ASC NULLS FIRST
LIMIT :limit LIMIT :limit
""" """
), ),
@ -117,23 +156,43 @@ async def geocode_missing_listings(
for idx, row in enumerate(rows): for idx, row in enumerate(rows):
address: str = row["address"] address: str = row["address"]
city: str | None = row.get("city")
listings_count: int = row["listings_count"] listings_count: int = row["listings_count"]
result.addresses_processed += 1 result.addresses_processed += 1
try: try:
geo = await geocode(address, db) # known_city_hint (#2603) — общий гейт по словарю городов области для
# всех DB-колоночных callers. Для listings.city он сегодня no-op
# (скрапер пишет только шесть кураторских имён из
# scraper_kit CITY_DISPLAY_NAMES, все они есть в словаре), но держит
# инвариант единым с deals-путями, где колонка росреестровая и в
# хвосте лежит мусор. Сырой `city` ниже остаётся ключом группы для
# UPDATE — гейт влияет только на подсказку геокодеру.
geo = await geocode(address, db, city_hint=known_city_hint(city))
except Exception as exc: except Exception as exc:
logger.warning("geocode_missing: geocode raised for '%s': %s", address[:60], exc) logger.warning("geocode_missing: geocode raised for '%s': %s", address[:60], exc)
result.addresses_failed += 1 result.addresses_failed += 1
if not dry_run: if not dry_run:
# Пометить tried_at чтобы адрес не переотбирался в следующих batch'ах # Пометить tried_at чтобы пара (address, city) не переотбиралась
# этого же прогона (loop-safe backoff 7 дней). # в следующих batch'ах этого же прогона (loop-safe backoff 7 дней).
# IS NOT DISTINCT FROM — city=NULL это отдельная группа, обычное
# `=` не поймает NULL-город и не должно задеть другой город с тем
# же текстом адреса.
# Намеренно БЕЗ `AND is_active` (#2604 п.2): tried_at — backoff-метка
# для (address, city) КАК ТЕКСТА, а не для конкретного listing.
# is_active=false дубликат этой пары и так никогда не будет выбран
# SELECT'ом заново (is_active=false исключён там навсегда) — фильтр
# здесь был бы no-op для неактивных строк. Единственный случай когда
# это имеет значение — если строка позже реактивируется (is_active
# → true): тогда tried_at уже стоит и backoff корректно защищает от
# немедленного повторного запроса того же заведомо неудачного адреса.
db.execute( db.execute(
text( text(
"UPDATE listings SET geocode_tried_at = NOW()" "UPDATE listings SET geocode_tried_at = NOW()"
" WHERE address = :addr AND lat IS NULL" " WHERE address = :addr AND city IS NOT DISTINCT FROM :city"
" AND lat IS NULL"
), ),
{"addr": address}, {"addr": address, "city": city},
) )
db.commit() db.commit()
continue continue
@ -141,18 +200,25 @@ async def geocode_missing_listings(
if geo is None: if geo is None:
result.addresses_failed += 1 result.addresses_failed += 1
logger.info( logger.info(
"geocode_missing: NOT FOUND '%s' (used in %d listings)", "geocode_missing: NOT FOUND '%s' city=%r (used in %d listings)",
address[:60], address[:60],
city,
listings_count, listings_count,
) )
if not dry_run: if not dry_run:
# Пометить tried_at — geocoder не нашёл адрес, backoff 7 дней. # Пометить tried_at — geocoder не нашёл адрес, backoff 7 дней.
# Намеренно БЕЗ `AND is_active` (#2604 п.2) — то же обоснование, что
# и в except-ветке выше: backoff привязан к тексту (address, city),
# не к конкретному listing, is_active=false строка и так не выбирается
# SELECT'ом заново; при реактивации backoff корректно защитит от
# немедленного повтора заведомо неудачного запроса.
db.execute( db.execute(
text( text(
"UPDATE listings SET geocode_tried_at = NOW()" "UPDATE listings SET geocode_tried_at = NOW()"
" WHERE address = :addr AND lat IS NULL" " WHERE address = :addr AND city IS NOT DISTINCT FROM :city"
" AND lat IS NULL"
), ),
{"addr": address}, {"addr": address, "city": city},
) )
db.commit() db.commit()
continue continue
@ -165,9 +231,13 @@ async def geocode_missing_listings(
result.addresses_geocoded += 1 result.addresses_geocoded += 1
if dry_run: if dry_run:
# city в логе (#2603) — с #2594 это часть ключа группы: без него две
# строки dry-run с одинаковым текстом адреса неотличимы друг от друга.
logger.info( logger.info(
"geocode_missing[dry]: '%s' → (%.5f, %.5f) provider=%s would update %d listings", "geocode_missing[dry]: '%s' city=%r → (%.5f, %.5f) provider=%s "
"would update %d listings",
address[:60], address[:60],
city,
geo.lat, geo.lat,
geo.lon, geo.lon,
geo.provider, geo.provider,
@ -183,16 +253,37 @@ async def geocode_missing_listings(
# UPDATE listings — PostGIS trigger (listings_set_geom_trg) обновит geom автоматически. # UPDATE listings — PostGIS trigger (listings_set_geom_trg) обновит geom автоматически.
# geo_precision и geocode_tried_at проставляются одновременно с координатами. # geo_precision и geocode_tried_at проставляются одновременно с координатами.
# city IS NOT DISTINCT FROM :city — обновляем ТОЛЬКО пару (address, city), из
# которой был geocode-запрос; иначе тот же текст адреса в другом городе
# (city IS NULL или другой явный город) перезаписался бы чужими координатами.
#
# Намеренно БЕЗ `AND is_active` (#2604 п.1): координаты — свойство физического
# адреса, а не свойство конкретного объявления. Если у этой же пары
# (address, city) есть is_active=false дубликат с lat IS NULL, он получит те же
# координаты бесплатно — Nominatim-вызов уже оплачен геокодом активного
# листинга, доп. запроса не будет. SELECT выше и так навсегда исключает
# is_active=false строки из очереди — без этого UPDATE такой дубликат остался
# бы с NULL lat/lon НАВСЕГДА (переезд в EKB-only локальные реестры/analytics по
# координатам сломан для него), хотя ответ уже есть в руках. Единственный
# довод «за» фильтр — консистентность с SELECT — не перевешивает: это не
# ошибка данных (координаты адреса объективны и не зависят от активности),
# а чистый выигрыш (та же строка при реактивации уже готова, доп. cost = 0).
update_result = db.execute( update_result = db.execute(
text( text(
""" """
UPDATE listings UPDATE listings
SET lat = :lat, lon = :lon, geo_precision = :precision, SET lat = :lat, lon = :lon, geo_precision = :precision,
geocode_tried_at = NOW() geocode_tried_at = NOW()
WHERE address = :addr AND lat IS NULL WHERE address = :addr AND city IS NOT DISTINCT FROM :city AND lat IS NULL
""" """
), ),
{"lat": geo.lat, "lon": geo.lon, "precision": precision, "addr": address}, {
"lat": geo.lat,
"lon": geo.lon,
"precision": precision,
"addr": address,
"city": city,
},
) )
db.commit() db.commit()
result.listings_updated += update_result.rowcount result.listings_updated += update_result.rowcount
@ -293,6 +384,13 @@ async def run_geocode_missing_listings(
) )
break break
if res.addresses_total < batch_size: if res.addresses_total < batch_size:
# #2604 п.3: с is_active-фильтром в SELECT очередь резко уже (была
# 14294 строк/98.5% мёртвых, стало ~220 активных → десятки уникальных
# пар address+city после GROUP BY) — этот дренаж почти всегда сработает
# уже на первой итерации (addresses_total < default batch_size=200), и
# это ПРАВИЛЬНОЕ поведение: разгребли всё что было, ждём следующего
# прогона. Никакого деления тут нет (только сравнение int), пустая
# очередь (addresses_total=0) ловится веткой выше, а не этой.
logger.info( logger.info(
"run_geocode_missing_listings: run_id=%d — дренаж " "run_geocode_missing_listings: run_id=%d — дренаж "
"(addresses_total=%d < batch_size=%d), завершаем", "(addresses_total=%d < batch_size=%d), завершаем",

View file

@ -9,13 +9,34 @@ listing_source_events. Так история per-source цены копится
через product_handlers._job_listing_source_snapshot, через product_handlers._job_listing_source_snapshot,
по образцу import_rosreestr_dkp (sync task в run_in_executor). по образцу import_rosreestr_dkp (sync task в run_in_executor).
Вся работа два set-based SQL statement'а (snapshot upsert + event-diff CTE), Вся работа два set-based SQL statement'а (snapshot upsert + event-diff), никакого
никакого row-by-row Python: 18 355 строк обслуживаются одним INSERT SELECT каждый. row-by-row Python.
#2607 — root cause висящих прогонов (ежедневный zombie с минимум 19 июля, всегда ровно 6h
до zombie-порога): event-diff раньше писал "prior" как CTE `DISTINCT ON (listing_source_id)
... ORDER BY listing_source_id, snapshot_date DESC` по ВСЕЙ listing_source_snapshots (~2.6-2.8M
строк) и джойнил её с "today" через обычный JOIN. Планировщик оценивает `snapshot_date =
CURRENT_DATE` в 1 строку (статистика ANALYZE ещё не видела свежевставленные в этой же
транзакции строки today CURRENT_DATE всегда за пределами гистограммы), выбирает Nested
Loop БЕЗ Materialize на внутренней стороне и на КАЖДУЮ реальную строку today (~80-140k)
заново пересчитывает DISTINCT ON по всей таблице (Unique + Index Scan ~2.7M строк)
EXPLAIN на проде показал cost300k именно на этом шаге. Реально это никогда не завершалось
за 6h, оставляя backend 'active' на сутки после того как zombie-детектор помечал
scrape_runs.status='zombie' (детектор НЕ убивает backend, см. reap_zombies) держало
backend_xmin, блокируя autovacuum на listings/listing_sources.
Fix: `prior` переписан через `JOIN LATERAL (... ORDER BY snapshot_date DESC LIMIT 1) ON true`
форсирует per-row индексный point-lookup по idx_lss_source_date (listing_source_id,
snapshot_date DESC) вместо полного DISTINCT ON по таблице; EXPLAIN на проде: cost внутреннего
подзапроса упал с ~298 627 до ~4.4 за строку today. Плюс defense-in-depth: budget_sec
SET LOCAL statement_timeout (см. snapshot_listing_sources) если что-то опять разрегрессирует
план, прогон честно падает в mark_failed вместо того чтобы висеть сутками.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -27,6 +48,30 @@ logger = logging.getLogger(__name__)
# Окно свежести: источник считается активным, если last_seen_at не старше N дней. # Окно свежести: источник считается активным, если last_seen_at не старше N дней.
FRESHNESS_WINDOW_DAYS = 7 FRESHNESS_WINDOW_DAYS = 7
# ── Wall-clock budget (#2607 п.4) ─────────────────────────────────────────────
# Задача не батчится Python-циклом (два set-based statement'а) — единственный способ
# гарантированно оборвать зависший statement это Postgres-нативный statement_timeout,
# выставленный SET LOCAL (per-transaction scope, НЕ трогает server/role-level timeout —
# это issue #2607 п.2, отдельное решение с согласованием). По образцу budget_sec из
# app/tasks/geocode_missing.py (run_geocode_missing_listings), только здесь это не Python
# loop-budget, а SQL statement_timeout.
# Default/clamp: см. data/sql/202_listing_source_snapshot_budget_sec.sql (default_params
# budget_sec=900 — 15 мин, с большим запасом над ожидаемым временем выполнения после
# LATERAL-фикса (секунды) и далеко от 6h zombie-порога).
DEFAULT_BUDGET_SEC = 900.0
_MIN_BUDGET_SEC = 30.0
_MAX_BUDGET_SEC = 3600.0 # hard ceiling — не даём budget_sec случайно воссоздать "висит вечно"
def _clamp_budget_sec(raw: Any) -> float:
"""Валидировать/зажать budget_sec из default_params — защита от 0/отрицательного/мусора."""
try:
val = float(raw)
except (TypeError, ValueError):
val = DEFAULT_BUDGET_SEC
return max(_MIN_BUDGET_SEC, min(val, _MAX_BUDGET_SEC))
# ── Daily snapshot upsert ───────────────────────────────────────────────────── # ── Daily snapshot upsert ─────────────────────────────────────────────────────
# Снимок на (listing_source_id, CURRENT_DATE). ON CONFLICT → last-write-wins за день # Снимок на (listing_source_id, CURRENT_DATE). ON CONFLICT → last-write-wins за день
# (повторный прогон в те же сутки перезаписывает снимок свежими значениями). # (повторный прогон в те же сутки перезаписывает снимок свежими значениями).
@ -59,83 +104,209 @@ _SNAPSHOT_SQL = text(
""" """
) )
# ── Event diff: price_change ────────────────────────────────────────────────── # ── Event diff: три выводимых типа событий из пяти в схеме ────────────────────
# Для каждого источника сравниваем сегодняшнюю цену (snapshot_date = CURRENT_DATE) с # Для каждого источника сравниваем сегодняшний снимок (snapshot_date = CURRENT_DATE) с
# самым свежим ПРЕДЫДУЩИМ снимком (snapshot_date < CURRENT_DATE). Если цена изменилась # самым свежим ПРЕДЫДУЩИМ (snapshot_date < CURRENT_DATE).
# (обе NOT NULL, old <> 0) — пишем price_change. # today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции).
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL). # p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607).
# prior — последний снимок строго ДО сегодня (DISTINCT ON … ORDER BY date DESC). #
# Полностью set-based: один INSERT … SELECT по всем источникам, без Python-цикла. # #2674: схема (079) знает пять типов событий, писатель умел один — price_change,
# change_time = now() детерминирует UNIQUE(listing_source_id, change_time, event_type) # 8288 строк. Дописаны два:
# в пределах прогона → ON CONFLICT DO NOTHING делает писатель идемпотентным. # edited — payload_hash изменился, а цена нет (изменение цены уже описано
# отдельным событием price_change — дублировать его как «редактирование»
# значило бы считать одно изменение дважды). Прошлый хеш обязан быть
# непустым: md5(NULL) = NULL, и «payload появился впервые» — это не
# правка, а первое наблюдение;
# first_seen — предыдущего снимка нет вовсе (LEFT JOIN LATERAL даёт p.* = NULL).
#
# delisted и relisted НЕ ПИШУТСЯ НАМЕРЕННО — они НЕ ВЫВОДИМЫ из наших данных.
# is_active в снимке — derived-признак «last_seen_at свежее FRESHNESS_WINDOW_DAYS»,
# то есть «мы видели», а не «объявление есть на площадке». При покрытии обхода 10-35%
# такой переход рождается тем, что скрейпер СНОВА ДОШЁЛ до источника, а не тем, что
# объявление вернулось/ушло. Контрольная группа в наших же данных (14-18.07):
# domklik, покрытие 99.9-100%: снятий 1/2/0/2/4 в сутки, возвратов — РОВНО 0 все дни;
# yandex, покрытие 34-43%: снятий 343-433 в сутки, возвратов до 155.
# Тот же обход, тот же день — разница только в покрытии. Отсюда же всплески:
# avito 13.07 (день остановки обхода) — 3023 «снятия» за сутки против контрольной
# ставки 1-4, точность события ≈4%; 4705 «возвратов» из 5493 за 12 дней (86%) — это
# два дня после возобновления обхода 2-3.08.
# Сузить окно свежести НЕ поможет — станет хуже (больше флапаний); окно шире
# максимального интервала повторного визита обессмысливает само событие.
# Честный ответ схеме — не писать эти два типа, а не наполнять журнал догадками.
# Единственный жёсткий сигнал снятия — 404 при поштучном обходе, он пишется в
# listings_snapshots.status='closed' (avito_detail_backfill).
#
# Оставшиеся три события утверждают факты о НАШИХ СОБСТВЕННЫХ строках («появился новый
# источник», «хеш изменился при той же цене», «цена другая»), а не о поведении площадки.
#
# JOIN → LEFT JOIN LATERAL: без LEFT источники без предыдущего снимка отбрасывались
# join'ом, поэтому first_seen был недостижим по построению. План #2607 не меняется —
# LEFT JOIN LATERAL так же форсирует per-row индексный point-lookup по
# idx_lss_source_date, просто не отбрасывает строку при отсутствии предыдущей.
#
# Ветки разворачиваются CROSS JOIN LATERAL (VALUES ...) — одна строка сравнения даёт
# до трёх строк-кандидатов, из которых WHERE e.fires оставляет сработавшие. Это
# по-прежнему ОДИН set-based statement (никакого Python-цикла), просто три предиката
# вместо одного.
#
# #2607: раньше `p` был отдельным CTE `DISTINCT ON (listing_source_id) ... FROM
# listing_source_snapshots WHERE snapshot_date < CURRENT_DATE` и джойнился обычным JOIN.
# Планировщик оценивает `today` в 1 строку (свежевставленные в этой же транзакции строки
# ANALYZE ещё не видел) → Nested Loop БЕЗ Materialize на внутренней стороне → DISTINCT ON
# по ВСЕЙ таблице (~2.6-2.8M строк, Index Scan + Unique) пересчитывался ЗАНОВО на каждую
# из ~80-140k реальных строк today — на проде EXPLAIN показал cost≈300k на этом шаге,
# запрос не укладывался ни в 6h zombie-порог, ни в сутки. LATERAL форсирует per-row
# индексный lookup через idx_lss_source_date (listing_source_id, snapshot_date DESC) —
# `ORDER BY s.snapshot_date DESC LIMIT 1` даёт тот же единственный "последний снимок до
# сегодня" на listing_source_id, что и старый DISTINCT ON (PK (listing_source_id,
# snapshot_date) исключает дубликаты snapshot_date на одном источнике — семантика
# идентична), но за O(log n) на строку вместо полного скана таблицы. EXPLAIN на проде:
# cost внутреннего подзапроса упал с ~298 627 до ~4.4 за строку today.
#
# Полностью set-based: один INSERT … SELECT по всем источникам, без Python-цикла (LATERAL
# — это внутренний план Postgres, не Python-итерация).
#
# change_time = date_trunc('day', now()), а НЕ now() (#2674): с now() уникальность
# UNIQUE(listing_source_id, change_time, event_type) работала только ВНУТРИ прогона —
# второй прогон в те же сутки перезаписывал сегодняшний снимок, предикаты срабатывали
# заново с другим временем и давали дубли (2 августа таких прогонов было два).
# Суточная гранулярность честнее для суточного же сравнения и включает заявленную
# идемпотентность: ON CONFLICT DO NOTHING теперь действительно гасит повтор за день.
#
# NULLIF(p.price_rub, 0) в diff_percent обязателен: выражения VALUES вычисляются ДО
# фильтра `WHERE e.fires`, поэтому предикат "p.price_rub <> 0" от деления на ноль уже
# не спасает — без NULLIF первый же источник с нулевой прошлой ценой уронил бы весь
# прогон. Результат при этом тот же: строка с NULL-диффом не проходит e.fires.
#
# Внешний SELECT над data-modifying CTE считает вставленное ПО ТИПАМ (RETURNING отдаёт
# только реально вставленные строки, не съеденные ON CONFLICT), сразу в виде ключей
# счётчиков `<event_type>_events` — писатель получает готовый dict без Python-агрегации.
# Ровно этот счётчик и показал бы четыре нуля из пяти, если бы существовал раньше.
_EVENT_DIFF_SQL = text( _EVENT_DIFF_SQL = text(
""" """
WITH today AS ( WITH today AS (
SELECT listing_source_id, price_rub SELECT listing_source_id, price_rub, payload_hash
FROM listing_source_snapshots FROM listing_source_snapshots
WHERE snapshot_date = CURRENT_DATE WHERE snapshot_date = CURRENT_DATE
), ),
prior AS ( inserted AS (
SELECT DISTINCT ON (listing_source_id)
listing_source_id, price_rub
FROM listing_source_snapshots
WHERE snapshot_date < CURRENT_DATE
ORDER BY listing_source_id, snapshot_date DESC
)
INSERT INTO listing_source_events ( INSERT INTO listing_source_events (
listing_source_id, change_time, event_type, price_rub, diff_percent listing_source_id, change_time, event_type, price_rub, diff_percent
) )
SELECT SELECT
t.listing_source_id, t.listing_source_id,
now(), date_trunc('day', now()),
'price_change', e.event_type,
t.price_rub, t.price_rub,
round((t.price_rub - p.price_rub)::numeric / p.price_rub * 100, 4) e.diff_percent
FROM today t FROM today t
JOIN prior p ON p.listing_source_id = t.listing_source_id LEFT JOIN LATERAL (
WHERE t.price_rub IS NOT NULL SELECT s.snapshot_date, s.price_rub, s.payload_hash
FROM listing_source_snapshots s
WHERE s.listing_source_id = t.listing_source_id
AND s.snapshot_date < CURRENT_DATE
ORDER BY s.snapshot_date DESC
LIMIT 1
) p ON true
CROSS JOIN LATERAL (VALUES
(
'first_seen',
NULL::numeric,
p.snapshot_date IS NULL
),
(
'price_change',
round((t.price_rub - p.price_rub)::numeric
/ NULLIF(p.price_rub, 0) * 100, 4),
t.price_rub IS NOT NULL
AND p.price_rub IS NOT NULL AND p.price_rub IS NOT NULL
AND p.price_rub <> 0 AND p.price_rub <> 0
AND t.price_rub <> p.price_rub AND t.price_rub <> p.price_rub
),
(
'edited',
NULL::numeric,
p.payload_hash IS NOT NULL
AND t.payload_hash IS DISTINCT FROM p.payload_hash
AND t.price_rub IS NOT DISTINCT FROM p.price_rub
)
) AS e(event_type, diff_percent, fires)
WHERE e.fires
ON CONFLICT (listing_source_id, change_time, event_type) DO NOTHING ON CONFLICT (listing_source_id, change_time, event_type) DO NOTHING
RETURNING event_type
)
SELECT event_type || '_events' AS counter_key, count(*) AS n
FROM inserted
GROUP BY 1
""" """
) )
def snapshot_listing_sources(db: Session, run_id: int) -> dict[str, int]: def snapshot_listing_sources(
"""Записать дневной снимок listing_sources + price_change-события. db: Session, run_id: int, params: dict[str, Any] | None = None
) -> dict[str, int]:
"""Записать дневной снимок listing_sources + события изменений.
Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp). Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp).
Два set-based statement'а в одной транзакции: Два set-based statement'а в одной транзакции:
1. upsert снимка на (listing_source_id, CURRENT_DATE) last-write-wins. 1. upsert снимка на (listing_source_id, CURRENT_DATE) last-write-wins.
2. diff сегодняшней цены против последнего предыдущего снимка price_change-события. 2. diff сегодняшнего снимка против последнего предыдущего три события,
выводимые из наших данных (#2674). delisted/relisted схема разрешает, но
они НЕ выводимы при покрытии обхода 10-35% см. _EVENT_DIFF_SQL.
Params (из default_params jsonb в scrape_schedules, #2607):
budget_sec: float SET LOCAL statement_timeout на транзакцию (default 900,
clamp [30, 3600]). Единственный способ гарантированно оборвать зависший
statement у не-батчащейся (два statement'а, не Python-цикл) задачи — если
план снова разрегрессирует, прогон честно упадёт в mark_failed вместо того
чтобы висеть часами/сутками (root cause #2607 — см. шапку файла и
_EVENT_DIFF_SQL).
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters. Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
Returns {"snapshotted": N, "price_change_events": M}. Returns {"snapshotted": N, "<event_type>_events": M} по счётчику на каждый из
трёх пишущихся типов, всегда все три ключа (тип, который за прогон не сработал
ни разу, честно показывает 0, а не пропадает из counters).
""" """
counters: dict[str, int] = {"snapshotted": 0, "price_change_events": 0} params = params or {}
budget_sec = _clamp_budget_sec(params.get("budget_sec", DEFAULT_BUDGET_SEC))
counters: dict[str, int] = {
"snapshotted": 0,
"price_change_events": 0,
"edited_events": 0,
"first_seen_events": 0,
}
try: try:
# statement_timeout НЕ принимает bind-параметр ($1/:name) — синтаксис Postgres SET
# запрещает placeholder на этом месте (проверено вживую на проде: "syntax error at
# or near \"$1\""). budget_sec провалидирован/clamp'нут в _clamp_budget_sec выше
# (источник — scrape_schedules.default_params, не user input) — f-string здесь
# безопасен (единственный практический способ выставить эту GUC динамически).
# SET LOCAL — per-transaction scope, сбрасывается на COMMIT/ROLLBACK, НЕ трогает
# server/role-level statement_timeout (issue #2607 п.2 — отдельное решение).
timeout_ms = int(budget_sec * 1000)
db.execute(text(f"SET LOCAL statement_timeout = {timeout_ms}"))
snap_result = db.execute( snap_result = db.execute(
_SNAPSHOT_SQL, _SNAPSHOT_SQL,
{"freshness_days": FRESHNESS_WINDOW_DAYS, "run_id": run_id}, {"freshness_days": FRESHNESS_WINDOW_DAYS, "run_id": run_id},
) )
counters["snapshotted"] = snap_result.rowcount or 0 counters["snapshotted"] = snap_result.rowcount or 0
event_result = db.execute(_EVENT_DIFF_SQL) # Statement возвращает уже готовые пары (counter_key, n) по типам событий —
counters["price_change_events"] = event_result.rowcount or 0 # dict(...) без Python-агрегации, набор ключей задан инициализацией counters
# выше, так что не сработавшие типы остаются нулями, а не исчезают.
event_rows = db.execute(_EVENT_DIFF_SQL).fetchall()
counters.update(dict(event_rows))
db.commit() db.commit()
runs_mod.mark_done(db, run_id, counters) runs_mod.mark_done(db, run_id, counters)
logger.info( logger.info("snapshot_listing_sources run_id=%d done: %s", run_id, counters)
"snapshot_listing_sources run_id=%d done: snapshotted=%d price_change_events=%d",
run_id,
counters["snapshotted"],
counters["price_change_events"],
)
return counters return counters
except Exception as exc: except Exception as exc:
logger.exception("snapshot_listing_sources run_id=%d failed", run_id) logger.exception(
"snapshot_listing_sources run_id=%d failed (budget_sec=%.0f)", run_id, budget_sec
)
db.rollback() db.rollback()
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters) runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise raise

View file

@ -66,6 +66,7 @@ import json
import logging import logging
import random import random
import time import time
from collections.abc import Callable
from dataclasses import dataclass, field, fields from dataclasses import dataclass, field, fields
from sqlalchemy import text from sqlalchemy import text
@ -306,8 +307,7 @@ def _house_enrichment_counts(db: Session, house_id: int) -> tuple[int, int, int]
rc = int( rc = int(
db.execute( db.execute(
text( text(
"SELECT COUNT(*) FROM house_reliability_checks " "SELECT COUNT(*) FROM house_reliability_checks WHERE house_id = CAST(:h AS bigint)"
"WHERE house_id = CAST(:h AS bigint)"
), ),
{"h": house_id}, {"h": house_id},
).scalar_one() ).scalar_one()
@ -328,6 +328,7 @@ async def backfill_newbuilding_enrichment(
force: bool = False, force: bool = False,
request_delay_sec: float | None = None, request_delay_sec: float | None = None,
dry_run: bool = False, dry_run: bool = False,
on_progress: Callable[[NewbuildingEnrichBackfillResult], None] | None = None,
) -> NewbuildingEnrichBackfillResult: ) -> NewbuildingEnrichBackfillResult:
"""Backfill the 3 newbuilding-enrichment tables over cian_newbuilding houses. """Backfill the 3 newbuilding-enrichment tables over cian_newbuilding houses.
@ -347,6 +348,11 @@ async def backfill_newbuilding_enrichment(
(default 5s). Applied with ±20% jitter; anti-bot politeness. A house needing (default 5s). Applied with ±20% jitter; anti-bot politeness. A house needing
a resolve incurs TWO delays (resolve fetch + enrich fetch). a resolve incurs TWO delays (resolve fetch + enrich fetch).
dry_run: count the population + log the pending list, fetch nothing, write nothing. dry_run: count the population + log the pending list, fetch nothing, write nothing.
on_progress: колбэк живости (#2725) — вызывается на каждом доме с текущим
(мутируемым) result; caller пишет scrape_runs.heartbeat_at. Без него
heartbeat уходил один раз до цикла, а `reap_zombies` меряет именно его:
дом обходится за ~2.6 мин, и на limit'е порядка 140 (полный прогон — 318
домов, см. выше) прогон переваливал бы 6-часовой порог живым.
Returns: Returns:
NewbuildingEnrichBackfillResult with population sizing, per-house outcome NewbuildingEnrichBackfillResult with population sizing, per-house outcome
@ -415,6 +421,8 @@ async def backfill_newbuilding_enrichment(
zhk_url: str | None = row["cian_zhk_url"] zhk_url: str | None = row["cian_zhk_url"]
ext_id: str | None = row["ext_id"] ext_id: str | None = row["ext_id"]
result.processed += 1 result.processed += 1
if on_progress is not None:
on_progress(result)
# Idempotency fast-path: with force=False the SELECT already excludes enriched # Idempotency fast-path: with force=False the SELECT already excludes enriched
# houses (price_dynamics + reliability present), so this branch is a belt-and- # houses (price_dynamics + reliability present), so this branch is a belt-and-
@ -696,6 +704,18 @@ async def run_newbuilding_enrich(
"failed_save": 0, "failed_save": 0,
} }
def _heartbeat(progress: NewbuildingEnrichBackfillResult) -> None:
"""Сигнал живости из середины цикла (#2725). Best-effort — сбой heartbeat не
должен ронять уже идущий обход."""
try:
runs_mod.update_heartbeat(db, run_id, progress.to_dict())
except Exception:
logger.warning(
"scheduler: newbuilding_enrich run_id=%d heartbeat failed (ignored)",
run_id,
exc_info=True,
)
try: try:
runs_mod.update_heartbeat(db, run_id, counters) runs_mod.update_heartbeat(db, run_id, counters)
@ -704,6 +724,7 @@ async def run_newbuilding_enrich(
limit=limit, limit=limit,
force=force, force=force,
request_delay_sec=request_delay_sec, request_delay_sec=request_delay_sec,
on_progress=_heartbeat,
) )
counters = result.to_dict() counters = result.to_dict()

View file

@ -9,26 +9,47 @@
видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок. видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок.
Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и
поднимает per-day WARNING-алерт, когда данные устарели СВЕРХ допустимого лага поднимает per-day ERROR-алерт, когда данные устарели СВЕРХ допустимого лага
публикации так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах. публикации так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах.
#2674 — почему ERROR, а не WARNING. В контейнере скрапера GlitchTip поднят с
LoggingIntegration(event_level=ERROR) (scheduler_main.py), поэтому WARNING
событием НЕ становится вообще. Бенчмарк цен участвует в сверке наших медиан, его
застой сбой, а не наблюдение. Сосед по конструкции (deals_freshness_monitor)
писал ERROR с самого начала расходилась только эта джоба.
ВАЖНО про «9 срабатываний» из #2674 (ревью PR #2681, прод-разбор всех 24 прогонов
монитора 2026-08-06). Эти девять НЕ были застоем бенчмарка это была ПИЛА нашего
собственного такта загрузки:
13-16.07 alert=1 age 73..76 latest=май 01-05.08 alert=1 age 61..65
17.07 alert=0 age 46 latest=июнь (день загрузки)
Загрузка ходила раз в 28 дней и приносила период на месяц новее, возраст же
считается от ПЕРВОГО числа покрытого месяца пол ~46 в момент загрузки, потолок
46+28=74, порог 60 ВНУТРИ диапазона, тревога 14 суток из 28 каждый цикл. Поднимать
такое до ERROR без починки такта значило бы завести ежедневное ложное событие на
две недели в месяц. Поэтому миграция 212 перевела sber_index_pull на НЕДЕЛЬНЫЙ
такт: потолок возраста пол+7 53 при пороге 60, тревога снова означает
«источник/загрузка встали», а не «мы давно не ходили».
Порог алерта (документирование выбора): Порог алерта (документирование выбора):
Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д). Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д).
Монитор: age > sber_index_max_age_days + lag_allowance. Монитор: age > sber_index_max_age_days + lag_allowance.
lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) запас на ИНХЕРЕНТНЫЙ лаг lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) запас на ИНХЕРЕНТНЫЙ лаг
публикации СберИндекса: источник отстаёт на 1-2 месяца, period_month лейбл публикации СберИндекса: источник отстаёт на 1-2 месяца, а period_month лейбл
ПЕРВОГО числа месяца, а месячный pull ещё не подтянул новейший период. Итог: ПЕРВОГО числа месяца, поэтому даже свежайшая загрузка даёт возраст ~46 суток.
35 + 25 = 60д. Ниже 60д latest считается «нормально отстающим» алерта нет Итог: 35 + 25 = 60д. При недельном такте (миграция 212) рабочий диапазон возраста
(иначе daily-шум на штатном лаге). Выше 60д данные застряли сверх ~2 месяцев ~46..53 до порога остаётся ~7 суток запаса: один пропущенный недельный цикл
алерт. Проверено на проде 2026-07-12: max=2026-05-01, age=72д > 60 alert=1. поглощается, два подряд дают тревогу. Порог НЕ должен снова оказаться внутри
рабочего диапазона если такт загрузки будут менять, пересчитай потолок
(пол + interval_days) и сверь с 60.
Задача синхронная (DB-only, один SELECT max(period_month)) запускается Задача синхронная (DB-only, один SELECT max(period_month)) запускается
kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в
run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ
функция evaluate_sber_freshness() (frozen-now тестируется без БД). функция evaluate_sber_freshness() (frozen-now тестируется без БД).
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) WARNING Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) ERROR-записи
достаточен. mark_failed только если sber_price_index недоступна/пуста (нечего достаточно. mark_failed только если sber_price_index недоступна/пуста (нечего
оценивать). оценивать).
""" """
@ -136,7 +157,10 @@ def check_sber_freshness(
row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first() row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first()
latest: date | None = row.latest if row is not None else None latest: date | None = row.latest if row is not None else None
if latest is None: if latest is None:
logger.warning( # ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой,
# а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд),
# а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке.
logger.error(
"sber freshness: sber_price_index пуст/недоступен для region=%s " "sber freshness: sber_price_index пуст/недоступен для region=%s "
"(вторичка) — оценить свежесть нельзя", "(вторичка) — оценить свежесть нельзя",
SBER_MONITOR_CITY, SBER_MONITOR_CITY,
@ -156,7 +180,9 @@ def check_sber_freshness(
} }
if verdict.stale: if verdict.stale:
logger.warning( # ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR) —
# 9 срабатываний на проде дали ноль событий. См. докстринг модуля.
logger.error(
"sber freshness: max(period_month)=%s устарел на %d дней " "sber freshness: max(period_month)=%s устарел на %d дней "
"(> порога %d = sber_index_max_age_days %d + lag %d); " "(> порога %d = sber_index_max_age_days %d + lag %d); "
"СберИндекс time-adjustment ДКП-сделок мог отстать — " "СберИндекс time-adjustment ДКП-сделок мог отстать — "

View file

@ -12,8 +12,10 @@ offer detail page via curl_cffi AsyncSession (chrome120 + proxy) — mirrors
yandex_address_backfill.py which already gets full HTML from Yandex on prod. yandex_address_backfill.py which already gets full HTML from Yandex on prod.
Parse HTML via YandexDetailScraper.parse (pure, no network). Persist via Parse HTML via YandexDetailScraper.parse (pure, no network). Persist via
save_detail_enrichment. Track consecutive parseNone results; abort after save_detail_enrichment. Track consecutive parseNone results; abort after
max_consecutive_blocks (mark_done, not mark_failed retry next night via max_consecutive_blocks. Прогон с нулём обогащений теперь 'failed', не 'done'
NULL detail_enriched_at). (#2674, runs.mark_backfill_finished): на проде 31 прогон из 52 упирался ровно в
этот брейкер (attempted=5 failed=5) и все 31 назывались успешными. Остаток
снапшота уедет в следующую ночь через NULL detail_enriched_at.
Why curl_cffi and not YandexDetailScraper.fetch_detail: Why curl_cffi and not YandexDetailScraper.fetch_detail:
fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS
@ -85,7 +87,8 @@ async def run_yandex_detail_backfill(
(possible captcha wall); consecutive None abort after max_consecutive_blocks. (possible captcha wall); consecutive None abort after max_consecutive_blocks.
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard -> Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
mark_done (incl. partial / consecutive-None abort) / mark_failed (exception only). mark_backfill_finished (done / failed при нуле обогащений, #2674);
mark_failed напрямую только при исключении.
""" """
batch_size = int(params.get("batch_size", 800)) batch_size = int(params.get("batch_size", 800))
budget_sec = float(params.get("budget_sec", 3600)) budget_sec = float(params.get("budget_sec", 3600))
@ -278,9 +281,11 @@ async def run_yandex_detail_backfill(
counters.duration_sec = time.monotonic() - start counters.duration_sec = time.monotonic() - start
current_counters = counters.to_dict() 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="yandex_detail_backfill"
)
logger.info( logger.info(
"yandex_detail_backfill: run_id=%d DONE -- attempted=%d enriched=%d " "yandex_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "
"failed=%d duration=%.1fs", "failed=%d duration=%.1fs",
run_id, run_id,
counters.attempted, counters.attempted,

View file

@ -12,7 +12,14 @@
-- --
-- СОЗДАЁТ: -- СОЗДАЁТ:
-- asking_to_sold_ratios — таблица коэффициентов (rooms_bucket, district) → ratio. -- asking_to_sold_ratios — таблица коэффициентов (rooms_bucket, district) → ratio.
-- rooms_bucket: 0=студия,1,2,3,4(=4+); СПЕЦ-строка rooms_bucket=-1 = global fallback. -- rooms_bucket: ИМЯ ЛЕГАСИ — с #2620 (2026-08) семантика AREA-BASED, не «комнаты»:
-- 0=area<30, 1=area<44, 2=area<62, 3=area<85, 4=area>=85 м² (границы = ровно та же
-- формула, что синтезирует deals.rooms из площади при импорте, см. deploy/import-
-- rosreestr.sh и app/tasks/asking_to_sold_ratio.py: _AREA_ROOMS_BUCKET_SQL/area_bucket()).
-- Причина: Росреестр не отдаёт реальную комнатность, поэтому обе стороны (расчёт ask_side
-- И применение в estimator.py) ключуются по площади — сравнение «area-бакет vs реальные
-- комнаты» давало систематический mismatch (до 55% строк не в своём бакете, #2620).
-- СПЕЦ-строка rooms_bucket=-1 = global fallback.
-- district: ЗАРЕЗЕРВИРОВАНО для #647 (geo-разбивка); в #648 ВСЕГДА '' (часть PK, -- district: ЗАРЕЗЕРВИРОВАНО для #647 (geo-разбивка); в #648 ВСЕГДА '' (часть PK,
-- поэтому NOT NULL DEFAULT '' — '' можно положить в PK, NULL нельзя). -- поэтому NOT NULL DEFAULT '' — '' можно положить в PK, NULL нельзя).
-- --
@ -61,7 +68,8 @@ BEGIN;
-- district NOT NULL DEFAULT '' — часть PK; #647 заполнит район, #648 всегда ''. -- district NOT NULL DEFAULT '' — часть PK; #647 заполнит район, #648 всегда ''.
-- sold_median/ask_median nullable — диагностика; ratio NOT NULL (строку без ratio не пишем). -- sold_median/ask_median nullable — диагностика; ratio NOT NULL (строку без ratio не пишем).
CREATE TABLE IF NOT EXISTS asking_to_sold_ratios ( CREATE TABLE IF NOT EXISTS asking_to_sold_ratios (
rooms_bucket int NOT NULL, -- 0=студия..4=4+; -1 = global fallback row rooms_bucket int NOT NULL, -- legacy name, area-based since #2620:
-- 0=area<30..4=area>=85; -1=global fallback
district text NOT NULL DEFAULT '', -- RESERVED for #647 (always '' in #648) district text NOT NULL DEFAULT '', -- RESERVED for #647 (always '' in #648)
ratio numeric NOT NULL, -- sold_median_ppm2 / ask_median_ppm2 ratio numeric NOT NULL, -- sold_median_ppm2 / ask_median_ppm2
sold_median bigint, -- median(deals.price_per_m2), диагностика sold_median bigint, -- median(deals.price_per_m2), диагностика
@ -75,18 +83,25 @@ CREATE TABLE IF NOT EXISTS asking_to_sold_ratios (
); );
COMMENT ON TABLE asking_to_sold_ratios IS COMMENT ON TABLE asking_to_sold_ratios IS
'Per-rooms asking→sold коэффициент (#648): ratio = median(SOLD ppm²)/median(ASKING ppm²). ' 'Asking→sold коэффициент (#648): ratio = median(SOLD ppm²)/median(ASKING ppm²). '
'rooms_bucket 0=студия..4=4+; -1 = global fallback (basis=global_fallback, пишется всегда). ' 'rooms_bucket — LEGACY NAME, area-based since #2620: 0=area<30..4=area>=85 m2 (same '
'formula deals.rooms is synthesized from, see import-rosreestr.sh); -1 = global fallback '
'(basis=global_fallback, пишется всегда). '
'Per-rooms строки только при n_deals>=30 AND n_listings>=30 (иначе estimator читает -1). ' 'Per-rooms строки только при n_deals>=30 AND n_listings>=30 (иначе estimator читает -1). '
'district зарезервирован под #647 (geo), в #648 всегда ''''. ' 'district зарезервирован под #647 (geo), в #648 всегда ''''. '
'Caveat: ask=ТЕКУЩИЕ listings vs sold=сделки за 12 мес (не point-in-time); ДКП=registered. ' 'Caveat: ask=ТЕКУЩИЕ listings vs sold=сделки за 12 мес (не point-in-time); ДКП=registered. '
'Refresh — Stage 4 asking_to_sold_ratio_refresh переиспользует derivation ниже.'; 'Refresh — Stage 4 asking_to_sold_ratio_refresh переиспользует derivation ниже (area-bucket '
'ask-side since #2620 — see app/tasks/asking_to_sold_ratio.py, this seed predates it).';
-- ── Derivation + seed ───────────────────────────────────────────────────────── -- ── Derivation + seed ─────────────────────────────────────────────────────────
-- Вынесено как один INSERT...SELECT с CTE-«сторонами» (deal_side / ask_side), чтобы -- Вынесено как один INSERT...SELECT с CTE-«сторонами» (deal_side / ask_side), чтобы
-- Stage 4 (asking_to_sold_ratio_refresh) переиспользовал ровно эту логику. Окно сделок -- Stage 4 (asking_to_sold_ratio_refresh) переиспользовал ровно эту логику. Окно сделок
-- = трейлинг 12 мес; listings — текущие активные. ppm²-полоса [30000,600000] и бакет -- = трейлинг 12 мес; listings — текущие активные. ppm²-полоса [30000,600000] и бакет
-- LEAST(GREATEST(rooms,0),4) — байт-в-байт как в харнесе (PPM2_MIN/PPM2_MAX, _bucketize_rooms). -- LEAST(GREATEST(rooms,0),4) — байт-в-байт как в харнесе (PPM2_MIN/PPM2_MAX, _bucketize_rooms).
-- #2620 (2026-08): live-рефреш (app/tasks/asking_to_sold_ratio.py) ушёл от этого fresh-install
-- seed — ask_side там бакетируется по площади (_AREA_ROOMS_BUCKET_SQL), а не rooms; см. комментарий
-- в СОЗДАЁТ выше и модуль asking_to_sold_ratio.py. Этот CTE-блок оставлен как есть (fresh-install
-- seed, применяется один раз через _schema_migrations) — не источник истины для прод-derivation.
WITH WITH
-- SOLD медианы по бакетам комнат за трейлинг-12мес (ДКП Росреестра). -- SOLD медианы по бакетам комнат за трейлинг-12мес (ДКП Росреестра).
deal_side AS ( deal_side AS (

View file

@ -13,7 +13,15 @@
-- precision БЕЗ единого внешнего HTTP-запроса (в отличие от Nominatim) — но был ТОЛЬКО -- precision БЕЗ единого внешнего HTTP-запроса (в отличие от Nominatim) — но был ТОЛЬКО
-- manual script (`python -m app.tasks.backfill_listings_coords_geoportal`), ни разу не -- manual script (`python -m app.tasks.backfill_listings_coords_geoportal`), ни разу не
-- запускавшийся на recurring основе. Один прошлый ручной прогон (#1841): 17241 -- запускавшийся на recurring основе. Один прошлый ручной прогон (#1841): 17241
-- кандидатов → 1008 проставлено (не-ЕКБ адреса не матчатся — корректно, EKB-only реестр). -- кандидатов → 1008 проставлено.
--
-- ИСПРАВЛЕНО #2583 (находка H3): до фикса не-ЕКБ адреса region 66 (Нижний Тагил, Серов
-- и т.д.) НЕ отсекались — street+house парсились без учёта города и слепо матчились
-- против EKB-only реестра. Улица+дом могут буквально совпасть с ЕКБ ("проспект Ленина 1"
-- есть и в ЕКБ, и в Нижнем Тагиле) — такой листинг получал координаты Екатеринбурга.
-- Фикс: городской гейт _names_non_ekb_city перед вызовом _geoportal_house_match (тот же
-- гейт, что и в geocoder.geocode()). Не "корректно, EKB-only реестр", как было написано
-- здесь раньше — это была реальная утечка не-ЕКБ адресов в ЕКБ-координаты.
-- --
-- Решение: wire в in-app scheduler (source='geoportal_coords_backfill') по паттерну -- Решение: wire в in-app scheduler (source='geoportal_coords_backfill') по паттерну
-- cadastral_geo_match (migration 125) — pure internal DB op, SAFE to enable=true. -- cadastral_geo_match (migration 125) — pure internal DB op, SAFE to enable=true.

View file

@ -0,0 +1,88 @@
-- Migration 192: tradein_users + tradein_sessions — DB-backed auth (issue #2551, эпик #2549)
--
-- WHY:
-- Trade-in auth сейчас держится на legacy Caddy basic-auth fallback (см. auth/roles.yaml,
-- упомянут в 191_account_quota_unlimited_flag.sql как "хардкод username в коде"). Эпик #2549
-- переводит auth на DB-backed модель: пользователи + сессии как данные, роли admin/manager/
-- employee с иерархией manager -> employee. Эта миграция — только схема (Foundation),
-- без seed-данных (seed — отдельная задача #2557) и без Python-кода (backend wiring — отдельно).
--
-- WHAT:
-- 1. tradein_users — identity + role + org-иерархия.
-- - password_hash NULL допустим: переходный период, когда логин ещё идёт через
-- legacy Caddy fallback, а не через password verify в приложении.
-- - role CHECK ('admin','manager','employee') — три уровня доступа.
-- - manager_id — self-FK, ON DELETE SET NULL (увольнение/удаление manager'а не должно
-- каскадно сносить его employees, они просто остаются без привязки).
-- - CHECK role_manager_hierarchy: admin/manager обязаны иметь manager_id IS NULL
-- (это top-level роли, у них нет "начальника" в этой модели); employee — manager_id
-- любой, включая NULL (свободный слот employee без организации допустим).
-- 2. tradein_sessions — токен-based сессии, привязаны к user_id, ON DELETE CASCADE
-- (удалили пользователя — его сессии теряют смысл, каскадная очистка корректна).
-- last_seen_at отдельно от created_at — для idle-timeout / активности сессии.
-- 3. Индексы: expires_at (уборка протухших сессий), user_id (список сессий юзера),
-- partial на manager_id (иерархия) — WHERE manager_id IS NOT NULL, т.к. большинство
-- admin/manager строк это NULL и не участвуют в lookup "employees этого manager'а".
--
-- IDEMPOTENCY:
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS. Повторный прогон — no-op.
-- CHECK-констрейнты добавлены inline в CREATE TABLE (не через ALTER) — при повторном
-- запуске CREATE TABLE IF NOT EXISTS не выполнится вообще, констрейнт не задублируется.
--
-- Dependencies: нет (новые таблицы, ничего существующего не меняем).
-- Deploy order: эта миграция — Foundation эпика #2549. Seed (#2557) и backend auth-код —
-- отдельные PR'ы ПОСЛЕ этой (SQL-схема первой, см. .claude/rules/sql.md "Migration order").
BEGIN;
CREATE TABLE IF NOT EXISTS tradein_users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username text NOT NULL UNIQUE,
password_hash text NULL,
role text NOT NULL CHECK (role IN ('admin', 'manager', 'employee')),
manager_id bigint NULL REFERENCES tradein_users(id) ON DELETE SET NULL,
display_name text NULL,
org_name text NULL,
email text NULL,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT tradein_users_role_manager_hierarchy_ck CHECK (
role NOT IN ('admin', 'manager') OR manager_id IS NULL
)
);
COMMENT ON TABLE tradein_users IS
'Trade-in DB-backed auth — пользователи (issue #2551, эпик #2549). password_hash NULL '
'допустим в переходный период (логин через legacy Caddy fallback). Seed — отдельно (#2557).';
COMMENT ON COLUMN tradein_users.password_hash IS
'NULL = логин только через legacy Caddy basic-auth fallback, не через password verify.';
COMMENT ON COLUMN tradein_users.manager_id IS
'Self-FK на tradein_users(id). NULL для admin/manager (top-level, CHECK ниже) или для '
'employee без назначенной организации.';
CREATE TABLE IF NOT EXISTS tradein_sessions (
token text PRIMARY KEY,
user_id bigint NOT NULL REFERENCES tradein_users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
last_seen_at timestamptz NOT NULL DEFAULT now(),
ip_address inet NULL,
user_agent text NULL
);
COMMENT ON TABLE tradein_sessions IS
'Trade-in DB-backed auth — активные сессии (issue #2551, эпик #2549). '
'ON DELETE CASCADE от tradein_users: удалённый пользователь теряет все сессии.';
CREATE INDEX IF NOT EXISTS tradein_sessions_expires_at_idx
ON tradein_sessions (expires_at);
CREATE INDEX IF NOT EXISTS tradein_sessions_user_id_idx
ON tradein_sessions (user_id);
CREATE INDEX IF NOT EXISTS tradein_users_manager_id_idx
ON tradein_users (manager_id)
WHERE manager_id IS NOT NULL;
COMMIT;

View file

@ -0,0 +1,127 @@
-- Migration 193: seed существующих юзеров в tradein_users + ASCII-CHECK на username
-- (issue #2557, эпик #2549)
--
-- WHY:
-- Migration 192 создала schema (tradein_users/tradein_sessions), но без данных —
-- DB-backed auth не может заработать, пока реальные аккаунты (сейчас живущие только
-- в auth/roles.yaml + caddy/users.caddy.snippet, legacy Caddy basic-auth) не отражены
-- в таблице. Эта миграция переносит org-карту, утверждённую владельцем продукта,
-- в данные — без единого пароля (см. WHAT.2) и без Python-кода (backend wiring — #2556,
-- team-UI для проставления паролей — отдельная задача, тоже #2556).
--
-- ASCII-CHECK (deep-review #2561, обязательное требование ДО прод-данных):
-- rbac кодирует session-username через `encode("latin-1", "replace")`. Кириллические
-- логины ОДИНАКОВОЙ длины схлопываются в одну и ту же byte-строку под этой кодировкой
-- ("иванов" и "петров" оба 6 кириллических символов -> оба превращаются в одинаковую
-- строку из '?' одной длины) -> общий downstream-identity между разными людьми, общая
-- квота, взаимный IDOR (один видит сессии/данные другого). Все текущие org-логины уже
-- ASCII (admin/kopylov/praktika/userN), поэтому constraint не конфликтует с seed'ом
-- ниже; он существует, чтобы navsegda запретить будущим кириллическим логинам попасть
-- в таблицу — fail-closed на уровне схемы, а не на уровне доверия к тому, что кто-то
-- не забудет проверить в UI/API layer.
--
-- WHAT:
-- 1. ASCII-CHECK: tradein_users_username_ascii_ck CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$').
-- Добавлен ДО seed-инсертов ниже для читаемости файла (CHECK — immediate constraint,
-- Postgres валидирует им и ROW-строки транзакции независимо от того, в каком месте
-- файла он объявлен относительно INSERT, так что порядок сам по себе не критичен).
-- 2. Seed — org-карта, утверждённая владельцем продукта (2026-07-30):
-- admin role=admin, manager_id=NULL, is_active=true (владелец)
-- kopylov role=manager, manager_id=NULL, is_active=true (отдельный клиент)
-- praktika role=manager, manager_id=NULL, is_active=true (ГК «Практика»)
-- user1, user3-10 role=employee, manager_id=NULL, is_active=true (свободные слоты, без org)
-- user2 role=employee, manager_id=NULL, is_active=false («Брусника», доступ
-- закрыт 2026-07-30)
-- password_hash = NULL для ВСЕХ — пароли админ проставит вручную через team-UI (#2556).
-- NULL-hash делает password-логин невозможным для этой строки, но НЕ снимает доступ:
-- в переходный период работает только legacy Caddy basic-auth fallback (dual-mode,
-- см. комментарий password_hash в 192_tradein_users_auth.sql) — никто не теряет доступ
-- из-за этой миграции.
-- display_name = 'Копылов' для kopylov (источник — auth.py::_USERNAME_PROFILE, уже
-- задокументированная фамилия). Для остальных — NULL, реальных данных нет, не выдумываем.
-- НЕ мигрируем admintest/pilottest/analysttest/expiredtest — temp QA-фикстуры
-- (auth/roles.yaml), остаются только там, в DB-backed auth не нужны.
--
-- IDEMPOTENCY:
-- - ADD CONSTRAINT через DO-блок с проверкой pg_constraint (Postgres не поддерживает
-- `ADD CONSTRAINT IF NOT EXISTS` для CHECK) — паттерн из
-- 189_account_estimate_usage_nonnegative.sql.
-- - INSERT ... ON CONFLICT (username) DO UPDATE, но НЕ безусловно: password_hash,
-- manager_id, display_name, org_name, email защищены COALESCE(текущее, EXCLUDED) —
-- если админ уже проставил пароль / назначил manager_id (team-API #2563 пишет
-- manager_id при создании сотрудника менеджером) / поменял display_name вручную
-- через team-UI (#2556) между двумя прогонами этого файла (например ручной re-apply
-- при recovery — обычный auto-apply тречит filename в _schema_migrations и не
-- запускает файл дважды на одном окружении, но scratch/staging БД такого
-- трекинга не имеют), повторный прогон НЕ должен затереть это состояние NULL-ом /
-- seed-дефолтом. Deep-review #2564 нашёл это живым багом: manager_id, назначенный
-- через #2563, тихо обнулялся повторным прогоном сида — employee выпадал из
-- `_LIST_EMPLOYEES_BY_MANAGER_SQL`, менеджер переставал видеть его в дашборде.
-- role намеренно синкается с EXCLUDED (не защищён) — это и есть источник истины
-- org-карты из этой миграции; если владелец продукта поправит эту таблицу новой
-- миграцией поверх, DO UPDATE-ветка должна донести исправление роли, а не
-- заморозить первый прогон навсегда.
-- - is_active НАМЕРЕННО отсутствует в SET (не COALESCE — колонка NOT NULL DEFAULT
-- true, COALESCE(NOT NULL, x) никогда не берёт x, это была бы мёртвая, вводящая в
-- заблуждение симметрия с password_hash/manager_id, deep-review #2564 medium).
-- Открытие/закрытие доступа (is_active) — решение владельца продукта, принимается
-- через UI (#2556), НЕ повторным прогоном этого seed-файла: после первой вставки
-- колонка сознательно «замораживается» на текущем значении в БД, seed её больше
-- не трогает.
--
-- Dependencies: 192_tradein_users_auth.sql (создаёт tradein_users, tradein_sessions).
-- Deploy order: после 192 (Foundation). Backend auth-код (login/password-verify) и
-- team-UI (#2556) — отдельные PR'ы ПОСЛЕ этой миграции (SQL-схема+данные первыми, см.
-- .claude/rules/sql.md "Migration order").
BEGIN;
-- Часть 1: ASCII-CHECK (immediate constraint — валидирует и вставляемые ниже строки).
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'tradein_users_username_ascii_ck'
) THEN
ALTER TABLE tradein_users
ADD CONSTRAINT tradein_users_username_ascii_ck
CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$');
END IF;
END $$;
COMMENT ON CONSTRAINT tradein_users_username_ascii_ck ON tradein_users IS
'Fail-closed защита от кириллических/не-ASCII логинов (deep-review #2561): '
'rbac кодирует session-username через encode("latin-1","replace"), не-ASCII '
'логины одинаковой длины схлопываются в общий downstream-identity (IDOR).';
-- Часть 2: seed org-карты (владелец продукта, 2026-07-30).
INSERT INTO tradein_users
(username, password_hash, role, manager_id, display_name, org_name, email, is_active)
VALUES
('admin', NULL, 'admin', NULL, NULL, NULL, NULL, true),
('kopylov', NULL, 'manager', NULL, 'Копылов', NULL, NULL, true),
('praktika', NULL, 'manager', NULL, NULL, NULL, NULL, true),
('user1', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user2', NULL, 'employee', NULL, NULL, NULL, NULL, false),
('user3', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user4', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user5', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user6', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user7', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user8', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user9', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user10', NULL, 'employee', NULL, NULL, NULL, NULL, true)
ON CONFLICT (username) DO UPDATE SET
role = EXCLUDED.role,
-- manager_id защищён COALESCE: team-API (#2563) пишет manager_id при назначении
-- сотрудника менеджером, повторный прогон seed'а не должен тихо обнулять эту связь.
manager_id = COALESCE(tradein_users.manager_id, EXCLUDED.manager_id),
password_hash = COALESCE(tradein_users.password_hash, EXCLUDED.password_hash),
display_name = COALESCE(tradein_users.display_name, EXCLUDED.display_name),
org_name = COALESCE(tradein_users.org_name, EXCLUDED.org_name),
email = COALESCE(tradein_users.email, EXCLUDED.email),
-- is_active НЕ в SET: NOT NULL DEFAULT true колонка, COALESCE был бы мёртвым кодом
-- (см. IDEMPOTENCY выше) — open/close доступа решается через UI (#2556), не seed'ом.
updated_at = now();
COMMIT;

View file

@ -0,0 +1,150 @@
-- 194_deal_city_price_bands_tiers.sql
-- Эпик #2576 Stage B — многоуровневые ценовые бэнды по городам + честный
-- региональный фолбэк вместо ЕКБ-калиброванного порога.
--
-- ПРОБЛЕМА:
-- Миграция 178 построила deal_city_price_bands РАЗОВО, только для городов
-- с count(*) >= 30 сделок (HAVING count(*) >= 30) на момент прогона. Auto-refresh
-- не был реализован (см. комментарий в 178). Город без строки в таблице
-- попадает на глобальный DEAL_MIN_PPM2=50_000 (estimator.py) — порог,
-- откалиброванный ИСКЛЮЧИТЕЛЬНО по Екатеринбургу. Для малых городов области
-- это не anti-outlier guard, а cut-off легитимного рынка (Североуральск
-- median ≈ 21.7k ₽/м²).
--
-- Замер по прод-данным deals (2026-07-31, source='rosreestr', city IS NOT NULL,
-- city <> 'Екатеринбург', price_per_m2 IS NOT NULL — 47 253 сделки / 369 городов):
-- N>=30 сделок → 80 городов (45 988 сделок, 97.3%) — уже покрыты 178.
-- N 15-29 → 21 город ( 460 сделок) — падали на global-50k fallback.
-- N 10-14 → 21 город ( 247 сделок) — падали на global-50k fallback.
-- N 1-9 → 247 городов ( 558 сделок) — падали на global-50k fallback,
-- per-city перцентиль на такой выборке статистически бессмысленен
-- (n=1 → «перцентиль» = единственная сделка).
-- Итого 289 городов / 1265 сделок (2.7% выборки, но 78% ДОЛГОГО ХВОСТА городов)
-- получали ЕКБ-калиброванный пол вместо своей реальной цены.
--
-- РЕШЕНИЕ — трёхуровневая схема (колонка tier), вместо единого порога 30:
-- 'full' N>=30 — own p1/p99 перцентиль (BYTE-IDENTICAL 178-derivation,
-- ЕКБ и существующие 80 городов НЕ меняются).
-- 'rough' 10<=N<30 — own p1 (floor), ceiling ФИКСИРОВАН на 800000
-- (не деривится из тонкой выборки — p99 на <30 точках
-- нестабилен, одна дорогая сделка исказит потолок).
-- 'region_fallback' 1<=N<10 — own-данные города СЛИШКОМ тонкие даже для floor
-- (единичная сделка = 100% перцентиля недостоверна).
-- Используем ПУЛ по всей области (region_stats CTE,
-- p1 по 47k+ не-ЕКБ сделкам = 15 263 ₽/м² на момент
-- замера) вместо DEAL_MIN_PPM2=50000 (ЕКБ-калибровка).
-- Честнее: 15k отражает реальный низ рынка обл.66,
-- а не искусственно завышенный екб-порог.
--
-- Екатеринбург по-прежнему НЕ включён (estimator.py fallback на глобальные
-- DEAL_MIN_PPM2/DEAL_MAX_PPM2 остаётся единственным путём для ЕКБ — invariant
-- из 178 сохранён). После этой миграции ЕВСЕ 369 не-ЕКБ городов, встречающихся
-- в deals, получают строку — Python-fallback в estimator.py (COALESCE(b.ppm2_min,
-- :ppm_min)) отныне срабатывает практически только для ЕКБ (плюс узкое окно
-- между refresh-циклами для только что появившегося города).
--
-- IDEMPOTENCY: ADD COLUMN IF NOT EXISTS + DO-блок guard на CHECK constraint
-- (PG 16 не поддерживает ADD CONSTRAINT IF NOT EXISTS). INSERT ... ON CONFLICT
-- DO UPDATE — повторный прогон рефрешит бэнды под свежие сделки (та же
-- семантика, что и 178). Без DELETE — множество городов монотонно растёт
-- (rosreestr_dkp_import только INSERT/UPDATE, никогда не удаляет), поэтому
-- merge-по-ключу достаточен (см. app/tasks/deal_city_price_bands_refresh.py —
-- периодический refresh, та же derivation байт-в-байт).
--
-- Dependencies: 177_deals_city_region.sql (deals.city), 178_deal_city_price_bands.sql
-- (таблица + PK(city)).
-- Apply after: --
-- Deploy order: эта миграция ПЕРЕД деплоем backend-кода, который регистрирует
-- scheduler-source 'deal_city_price_bands_refresh' (product_handlers.py) —
-- см. 195_scrape_schedules_seed_deal_city_price_bands_refresh.sql (deploy after
-- backend-код задеплоен, тот же порядок, что 088).
BEGIN;
ALTER TABLE deal_city_price_bands
ADD COLUMN IF NOT EXISTS tier text NOT NULL DEFAULT 'full';
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'deal_city_price_bands_tier_check'
) THEN
ALTER TABLE deal_city_price_bands
ADD CONSTRAINT deal_city_price_bands_tier_check
CHECK (tier IN ('full', 'rough', 'region_fallback'));
END IF;
END $$;
COMMENT ON COLUMN deal_city_price_bands.tier IS
'full: N>=30 сделок, own p1/p99 band (миграция 178, unchanged). '
'rough: 10<=N<30, own p1 floor + фиксированный 800000 ceiling (миграция 194). '
'region_fallback: 1<=N<10, pooled Свердловская-обл. p1 floor (region_stats, '
'все не-ЕКБ сделки) + фиксированный 800000 ceiling — вместо '
'ЕКБ-калиброванного DEAL_MIN_PPM2=50000 (estimator.py).';
WITH region_stats AS (
-- Пул по ВСЕЙ области (не-ЕКБ) — честный фолбэк для городов, где own-выборка
-- (N<10) слишком тонкая для собственного перцентиля.
SELECT GREATEST(
round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int,
8000
) AS region_ppm2_min
FROM deals
WHERE source = 'rosreestr'
AND price_per_m2 IS NOT NULL
AND city IS NOT NULL
AND city <> 'Екатеринбург'
),
city_stats AS (
SELECT
city,
GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000)
AS ppm2_p1,
LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000)
AS ppm2_p99,
count(*) AS n_deals
FROM deals
WHERE source = 'rosreestr'
AND price_per_m2 IS NOT NULL
AND city IS NOT NULL
AND city <> 'Екатеринбург'
GROUP BY city
),
tiered AS (
-- full — байт-в-байт исходная 178-derivation (own p1/p99), плюс тот же
-- анти-мусорный инвариант (p99 < 8000 → город не матчил бы ни одну сделку).
SELECT city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals,
'full'::text AS tier
FROM city_stats
WHERE n_deals >= 30
AND ppm2_p99 >= 8000
UNION ALL
-- rough — собственный p1 (floor), ceiling НЕ деривится (тонкая выборка).
SELECT city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max, n_deals,
'rough'::text AS tier
FROM city_stats
WHERE n_deals BETWEEN 10 AND 29
UNION ALL
-- region_fallback — собственных данных недостаточно даже для floor, берём
-- пул по области целиком.
SELECT c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max, c.n_deals,
'region_fallback'::text AS tier
FROM city_stats c
CROSS JOIN region_stats r
WHERE c.n_deals < 10
)
INSERT INTO deal_city_price_bands (city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at)
SELECT city, ppm2_min, ppm2_max, n_deals, tier, now()
FROM tiered
ON CONFLICT (city) DO UPDATE
SET ppm2_min = EXCLUDED.ppm2_min,
ppm2_max = EXCLUDED.ppm2_max,
n_deals = EXCLUDED.n_deals,
tier = EXCLUDED.tier,
refreshed_at = EXCLUDED.refreshed_at;
COMMIT;

View file

@ -0,0 +1,62 @@
-- 195_scrape_schedules_seed_deal_city_price_bands_refresh.sql
-- Эпик #2576 Stage B — seed scrape_schedules row для daily-рефреша
-- deal_city_price_bands (миграция 194).
--
-- ПРОБЛЕМА: 178/194 заполняют deal_city_price_bands на момент прогона миграции.
-- По мере ночного импорта новых ДКП-сделок (rosreestr_dkp_import, 04:00-06:00 UTC)
-- бэнды (own p1/p99, tier-границы N) устаревают — города переходят между tier
-- ('region_fallback' → 'rough' → 'full') по мере накопления сделок, а сами
-- перцентили внутри tier дрейфуют. Auto-refresh отсутствовал (см. follow-up
-- в 178) — эта миграция закрывает разрыв.
--
-- Задача (app/tasks/deal_city_price_bands_refresh.py, byte-identical derivation
-- 194) — pure-internal DB re-derivation, никаких внешних HTTP-вызовов. Запускается
-- kit-scheduler'ом через product_handlers._job_deal_city_price_bands_refresh
-- (run_in_executor, по образцу _job_asking_to_sold_ratio).
--
-- enabled = true — БЕЗОПАСНО включать сразу (тот же аргумент, что 082/088: pure DB,
-- без анти-бота).
-- Окно 07:00-08:00 UTC — ПОСЛЕ rosreestr_dkp_import (04:00-06:00, см. 072) И
-- asking_to_sold_ratio_refresh (06:00-07:00, см. 082), чтобы бэнды считались по
-- тому же свежему срезу deals, что и ratio-таблица того же дня.
-- next_run_at = завтрашнее наступление окна (tomorrow + 07:00 UTC) — тот же паттерн,
-- что 078/079/082/088 (иначе get_due_schedules() выстрелит сразу после деплоя).
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)),
-- 194_deal_city_price_bands_tiers.sql (tier-колонка, которую переиспользует refresh).
-- Idempotent: ON CONFLICT (source) DO NOTHING — безопасно запускать повторно.
-- Deploy order: применять ПОСЛЕ деплоя backend-кода, регистрирующего
-- 'deal_city_price_bands_refresh' в product_handlers.build_product_handlers()
-- (тот же порядок, что 088 relative к scheduler.py) — иначе kit-scheduler не
-- найдёт Handler для нового source и упадёт в "unknown source" на первом due-run
-- (не раньше завтрашнего окна — не блокирует деплой).
BEGIN;
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'deal_city_price_bands_refresh',
true, -- SAFE: pure internal DB, no external calls
7,
8,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
'{}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMENT ON TABLE scrape_schedules IS
'In-app scheduler config (заменяет cron-script setup). '
'Sources: avito_city_sweep, yandex_city_sweep (dormant, #561), '
'cian_history_backfill, rosreestr_dkp_import, listing_source_snapshot (#570), '
'asking_to_sold_ratio_refresh (#648), refresh_search_matview (#769), '
'deal_city_price_bands_refresh (#2576 Stage B).';
COMMIT;

View file

@ -0,0 +1,44 @@
-- 196_listings_city.sql
-- Issue #2594 — критичный дефект: скрапер знает город в момент сбора (city_slug из
-- CITY_LOCATIONS/CITY_ANCHORS, packages/scraper-kit/.../orchestration/pipeline.py), но
-- НИКУДА его не пишет. Провайдеры (avito/cian) часто отдают адрес БЕЗ города в тексте
-- ("ул. Победы, 30" вместо "Нижний Тагил, ул. Победы, 30") — cian даже явно вырезает
-- location-часть перед записью (skip_types = {"location", "metro"}, providers/cian/serp.py).
-- Без явного города такой адрес при геокодинге считается «город не назван» → попадает
-- в EKB-only локальные реестры (ekb_geoportal_buildings/gendesign_cad_buildings) и
-- коллизирует с одноимённой екатеринбургской улицей (Ленина/Победы/Тенистая — сотни
-- совпадений) → объявление получает координаты Екатеринбурга и тянет медиану чужих цен.
--
-- Fix:
-- Add listings.city TEXT column. Проставляется НЕПОСРЕДСТВЕННО из контекста
-- развёртки (город известен вызывающему коду — city_slug/CITY_LOCATIONS для oblast,
-- "Екатеринбург" для EKB-развёрток) — НЕ парсингом текста адреса. См.
-- scraper_kit.base.save_listings(..., city=...) + scraper_kit.orchestration.pipeline
-- .resolve_city_name(). Раздельная колонка (а не дописывание города в address) —
-- исходный текст адреса не портится, downstream text-парсеры (geocoder._parse_street_house,
-- geocoder._names_non_ekb_city, estimator._parse_street_house, house-matching) продолжают
-- работать НЕИЗМЕНЁННЫМИ на исходном сыром тексте — риск регрессии на bare-form адресах
-- без street-маркера ("Дружинина, 33") исключён.
--
-- Scope (#2594): только write-path для НОВЫХ листингов (go-forward). Бэкфилл city для
-- уже накопленных строк (restore по тому, какая развёртка их когда-то принесла) —
-- отдельная задача, НЕ эта миграция.
--
-- Idempotency:
-- ALTER TABLE ... ADD COLUMN IF NOT EXISTS — safe on re-run.
-- BEGIN/COMMIT block.
--
-- Dependencies:
-- 002_core_tables.sql (listings table).
BEGIN;
ALTER TABLE listings ADD COLUMN IF NOT EXISTS city text;
COMMENT ON COLUMN listings.city IS
'Город объявления (#2594) — проставляется из контекста развёртки '
'(city_slug city-sweep / "Екатеринбург" default), НЕ парсингом address. '
'NULL — листинг записан до этой миграции ИЛИ путём, ещё не проставляющим город '
'(admin ad-hoc /admin/scrape, manual ingest-скрипты).';
COMMIT;

View file

@ -0,0 +1,92 @@
-- 197_backfill_listings_city_from_url.sql
-- Issue #2594 шаг 3 — бэкфилл listings.city (миграция 196) для УЖЕ накопленных
-- Avito-объявлений из слага города в source_url.
--
-- ПРОБЛЕМА: 196 добавила колонку listings.city и write-path проставляет её
-- ТОЛЬКО для новых листингов (см. заголовок 196). Накопленные ранее строки
-- остались с city IS NULL. Для Avito-объявлений вне ЕКБ (city-sweep областных
-- городов) адрес в тексте часто без города («пр-т Вагоностроителей,18» вместо
-- «Нижний Тагил, пр-т Вагоностроителей,18»), а у части улиц есть тёзки в
-- Екатеринбурге (Хохрякова, Калинина — центральные ЕКБ-улицы). Без явного
-- city такой адрес при геокодировании (app/tasks/geocode_missing.py,
-- app/services/geocoder.py city_hint) считается «город не назван» → рискует
-- получить координаты Екатеринбурга (тот же баг-класс, что и #2594 основной).
-- Ночной прогон geocode_missing_listings 2026-08-01 заберёт в очередь 148
-- активных объявлений Нижнего Тагила без city — этот бэкфилл проставляет им
-- city ДО того, как очередь начнёт их обрабатывать.
--
-- ИСТОЧНИК: первый сегмент пути URL после хоста —
-- https://www.avito.ru/nizhniy_tagil/kvartiry/... -> 'nizhniy_tagil'
-- извлекается regex `substring(source_url from 'avito\.ru/([^/]+)/')`.
-- Маппинг ТОЛЬКО наших шести городов Свердловской обл. (region 66); слаги и
-- человекочитаемые названия сверены с CITY_DISPLAY_NAMES/CITY_LOCATIONS
-- (tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py)
-- — значения побайтно совпадают с тем, что теперь пишет скрапер (go-forward
-- write-path 196), чтобы не расщепить один город на две разные метки.
--
-- Проверено на проде (SELECT, read-only) перед миграцией:
-- avito_slug наш город city IS NULL (Avito)
-- 'ekaterinburg' -> 'Екатеринбург' 26770
-- 'nizhniy_tagil' -> 'Нижний Тагил' 551 (148 сегодня в очереди геокода)
-- 'kamensk-uralskiy' -> 'Каменск-Уральский' 244
-- 'pervouralsk' -> 'Первоуральск' 95
-- 'verhnyaya_pyshma' -> 'Верхняя Пышма' 21
-- 'serov' -> 'Серов' 25
-- ИТОГО 27706
-- ⚠️ avito_slug у Каменска-Уральского — ЧЕРЕЗ ДЕФИС ('kamensk-uralskiy'), не
-- через подчёркивание, в отличие от нашего внутреннего city_slug
-- 'kamensk_uralskiy' (CITY_LOCATIONS ключ). У Верхней Пышмы наоборот —
-- у Avito 'verhnyaya_pyshma' (kh -> h, БЕЗ 'k'), совпадает с
-- CityLocation("verhnyaya_pyshma", ...).avito_slug в pipeline.py, но
-- отличается от нашего внутреннего ключа 'verkhnyaya_pyshma' (с 'k').
-- В фактических данных встретился ТОЛЬКО вариант 'verhnyaya_pyshma' — второй
-- вариант написания в WHERE не нужен (дал бы 0 доп. строк).
--
-- ВНЕ SCOPE (сознательно не трогаем, обоснование):
-- - Cian: хост НЕ индикатор города (ekb.cian.ru отдаёт областные объявления,
-- включая тагильские, через тот же хост с параметром региона) — бэкфилл
-- по хосту дал бы неверный результат.
-- - Domclick: у объявлений без координат город не критичен (0 rows без
-- lat), 13 строк на голом domclick.ru — отдельный разбор, не эта миграция.
-- - Yandex: в URL (realty.yandex.ru/offer/<id>) города нет вовсе.
-- - listings.region_code: у 16912 чужих-региона строк он неверный (стоит
-- 66) — отдельный пункт issue #2604, ждёт решения владельца, здесь НЕ
-- трогаем.
-- - Слаги вне наших шести городов (1644 distinct на Avito, 16930 строк
-- city IS NULL) остаются NULL — по ним отдельное решение владельца.
--
-- Idempotency:
-- `WHERE city IS NULL` — не перетирает то, что уже проставил скрапер
-- (write-path 196) или предыдущий прогон этой же миграции. Повторный
-- прогон обновляет 0 строк (все затронутые строки уже НЕ city IS NULL).
-- CASE ветки строго совпадают со списком в WHERE ... IN (...), поэтому
-- для любой строки, прошедшей WHERE, CASE НЕ может вернуть NULL.
--
-- НЕ DDL — только UPDATE данных (колонка listings.city уже существует,
-- миграция 196). Ни одна строка не удаляется и не деактивируется.
--
-- Dependencies: 196_listings_city.sql (колонка listings.city).
BEGIN;
UPDATE listings
SET city = CASE substring(source_url from 'avito\.ru/([^/]+)/')
WHEN 'ekaterinburg' THEN 'Екатеринбург'
WHEN 'nizhniy_tagil' THEN 'Нижний Тагил'
WHEN 'kamensk-uralskiy' THEN 'Каменск-Уральский'
WHEN 'pervouralsk' THEN 'Первоуральск'
WHEN 'verhnyaya_pyshma' THEN 'Верхняя Пышма'
WHEN 'serov' THEN 'Серов'
END
WHERE source = 'avito'
AND city IS NULL
AND substring(source_url from 'avito\.ru/([^/]+)/') IN (
'ekaterinburg',
'nizhniy_tagil',
'kamensk-uralskiy',
'pervouralsk',
'verhnyaya_pyshma',
'serov'
);
COMMIT;

View file

@ -0,0 +1,49 @@
-- 198_scrape_proxy_rotations.sql
-- Issue #2600 п.5 — ротация exit-IP прокси ASocks по бану, со счётчиком и громким
-- отказом. АДДИТИВНО, не трогает scrape_proxies (157_scrape_proxies.sql) кроме
-- FK-ссылки; не трогает proxy_pool.py (параллельный PR #2609).
--
-- WHY:
-- Провайдер (ASocks, безлимитные порты) ограничивает ручную ротацию exit-IP тремя
-- вызовами в сутки на порт (эмпирика, владелец аккаунта). app.services.proxy_rotation
-- должен и проверять этот лимит ПЕРЕД обращением к API, и вести аудит попыток —
-- без отдельной таблицы истории лимит негде считать (scrape_proxies хранит только
-- текущее состояние, не историю).
--
-- Semantics:
-- Одна строка = одна попытка ротации (успешная ИЛИ неуспешная), но НЕ каждый
-- вызов rotate_proxy() пишет строку — локально отклонённые попытки (нет
-- rotate_url / нет ASOCKS_API_TOKEN / лимит уже исчерпан) вообще не доходят до
-- HTTP-вызова и в таблицу не пишутся (см. app.services.proxy_rotation docstring
-- за полным обоснованием "какие попытки считать против лимита").
-- http_status NULL = сетевая ошибка (ответа от провайдера не было вообще).
--
-- Idempotency:
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS → повторный прогон
-- no-op (auto-apply strict на деплое это требует). Весь файл в BEGIN/COMMIT.
--
-- Dependencies:
-- 157_scrape_proxies.sql (scrape_proxies.id — FK-таргет).
BEGIN;
CREATE TABLE IF NOT EXISTS scrape_proxy_rotations (
id bigserial PRIMARY KEY,
proxy_id bigint NOT NULL REFERENCES scrape_proxies (id),
rotated_at timestamptz NOT NULL DEFAULT now(),
success boolean NOT NULL,
http_status integer,
note text
);
COMMENT ON TABLE scrape_proxy_rotations IS
'Аудит + суточный лимит (#2600 п.5) ручных ротаций exit-IP через ASocks '
'refresh-ip. Лимит провайдера — 3 попытки/сутки на порт; app.services.'
'proxy_rotation._quota_used_today считает только строки с http_status '
'IS NOT NULL AND != 401 (реально дошедшие до провайдера) за последние 24ч.';
-- Проверка суточного лимита + выборка истории по прокси: (proxy_id, rotated_at).
CREATE INDEX IF NOT EXISTS idx_scrape_proxy_rotations_proxy_time
ON scrape_proxy_rotations (proxy_id, rotated_at);
COMMIT;

View file

@ -0,0 +1,58 @@
-- 199_scrape_proxies_asocks_rotate_url.sql
-- Issue #2600 п.5 — проставить rotate_url для четырёх ASocks unlimited-портов пула,
-- чтобы app.services.proxy_rotation.rotate_proxy имел куда стучаться.
--
-- WHY:
-- scrape_proxies.rotate_url для этих 4 строк сейчас NULL (загружены через
-- POST /proxies/bulk без rotate_url). Единственный рабочий способ ротации exit-IP
-- для ASocks-безлимитных портов — ручка веб-кабинета
-- POST https://api.asocks.com/unlimited-proxy/{portId}/refresh-ip с заголовком
-- Authorization: Bearer <ASOCKS_API_TOKEN> (env, НЕ в URL — секретов в миграции
-- нет). Документированный публичный GET /v2/proxy/refresh/{portId}?apiKey= для
-- безлимитных портов не работает (подтверждено владельцем аккаунта); ротация
-- session-суффиксом логина тоже не работает (проверено пробой, три варианта —
-- один и тот же exit-IP).
--
-- Matching (важно — НЕ по id):
-- scrape_proxies.id может разъехаться между средами (dev/stage/prod грузятся
-- bulk-ручкой независимо) — сопоставляем по адресу host:port, зашитому в конец
-- url (scrape_proxies.url — всегда 'scheme://[user:pass@]host:port' БЕЗ пути,
-- см. admin.py _mask_proxy_url/urlparse-логику и 157_scrape_proxies.sql) через
-- right(url, length(hostport)) = hostport. portId → host:port (проверено
-- владельцем аккаунта, issue #2600 п.5):
-- 223610715 → 212.8.249.134:10423
-- 225031312 → 190.2.145.131:10313
-- 231878029 → 175.110.115.153:10492
-- 231878030 → 109.236.82.42:11048
--
-- Idempotency:
-- Обычный UPDATE ... WHERE — повторный прогон пишет то же значение, no-op по
-- результату. Прокси, которых нет в пуле текущей среды (host:port не найден) —
-- 0 строк обновлено, не ошибка. Весь файл в BEGIN/COMMIT.
--
-- Dependencies:
-- 157_scrape_proxies.sql (scrape_proxies.rotate_url).
BEGIN;
UPDATE scrape_proxies
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/223610715/refresh-ip',
updated_at = now()
WHERE right(url, length(CAST('212.8.249.134:10423' AS text))) = '212.8.249.134:10423';
UPDATE scrape_proxies
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/225031312/refresh-ip',
updated_at = now()
WHERE right(url, length(CAST('190.2.145.131:10313' AS text))) = '190.2.145.131:10313';
UPDATE scrape_proxies
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/231878029/refresh-ip',
updated_at = now()
WHERE right(url, length(CAST('175.110.115.153:10492' AS text))) = '175.110.115.153:10492';
UPDATE scrape_proxies
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/231878030/refresh-ip',
updated_at = now()
WHERE right(url, length(CAST('109.236.82.42:11048' AS text))) = '109.236.82.42:11048';
COMMIT;

View file

@ -0,0 +1,100 @@
-- 200_region_code_foreign_cities.sql
-- Issue #2604 п.2 — убрать ложную метку региона у объявлений Avito из чужих
-- городов (Новосибирск, Казань, Челябинск, Тюмень и ещё ~1600 слагов).
--
-- ПРОБЛЕМА: 16930 строк listings (source='avito') несут region_code = 66
-- (Свердловская обл.), хотя source_url указывает на город ВНЕ наших шести —
-- это неправда. Строки — наследие массового заброса 18 июня (сплошной
-- multi-city SERP-краул до появления гео-фильтра карточек, коммит
-- f0264237, 20 июня), который с тех пор не проставлял target_city_slug на
-- SERP-запрос и не отсеивал карточки чужих городов на этапе сбора. Канал
-- давно закрыт (тот же класс проблемы, что чинили 196/197 для listings.city),
-- новых таких строк не поступает — все 16930 сейчас is_active = false.
--
-- ПОЧЕМУ NULL, А НЕ НАСТОЯЩИЙ РЕГИОН: вывести реальный регион из текста
-- адреса/URL можно было бы (slug города в source_url), но это требовало бы
-- поддерживать растущий справочник ~1600 чужих региональных кодов ради
-- колонки, которую сегодня не читает НИ ОДНА живая выборка (проверено grep:
-- только исторические миграции 077_*/091_* и один комментарий). Честное
-- «неизвестно» (NULL) дешевле и не создаёт вторую ложь взамен первой.
--
-- ПОЧЕМУ ТОЛЬКО AVITO: у cian/domklik/yandex region_code=66 определяется не
-- заброс-механизмом чужого города (там его и не было), а параметром region=
-- самого запроса (cian) / отсутствием городской привязки в URL вовсе
-- (domklik/yandex) — то есть в подавляющем большинстве region_code=66 у них
-- ВЕРНЫЙ. Среди них нашлось лишь 27 строк с адресом, похожим на чужой город
-- (текстовый разбор, ненадёжный сигнал) — сознательно НЕ трогаем, отдельная
-- задача при желании её довести.
--
-- ИСТОЧНИК СЛАГА: первый сегмент пути после хоста —
-- https://www.avito.ru/nizhniy_tagil/kvartiry/... -> 'nizhniy_tagil'
-- извлекается regex `substring(source_url from 'avito\.ru/([^/]+)/')` —
-- тот же идиом, что и в 197 (проверено: 'www.' перед 'avito.ru' в общий
-- матч не проваливается, слаг 'www' ни разу не извлёкся — все 45472
-- source_url на проде имеют форму 'https://www.avito.ru/...'). Точный
-- сегмент пути, НЕ `LIKE '%slug%'` — среди наших шести слагов нет
-- подстрочных коллизий друг с другом (ekaterinburg, nizhniy_tagil,
-- kamensk-uralskiy, pervouralsk, verhnyaya_pyshma, serov — все взаимно
-- не substring), поэтому точное сравнение через WHERE ... NOT IN (...) над
-- извлечённым сегментом безопасно.
--
-- Наши шесть слагов — АВИТОВСКОЕ написание (см. CityLocation(...).avito_slug
-- в packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py,
-- CITY_LOCATIONS ~ строки 330-336 + EKB default для 'ekaterinburg'):
-- kamensk-uralskiy — ЧЕРЕЗ ДЕФИС (не 'kamensk_uralskiy', наш внутренний
-- city_slug/CITY_LOCATIONS-ключ — через подчёркивание)
-- verhnyaya_pyshma — БЕЗ 'k' (не 'verkhnyaya_pyshma', наш внутренний ключ)
-- Побайтно сверено с 197_backfill_listings_city_from_url.sql, который решает
-- ту же задачу маппинга avito_slug -> наши города.
--
-- ЗАМЕРЫ (SELECT, read-only, прод, перед миграцией):
-- Наши шесть городов (НЕ должны попасть под UPDATE): 28542 строк
-- Кандидаты на UPDATE (source='avito', НЕ наши 6, region_code=66):
-- 16930 строк
-- из них is_active = false: 16930 (100%)
-- из них region_code = 66 (единственное текущее значение): 16930 (100%)
-- Avito-строк с region_code уже NULL среди кандидатов: 0
-- (UPDATE их не задевает по построению — WHERE region_code IS NOT NULL)
-- Avito-строк с нераспознаваемым source_url (слаг не извлёкся): 0
-- total avito = 45472 = 28542 (наши 6) + 16930 (кандидаты) — сходится.
--
-- ПРОИЗВОДИТЕЛЬНОСТЬ: триггеры на listings — column-scoped
-- (`listings_price_change_trg` на UPDATE OF price_rub,
-- `listings_set_geom_trg` на UPDATE OF lat, lon) — UPDATE только по
-- region_code их не пробуждает. Но `tsv` (GENERATED ALWAYS ... STORED над
-- description+address) пересчитывается на КАЖДОМ UPDATE независимо от того,
-- какие колонки менялись. EXPLAIN (без ANALYZE, план не исполняется) на
-- проде показывает Bitmap Heap Scan по listings_source_idx (source='avito')
-- — тот же путь доступа, что и в 197. 197 обновила 27706 строк с тем же tsv
-- recalculation за 4.1с; здесь строк меньше (16930, ~61% от 27706) —
-- ожидаемая длительность ~2.5-3с. Никакого DDL, GIST/geom не затронуты.
--
-- Idempotency: `AND region_code IS NOT NULL` — повторный прогон находит 0
-- строк (все затронутые строки уже NULL после первого прогона), UPDATE
-- становится no-op. WHERE ограничен ровно source='avito' и slug вне наших
-- шести — наши города и другие источники никогда не попадают в scope.
--
-- ГРАНИЦЫ: НЕ трогает region_code наших шести городов, НЕ трогает
-- cian/domklik/yandex/n1, НЕ трогает city/is_active/скраперы/
-- DEFAULT_REGION_CODE. Ничего не удаляет, ничего не деактивирует. Только
-- UPDATE одной колонки одной таблицы.
--
-- Dependencies: 002_core_tables.sql (listings.region_code — nullable int,
-- без DEFAULT на уровне таблицы).
BEGIN;
UPDATE listings
SET region_code = NULL
WHERE source = 'avito'
AND region_code IS NOT NULL
AND substring(source_url from 'avito\.ru/([^/]+)/') NOT IN (
'ekaterinburg',
'nizhniy_tagil',
'kamensk-uralskiy',
'pervouralsk',
'verhnyaya_pyshma',
'serov'
);
COMMIT;

View file

@ -0,0 +1,83 @@
-- 201_purge_dead_mobileproxy_proxies.sql
-- Issue #2613 — выпилить мёртвые узлы mobileproxy из пула scrape_proxies
-- вместе с чужим API-ключом, который лежал у них в rotate_url.
--
-- WHY:
-- Владелец подтвердил: подписка mobileproxy закрыта, продлевать не будут.
-- Прямая проба каждого узла из контейнера tradein-scraper (2026-08-01)
-- подтверждает смерть: id 2 — connection refused, id 3/4/5 — 407 Proxy
-- Authentication Required. Последняя успешная проверка (last_check_at) у
-- всех четырёх — 4-9 июля, все четыре enabled=false, consecutive_fails=5.
--
-- Две причины удалить, вторая важнее:
-- 1. Мёртвые узлы засоряют пул и его health-метрики.
-- 2. rotate_url у трёх из четырёх строк (id 3, 4, 5) хранит открытым
-- текстом чужой ключ провайдера в query-параметре ссылки ротации
-- (https://changeip.mobileproxy.space/?proxy_key=...). Именно из-за
-- неоднородности этой колонки (вперемешку с ASocks-строками, где
-- rotate_url — наш собственный API-эндпоинт БЕЗ секрета в URL,
-- авторизация Bearer-заголовком) глубокое ревью PR #2611 нашло
-- блокер: вызов ротации для такой строки отправил бы НАШ токен
-- ASocks на changeip.mobileproxy.space. Пин хоста в #2611 уже
-- закрывает саму уязвимость, но чужой секрет в базе держать незачем.
--
-- ПОЧЕМУ DELETE, А НЕ UPDATE (очистка полей + enabled=false):
-- Единственный FK, ссылающийся на scrape_proxies — scrape_proxy_rotations
-- .proxy_id (заведён 198_scrape_proxy_rotations.sql), delete_rule NO ACTION.
-- На момент миграции (замер ниже) в scrape_proxy_rotations нет НИ ОДНОЙ
-- строки вообще — таблица введена в этом же цикле работ (#2600 п.5) и
-- ручная ротация ни разу не запускалась. DELETE четырёх строк scrape_proxies
-- ничего не упирает. Если бы к строкам 2-5 успела прилипнуть история ротаций
-- к моменту применения — DELETE упадёт по FK-violation ВНУТРИ этой же
-- транзакции (BEGIN/COMMIT ниже), миграция целиком откатится, deploy
-- завершится ошибкой (auto-apply strict, exit 1) без частичного эффекта и
-- без порчи данных; отдельного ON DELETE-обработчика не требуется — узлы
-- disabled=false уже сейчас, acquire() их не выдаёт (idx_scrape_proxies_pick
-- фильтрует по enabled), новых ротаций на них взяться неоткуда до deploy.
-- Строки — исторический мусор без ссылок, полное удаление честнее частичной
-- очистки (не оставляет призрачную запись мёртвого узла в пуле) и убирает
-- секрет из базы целиком, а не только из одной колонки.
--
-- Matching (по домену url, НЕ по id):
-- id в scrape_proxies разъезжается между средами (bulk-загрузка независима
-- per-среда, тот же класс проблемы решён в 199 через host:port-matching).
-- Условие — WHERE url LIKE '%mobileproxy.space%' — ловит все четыре узла
-- независимо от порта/поддомена (ha./gi./auv./aup.mobileproxy.space) и не
-- заденет ASocks-строки (212.8.249.134 / 190.2.145.131 / 175.110.115.153 /
-- 109.236.82.42 — IP-адреса, без mobileproxy.space в url вовсе).
--
-- ЗАМЕРЫ (SELECT, read-only, прод, перед миграцией, 2026-08-01):
-- Строк под условие (url LIKE '%mobileproxy.space%'): 4 (id 2, 3, 4, 5)
-- Остаток пула после удаления (url NOT LIKE '%mobileproxy.space%'):
-- 4 (id 1, 9, 10, 11) — все ASocks
-- Строк в scrape_proxy_rotations на id 2/3/4/5: 0
-- Строк в scrape_proxy_rotations всего (таблица пуста): 0
-- Секрет-паттерн (token|bearer|secret|key=|password, regex
-- case-insensitive) в rotate_url ОСТАЮЩИХСЯ 4 строк: 0 совпадений
-- (rotate_url остающихся — https://api.asocks.com/unlimited-proxy/
-- <portId>/refresh-ip, без query-параметров вообще, авторизация Bearer
-- заголовком вне URL, см. 199_scrape_proxies_asocks_rotate_url.sql)
-- FK, ссылающиеся на scrape_proxies: ровно один —
-- scrape_proxy_rotations.proxy_id -> scrape_proxies.id, delete_rule NO ACTION.
--
-- Idempotency:
-- Обычный DELETE ... WHERE — повторный прогон находит 0 строк (уже
-- удалены), no-op. Весь файл в BEGIN/COMMIT.
--
-- ГРАНИЦЫ: НЕ трогает ASocks-строки (id 1, 9, 10, 11) и их rotate_url. НЕ
-- трогает переменные окружения (*_PROXY_URL, BROWSER_PROXY_*,
-- *_PROXY_ROTATE_URL) — их снятие отдельная задача и НЕ раньше неё, иначе
-- при пустом прокси curl_proxy_url отдаёт None = скрапер идёт напрямую с IP
-- сервера. НЕ трогает app/services/proxy_pool.py, proxy_rotation.py,
-- скраперы. Никакого DDL.
--
-- Dependencies:
-- 157_scrape_proxies.sql (scrape_proxies.url/rotate_url/enabled).
-- 198_scrape_proxy_rotations.sql (FK proxy_id -> scrape_proxies.id, NO ACTION).
BEGIN;
DELETE FROM scrape_proxies
WHERE url LIKE '%mobileproxy.space%';
COMMIT;

View file

@ -0,0 +1,38 @@
-- 202_listing_source_snapshot_budget_sec.sql
-- #2607 — listing_source_snapshot зависал каждую ночь (минимум с 19 июля): scrape_runs
-- всегда добирал до 'zombie' ровно за 6h (порог zombie-детектора), но backend в Postgres
-- продолжал жечь CPU СУТКАМИ после этого (zombie-детектор в scraper_kit.orchestration.
-- scheduler.reap_zombies только помечает строку scrape_runs — не убивает backend), держа
-- backend_xmin и блокируя autovacuum на listings/listing_sources.
--
-- ROOT CAUSE (тот же PR, app/tasks/listing_source_snapshot.py): event-diff CTE джойнил
-- "today" (снимок за CURRENT_DATE) с "prior" — DISTINCT ON по ВСЕЙ listing_source_snapshots
-- (~2.6-2.8M строк) обычным JOIN. Планировщик оценивал "today" в 1 строку (свежевставленные
-- в той же транзакции строки ANALYZE ещё не видел) → выбирал Nested Loop БЕЗ Materialize на
-- внутренней стороне → DISTINCT ON пересчитывался заново на КАЖДУЮ из ~80-140k реальных
-- строк today. EXPLAIN на проде: cost внутреннего подзапроса ~298 627. Запрос переписан на
-- JOIN LATERAL (per-row indexed point-lookup, cost ~4.4/строку) — устраняет корневую причину.
--
-- ЭТА миграция — ДОПОЛНИТЕЛЬНЫЙ предохранитель (issue #2607 п.4): budget_sec в default_params
-- теперь читается snapshot_listing_sources() и выставляется как SET LOCAL statement_timeout
-- (per-transaction, НЕ server/role-level — тот отдельный вопрос issue #2607 п.2, требует
-- согласования, здесь намеренно не трогается). Если план когда-нибудь снова разрегрессирует,
-- прогон честно упадёт в mark_failed вместо того чтобы висеть сутками.
--
-- 900 сек (15 мин) — по образцу migration 110 (geocode_missing_listings budget_sec=1800),
-- с большим запасом над ожидаемым временем выполнения после LATERAL-фикса (секунды) и
-- далеко от 6h zombie-порога и от окна 01:00-02:00 UTC (052/079).
--
-- ЗАВИСИМОСТИ: 079_listing_source_history.sql (создаёт scrape_schedules row, source=
-- 'listing_source_snapshot', default_params='{}'::jsonb).
-- Idempotent: UPDATE ... || jsonb-merge — безопасно перезапускать (всегда приводит
-- default_params.budget_sec к 900 независимо от предыдущего состояния).
-- Apply after: 201_purge_dead_mobileproxy_proxies.sql
BEGIN;
UPDATE scrape_schedules
SET default_params = COALESCE(default_params, '{}'::jsonb) || '{"budget_sec": 900}'::jsonb
WHERE source = 'listing_source_snapshot';
COMMIT;

View file

@ -0,0 +1,52 @@
-- Инвалидация записей geocode_cache, отравленных багом матчинга литеры дома.
--
-- Контекст: `_cadastral_house_match` (app/services/geocoder.py) сравнивал дом
-- только по ЦИФРАМ — литера была опциональна в WHERE и участвовала лишь как
-- tie-break в ORDER BY. Итог, двусторонний:
-- • «Новгородцевой 13б» → «дом 13» (запрос с литерой → дом без неё)
-- • «Малышева 30» → «д. 30-б» (запрос без литеры → дом с литерой)
-- Оба результата писались с provider-тиром локального реестра и
-- `confidence='exact'`, TTL 90 дней → пользователь получал оценку ЧУЖОГО
-- здания, помеченную как точная, и она залипала в кэше.
--
-- Здесь удаляем только ПОДОЗРИТЕЛЬНЫЕ строки, а не весь кэш: полная очистка
-- сожгла бы квоту внешних геокодеров (DaData 10k/день) на ре-резолв заведомо
-- корректных адресов. Удалённое будет пересчитано лениво, при следующем
-- запросе, уже исправленным матчером.
--
-- Идемпотентность: чистый DELETE по предикату. Повторный прогон удалит 0 строк
-- (первый уже вычистил всё подходящее), новых строк с такой же патологией
-- исправленный код не создаёт. Безопасно для strict exit-1 авто-применения.
BEGIN;
DELETE FROM geocode_cache
WHERE
-- (a) В самом запросе была литера дома: под старым матчером такой адрес мог
-- уехать в дом без литеры / с чужой литерой. Смотрим на ХВОСТ адреса
-- (дом пишется последним) — иначе порядковые части улиц («1-я
-- Пятилетки», «4-й Кианитовый») ложно читались бы как литера.
-- `|city=` — суффикс ключа кэша (см. geocoder._cache_key), отрезаем.
split_part(address_normalized, '|city=', 1) ~* '[0-9]+\s*-?\s*[а-яё]\s*$'
-- (b) Обратное направление: в запросе литеры НЕ было, а закэширован адрес
-- реестра, у которого номер дома С литерой («Малышева 30» → «д. 30-б»).
-- Извлечение номера — то же выражение, что и в исправленном матчере
-- (geocoder._SQL_HOUSE_TOKEN_RE): маркер только с начала слова, литера
-- — одиночная кириллическая буква, «58/3»/«64-2» литерой не считаются.
OR (
split_part(address_normalized, '|city=', 1) !~* '[0-9]+\s*-?\s*[а-яё]\s*$'
AND full_address IS NOT NULL
AND regexp_replace(
regexp_replace(
lower(COALESCE((regexp_match(
full_address,
'\m(?:дом|д\.?|строение|стр\.?|сооружение|соор\.?)\s*'
|| '([0-9]+(?:\s*[-/]\s*[0-9]+)?(?:\s*-?\s*[а-яё](?![а-яё]))?)',
'i'))[1], '')),
'\s', '', 'g'),
'-([а-яё])', '\1', 'g'
) ~ '[а-яё]$'
);
COMMIT;

View file

@ -0,0 +1,87 @@
-- 204_cian_oblast_sweeps_secondary.sql
-- Включить сбор вторички Циана по 4 областным city-sweep'ам (Свердловская обл.,
-- миграция 179 — nizhniy_tagil/kamensk_uralskiy/pervouralsk/serov).
--
-- ПРОБЛЕМА: _job_cian_city_sweep (scraper_kit.orchestration.scheduler:570) читает
-- newbuilding_only = bool(default_params.get("newbuilding_only", True)) — дефолт True.
-- run_cian_city_sweep (pipeline.py:2436) фильтрует SERP-результат на
-- listing_segment == "novostroyki" ДО save_listings, вторичку отбрасывает
-- (counters.lots_dropped_secondary).
--
-- Дефолт осмыслен для ЕКБ: docstring run_cian_city_sweep прямо говорит, что
-- вторичку авторитетно собирает run_cian_full_load (exhaustive региональный сбор).
-- НО run_cian_full_load (pipeline.py:2790) хардкодит city=EKATERINBURG_CITY_NAME —
-- параметра города там нет вообще, область не покрывает. Итог: областную вторичку
-- Циана не собирает НИКТО (городская развёртка её выбрасывает, full_load туда не
-- ходит) — областные schedule'ы склонированы с ЕКБ (миграция 179) и унаследовали
-- предположение, которое для них неверно.
--
-- Прод-счётчики (scrape_runs.counters, последние runs на 2026-08-02) подтверждают:
-- pervouralsk 55 увидено, 53 выброшено (сохранено 2)
-- kamensk_uralskiy 113 увидено, 108 выброшено (сохранено 5)
-- nizhniy_tagil 184 увидено, 176 выброшено (сохранено 3)
-- verkhnyaya_pyshma 38 увидено, 16 выброшено (сохранено 9) -- см. EXCLUSION ниже
--
-- FIX: newbuilding_only: false для ЧЕТЫРЁХ областных source'ов. cian_city_sweep (ЕКБ,
-- БЕЗ суффикса города) НЕ трогаем — для него дефолт корректен (вторичку ЕКБ
-- собирает cian_full_load), включение дало бы дублирующую нагрузку на источник.
--
-- !!! EXCLUSION: cian_city_sweep_verkhnyaya_pyshma НЕ включён в эту миграцию !!!
-- Верхняя Пышма физически ~15 км от центра Екатеринбурга — geo-проверка по
-- ST_DWithin (координаты listings vs центр города) показала, что 5 из 22 (23%)
-- текущих cian-строк с меткой city="Верхняя Пышма" физически лежат в 15 км от
-- центра ЕКБ, т.е. это загрязнённая городская разметка (sweep по anchor'у В.Пышмы
-- зацепляет краевые екатеринбургские объявления и подписывает их не тем городом).
-- Колонка listings.city — money-critical: её читает asking_to_sold_ratio.py
-- (city-скоуп ASKING vs SOLD стороны, #2583 H2) — неверная метка двигает выкупные
-- цены. При newbuilding_only=false объём cian-строк под меткой В.Пышма вырастет с
-- 22 до нескольких сотен (те же ~38 увидено/16 выброшено за один run, помноженные
-- на число прогонов) — 23%-загрязнение умножилось бы пропорционально.
-- nizhniy_tagil/kamensk_uralskiy/pervouralsk/serov — загрязнение по той же
-- geo-проверке НУЛЕВОЕ (0 из 8/5/2 соответственно физически в ЕКБ) — включать
-- безопасно. cian_city_sweep_verkhnyaya_pyshma будет включён ОТДЕЛЬНОЙ миграцией
-- после починки городской разметки sweep'а (правится параллельно) — НЕ забыт.
--
-- Нагрузка на источник (см. PR description / vault fix-запись для полного разбора):
-- fetch_around_multi_room (providers/cian/serp.py:209) НЕ принимает newbuilding_only/
-- secondary_only — SERP-фаза (все rooms×pages) выполняется ОДИНАКОВО независимо от
-- этого флага. Фильтр в pipeline.py:2436 применяется ПОСЛЕ фетча, ДО save — чисто
-- in-memory отсечение уже оплаченных запросов. HTTP-нагрузка на cian.ru НЕ меняется;
-- меняется только объём save_listings (DB-writes) — на порядок больше СОХРАНЯЕМЫХ
-- строк, не больше запросов к источнику. detail_top_n=10 detail-фетчей тоже не растёт
-- (LIMIT :lim константен, лишь конкурирующий пул кандидатов расширяется).
--
-- Дубли: run_cian_full_load всегда region_code=EKB (city_region_id=4743 через
-- CianScraper() без city_slug), областные sweeps используют CITY_LOCATIONS[<slug>]
-- .cian_region_id (4886/4781/4925/4982 — все != 4743) — SERP-запросы физически
-- разных региональных выдач. dedup_hash = sha256(source|source_id) — глобальный
-- Cian offer_id, ON CONFLICT (dedup_hash) DO UPDATE — даже в теоретическом edge-case
-- совпадения upsert НЕ создаёт дубль-строку.
--
-- listing_segment: providers/cian/serp.py:892 — вторичка получает
-- listing_segment = "vtorichka" (НЕ NULL) → проходит фильтр
-- "listing_segment IS NULL OR listing_segment = 'vtorichka'" в asking_to_sold_ratio.py
-- и buildings_query.py — новые лоты попадут в оценку без доп. кода.
--
-- Мердж jsonb (COALESCE || ...), НЕ перезапись — сохраняет city/radius_m/detail_top_n/
-- enrich_houses/pages_per_anchor/request_delay_sec (см. 179_scrape_schedules_seed_oblast_city_sweeps.sql
-- за текущими прод-значениями). Idempotent: повторный прогон ставит то же значение.
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица), 179 (seed этих source'ов).
-- deploy order: только миграция — код scheduler.py/pipeline.py НЕ меняется в этом PR,
-- дефолт newbuilding_only=True в коде остаётся (правильный fallback для будущих
-- source'ов без явного default_params override).
BEGIN;
UPDATE scrape_schedules
SET default_params = COALESCE(default_params, '{}'::jsonb)
|| '{"newbuilding_only": false}'::jsonb
WHERE source IN (
'cian_city_sweep_nizhniy_tagil',
'cian_city_sweep_kamensk_uralskiy',
'cian_city_sweep_pervouralsk',
'cian_city_sweep_serov'
);
COMMIT;

View file

@ -0,0 +1,222 @@
-- 205_sales_vs_listings_city_filter.sql
-- Purpose: #2583 H4 — street_sales_vs_listings() (067) строит пары «ДКП-сделка ↔
-- listing» через LEFT JOIN, где условие матчинга — ТОЛЬКО street_pattern (ILIKE) +
-- rooms + area ±tolerance + дата. Городской корреляции нет вообще: deals.address /
-- listings.address хранят "<Город>, <Улица>" (Росреестр агрегирует до улицы, без
-- дома), а street_pattern = голое имя улицы («Ленина», «Красноармейская»,
-- «Советская» — десятки одноимённых улиц в разных городах обл.66). ILIKE
-- '%Ленина%' матчит "Нижний Тагил, Ленина" И "Екатеринбург, Ленина" одинаково —
-- пара выбирается ближайшей по дате, город игнорируется.
--
-- Прод-репро (см. PR-описание): street='Ленина', rooms=2, area≈44.3м², defaults —
-- 352 total pairs по всем городам, 244 с listing-match, из них 119 (49%) явно
-- чужого города (deal.city <> listing.city, обе стороны известны) + 122 (50%) с
-- listing.city IS NULL (Циан/Домклик/Яндекс, город неизвестен — потенциально тоже
-- чужой). Для Нижнего Тагила конкретно: 8 сделок получили match, 4 — явно чужой
-- город (ЕКБ и др.). median_discount_pct на смеси городов уезжает в -63.6%
-- (в audit-заходе см. #2583 -59%) — «медианный торг» на витрине читается как
-- реальная рыночная скидка по улице пользователя, а на деле мешает рынки разной
-- ценовой полки.
--
-- Соседний эндпоинт /street-deals (trade_in.py:1654) городской скоуп уже получил
-- (комментарий #C1 там же) — тот же паттерн переносим сюда: город резолвится
-- ОДИН раз в Python через _resolve_target_city(address) (estimator.py:1350,
-- словарь ~30 городов обл.66 вкл. ЕКБ + sweep-города) и передаётся как ОДИН
-- bind-параметр в TVF, который применяет его к ОБЕИМ сторонам JOIN:
-- - deals.city заполнена на 100% (проверено на проде) → строгое равенство
-- LOWER(d.city) = LOWER(p_target_city).
-- - listings.city заполнена ЧАСТИЧНО (прод-замер: avito 63%, yandex 19%,
-- cian 4.6%, domklik 0.6%, n1 0%) → предикат терпим к NULL, симметрично
-- паттерну asking_to_sold_ratio.py (#2583 H2, PR #2617):
-- (l.city IS NULL OR LOWER(l.city) = LOWER(p_target_city)).
-- Строгий `l.city = p_target_city` без IS NULL выбросил бы ~80-95% listings
-- для источников кроме avito — по мере роста покрытия колонки предикат сам
-- ужесточается без правок кода.
-- - p_target_city IS NULL (адрес вне словаря SVERDLOVSK_OBLAST_CITIES, редкий
-- мелкий н.п. области — тот же неполный список, что в известной находке H1)
-- → фильтр не применяется НИ на одной стороне, текущее (pre-fix) поведение
-- сохраняется как fallback. Осознанно, не побочный эффект: /street-deals уже
-- принял этот компромисс для того же словаря городов — расхождение в
-- поведении между двумя виджетами на одной странице (для одного и того же
-- адреса) было бы хуже, чем редкий edge-case без фильтра. H1 — известная
-- отдельная находка (fix отдельным PR), здесь её не трогаем.
--
-- Signature change: p_target_city добавлен СЕДЬМЫМ параметром с DEFAULT NULL —
-- обратная совместимость с любым caller'ом, который вызывает функцию 6
-- позиционными аргументами (сейчас единственный caller — trade_in.py:1865,
-- обновляется в этом же PR). CREATE OR REPLACE FUNCTION с ДОБАВЛЕННЫМ параметром
-- технически создаёт НОВУЮ перегрузку (Postgres матчит функции по списку типов
-- аргументов) — поэтому старую 6-параметровую сигнатуру дропаем явно ПЕРЕД
-- CREATE OR REPLACE, чтобы не остались висеть два оверлоада одной функции.
-- DROP FUNCTION IF EXISTS с 6-арг сигнатурой идемпотентен: при повторном
-- прогоне (когда функция уже 7-арг) просто no-op, ошибки не будет.
--
-- Grep-проверка вызывающих (2026-08): единственный caller —
-- app/api/v1/trade_in.py:1865 (/sales-vs-listings). Convenience view
-- v_street_sales_vs_listings из 067 уже дропнута в 068 (была без street-match,
-- генерила 50k spurious pairs) — фиксить нечего, объекта не существует.
--
-- Deploy order: после 204. Второй caller (Python) обновляется в том же PR —
-- миграция должна применяться ДО деплоя backend-кода (стандартный SQL-first
-- порядок), но т.к. новый параметр DEFAULT NULL — старый код (без city) продолжит
-- работать без ошибок между миграцией и деплоем кода (не критичный порядок, но
-- соблюдаем канон).
BEGIN;
DROP FUNCTION IF EXISTS street_sales_vs_listings(text, numeric, integer, integer, numeric, integer);
CREATE OR REPLACE FUNCTION street_sales_vs_listings(
p_street_pattern text,
p_area_m2 numeric,
p_rooms integer,
p_window_days integer DEFAULT 180,
p_area_tolerance numeric DEFAULT 0.15,
p_period_months integer DEFAULT 24,
p_target_city text DEFAULT NULL
)
RETURNS TABLE (
deal_id bigint,
deal_date date,
deal_price_rub bigint,
deal_price_per_m2 integer,
deal_area_m2 numeric,
deal_rooms integer,
deal_floor integer,
deal_address text,
listing_id bigint,
listing_source text,
listing_source_url text,
listing_date date,
listing_price_rub bigint,
listing_price_per_m2 integer,
listing_area_m2 numeric,
days_listing_to_deal integer,
discount_pct numeric
)
LANGUAGE sql
STABLE
AS $$
WITH window_deals AS (
-- Сделки в улице + период. Фильтр по rooms + area + (#2583 H4) city.
SELECT
d.id AS deal_id,
d.deal_date AS deal_date,
d.price_rub AS deal_price_rub,
d.price_per_m2 AS deal_price_per_m2,
d.area_m2 AS deal_area_m2,
d.rooms AS deal_rooms,
d.floor AS deal_floor,
d.address AS deal_address
FROM deals d
WHERE d.source = 'rosreestr'
AND d.address ILIKE p_street_pattern
AND d.rooms = p_rooms
AND d.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance)
AND p_area_m2 * (1.0 + p_area_tolerance)
AND d.deal_date > NOW() - (p_period_months || ' months')::interval
AND d.price_rub > 0
-- #2583 H4: deals.city заполнена на 100% — строгое равенство.
-- NULL p_target_city (город вне словаря) → фильтр не применяется.
AND (p_target_city IS NULL OR LOWER(d.city) = LOWER(p_target_city))
),
window_listings AS (
-- Кандидаты-listings на той же улице, rooms exact, area ±tolerance,
-- (#2583 H4) тот же город что deals-сторона.
SELECT
l.id AS listing_id,
l.source AS listing_source,
l.source_url AS listing_source_url,
l.listing_date AS listing_date,
l.price_rub AS listing_price_rub,
l.price_per_m2 AS listing_price_per_m2,
l.area_m2 AS listing_area_m2,
l.rooms AS listing_rooms,
COALESCE(l.listing_date, l.scraped_at::date) AS listing_event_date
FROM listings l
WHERE l.address ILIKE p_street_pattern
AND l.rooms = p_rooms
AND l.area_m2 BETWEEN p_area_m2 * (1.0 - p_area_tolerance)
AND p_area_m2 * (1.0 + p_area_tolerance)
AND l.price_rub > 0
AND COALESCE(l.listing_date, l.scraped_at::date)
> NOW() - ((p_period_months + 6) || ' months')::interval
-- #2583 H4: listings.city заполнена ЧАСТИЧНО (прод: avito 63%,
-- yandex 19%, cian 4.6%, domklik 0.6%, n1 0%) — NULL считается "своим"
-- (симметрично asking_to_sold_ratio.py #2583 H2), иначе строгий
-- фильтр выбросил бы почти все listings кроме avito.
AND (p_target_city IS NULL OR l.city IS NULL OR LOWER(l.city) = LOWER(p_target_city))
),
paired AS (
-- LEFT JOIN: сохраняем все сделки даже если нет listing match.
-- Для каждой сделки выбираем listing с listing_date ближайший
-- к deal_date (предпочтительно перед сделкой).
SELECT DISTINCT ON (wd.deal_id)
wd.deal_id,
wd.deal_date,
wd.deal_price_rub,
wd.deal_price_per_m2,
wd.deal_area_m2,
wd.deal_rooms,
wd.deal_floor,
wd.deal_address,
wl.listing_id,
wl.listing_source,
wl.listing_source_url,
wl.listing_date,
wl.listing_price_rub,
wl.listing_price_per_m2,
wl.listing_area_m2,
(wd.deal_date - wl.listing_event_date)::integer AS days_listing_to_deal,
CASE
WHEN wl.listing_price_rub IS NOT NULL AND wl.listing_price_rub > 0
THEN ROUND(
(wd.deal_price_rub - wl.listing_price_rub)::numeric
/ wl.listing_price_rub * 100,
2
)
ELSE NULL
END AS discount_pct
FROM window_deals wd
LEFT JOIN window_listings wl
ON wl.listing_event_date
BETWEEN (wd.deal_date - (p_window_days || ' days')::interval)::date
AND (wd.deal_date + interval '30 days')::date
ORDER BY
wd.deal_id,
-- prefer listing event дата перед сделкой и ближе к ней
CASE WHEN wl.listing_event_date IS NULL THEN 1 ELSE 0 END,
CASE WHEN wl.listing_event_date <= wd.deal_date THEN 0 ELSE 1 END,
ABS((wd.deal_date - wl.listing_event_date))
)
SELECT
deal_id,
deal_date,
deal_price_rub,
deal_price_per_m2,
deal_area_m2,
deal_rooms,
deal_floor,
deal_address,
listing_id,
listing_source,
listing_source_url,
listing_date,
listing_price_rub,
listing_price_per_m2,
listing_area_m2,
days_listing_to_deal,
discount_pct
FROM paired
ORDER BY deal_date DESC;
$$;
COMMENT ON FUNCTION street_sales_vs_listings(text, numeric, integer, integer, numeric, integer, text) IS
'Pairs (ДКП-сделка, listing) для улицы. PR K / issue #564 Foundation Phase 1, '
'city-filter #2583 H4 (миграция 205). Per-street matching: address ILIKE, area '
'±tolerance, rooms exact, window_days до даты сделки (+30д grace), city-scope '
'(p_target_city, deals строго / listings терпимо к NULL). Возвращает LEFT '
'JOIN — сделки без listing match имеют listing_* = NULL. discount_pct = '
'(deal - listing) / listing * 100.';
COMMIT;

View file

@ -0,0 +1,136 @@
-- 206_scrape_schedules_cut_wasteful_load.sql
-- Срезать бесполезную нагрузку на источники (=нагрузку на единственный живой общий
-- прокси: scrape_proxies enabled=true AND provider_affinity='any' → ровно 1 узел
-- asocks-mobile-2 на момент этой миграции; asocks-residential-1 закреплён отдельно
-- за domclick). Только UPDATE scrape_schedules.default_params / .enabled — код
-- скраперов НЕ меняется. Все цифры ниже — прод, scrape_runs.counters, 30 дней
-- (2026-08-02), проверено read-only перед написанием файла.
--
-- 1) cian_full_load — 110.0 ч из 30-дневного окна (29 runs), доминирующий потребитель
-- прокси-времени в системе. Текущий default_params подтверждён на проде:
-- concurrency=5, request_delay_sec=4.0 → эффективный интервал 4.0/5=0.8с между
-- запросами. 30-дневные counters: unique_fetched=44011, saved_inserted=1682,
-- saved_updated=32862 — сигнал реальный (НЕ нулевой выхлоп), но темп избыточен
-- относительно ценности. concurrency 5→2, request_delay_sec 4.0→6.0 даёт
-- эффективный интервал 6.0/2=3.0с (в 3.75 раза медленнее); interval_days 1→3
-- (daily → раз в 3 дня) сокращает число прогонов в 3 раза. Совместно — падение
-- запросов к Циану на порядок, в духе оценки задачи (~11-16 тыс./сутки → ~1-1.5 тыс.).
-- Потеря свежести: ~1095 price-update/сутки в среднем откладываются на срок до
-- 2 суток между прогонами — не исчезают, детектируются позже; оценщик использует
-- LISTINGS_FRESH_DAYS=14, лаг в 1-2 дня внутри этого окна некритичен.
BEGIN;
UPDATE scrape_schedules
SET default_params = COALESCE(default_params, '{}'::jsonb)
|| '{"concurrency": 2, "request_delay_sec": 6.0, "interval_days": 3}'::jsonb
WHERE source = 'cian_full_load';
-- 2) avito_full_load — request_delay_sec=1.0, самая агрессивная настройка в конфиге
-- (подтверждено). 30-дневные scrape_runs.status: 20/30 banned мгновенно
-- (done_buckets=[], 0 fetched), 9/30 failed (0 fetched), 1/30 done (357 inserted /
-- 3397 updated). avito_full_load_exhaustive — тот же traversal, но УЖЕ на
-- request_delay_sec=7.0 + interval_days=7 — тем не менее 4/4 runs banned за 30д:
-- сама скорость запроса не единственная причина бана (вероятно паттерн полного
-- обхода всех room×price buckets), но замедление всё равно валидно снижает
-- бесполезную нагрузку на прокси при каждой попытке. request_delay_sec 1.0→7.0
-- (уравнено с городскими развёртками) и interval_days 1→7 (недельный такт, решение
-- по данным — см. § "рассмотри и перевод на недельный такт, но реши по данным":
-- ежедневный прогон 29 из последних 30 раз не даёт НИ ОДНОЙ новой/обновлённой
-- строки, недельный такт не теряет свежести, которой и так нет).
UPDATE scrape_schedules
SET default_params = COALESCE(default_params, '{}'::jsonb)
|| '{"request_delay_sec": 7.0, "interval_days": 7}'::jsonb
WHERE source = 'avito_full_load';
-- 3a) yandex_address_backfill (отдельная джоба) — 30-дневные counters: checked=6000,
-- saved=4 (0.07%), errors=406. Ненулевой поток (4 записи/мес) — по границам задачи
-- НЕ выключаем полностью, переводим на недельный такт (1→7).
UPDATE scrape_schedules
SET default_params = COALESCE(default_params, '{}'::jsonb)
|| '{"interval_days": 7}'::jsonb
WHERE source = 'yandex_address_backfill';
-- 3b) address-enrich ФАЗА ВНУТРИ yandex_city_sweep (ЕКБ) — run_yandex_city_sweep()
-- принимает enrich_address: bool (orchestration/pipeline.py:1870), scheduler.py:545
-- читает его ИМЕННО из default_params.get("enrich_address", True) — управляется
-- параметром, правка кода НЕ требуется (в отличие от того, если бы флаг был
-- захардкожен — этого на проверке НЕТ, поэтому трогаем только данные).
-- 30-дневные counters ТОЛЬКО для source='yandex_city_sweep' (ЕКБ, без city-суффикса):
-- address_attempted=5829, address_enriched=0, address_failed=40 — фаза полностью
-- впустую. ВАЖНО: 5 областных yandex_city_sweep_<city> за те же 30 дней показывают
-- address_attempted=0 (фаза там и так не тратит запросы — не из-за enrich_address,
-- а потому что WHERE-условие backfill'а — address IS NOT NULL AND NOT ~ ',\s*\d+' —
-- там просто ничего не находит) — их НЕ трогаем, нечего чинить по данным.
UPDATE scrape_schedules
SET default_params = COALESCE(default_params, '{}'::jsonb)
|| '{"enrich_address": false}'::jsonb
WHERE source = 'yandex_city_sweep';
-- 4) house_imv_backfill — 30-дневные counters: checked=1500, saved=44 (2.9%),
-- errors=1301 (87%), skipped=155. Ненулевой поток — НЕ выключаем (граница задачи),
-- втрое снижаем частоту (1→3 дня) до отдельного разбора причины 87%-ошибок —
-- сокращает объём бесполезных попыток пропорционально при сохранении прогресса
-- по валидным 13%.
UPDATE scrape_schedules
SET default_params = COALESCE(default_params, '{}'::jsonb)
|| '{"interval_days": 3}'::jsonb
WHERE source = 'house_imv_backfill';
-- 5) domclick_detail_backfill — 30-дневные counters: attempted=491, enriched=0,
-- failed=431, blocked=60 — 100% впустую (0 обогащений вообще), включая на
-- ДЕДИКЕЙТЕД прокси (scrape_proxies.provider_affinity='domclick',
-- asocks-residential-1) — тот прокси тоже палится в никуда. Полностью выключаем
-- до починки (единственный пункт этой миграции, где нулевой выход подтверждён
-- буквально — enabled=false оправдан границей задачи).
UPDATE scrape_schedules
SET enabled = false
WHERE source = 'domclick_detail_backfill';
-- 6) yandex_newbuilding_sweep — 30/30 runs status=done, но rows_inserted=0 во ВСЕХ
-- (failed_resolve стабильно ~4-5/run, backlog pending растёт 351→367 за 30д —
-- джоба не успевает и не разбирает очередь). interval_days 1→7.
UPDATE scrape_schedules
SET default_params = COALESCE(default_params, '{}'::jsonb)
|| '{"interval_days": 7}'::jsonb
WHERE source = 'yandex_newbuilding_sweep';
-- 7) Областные развёртки (15 job'ов = 5 городов × {avito,cian,yandex}_city_sweep_<city>,
-- см. миграцию 179) — ежедневно → раз в 3 дня. Независимая проверка (НЕ те же цифры,
-- что в задаче — посчитано отдельно по listings_snapshots за последние 14 дней для
-- ~1245 активных объявлений в 5 областных городах): 4 события изменения цены на
-- 2282 снапшот-строки = ~0.023%/сутки — НИЖЕ заявленных в задаче 0.15%/сутки,
-- подтверждает избыточность daily-такта. cian_city_sweep (ЕКБ, БЕЗ суффикса города,
-- id=128) и его newbuilding_only-логику НЕ трогаем (недавно правились, вне периметра
-- этой миграции). Потеря свежести: при трёхдневном такте цена/новый лот в областном
-- городе детектируется с лагом до 2 суток — при ~0.02-0.15%/сутки волатильности и
-- LISTINGS_FRESH_DAYS=14 эффект на оценку пренебрежим.
UPDATE scrape_schedules
SET default_params = COALESCE(default_params, '{}'::jsonb)
|| '{"interval_days": 3}'::jsonb
WHERE source IN (
'avito_city_sweep_nizhniy_tagil',
'avito_city_sweep_kamensk_uralskiy',
'avito_city_sweep_pervouralsk',
'avito_city_sweep_verkhnyaya_pyshma',
'avito_city_sweep_serov',
'cian_city_sweep_nizhniy_tagil',
'cian_city_sweep_kamensk_uralskiy',
'cian_city_sweep_pervouralsk',
'cian_city_sweep_verkhnyaya_pyshma',
'cian_city_sweep_serov',
'yandex_city_sweep_nizhniy_tagil',
'yandex_city_sweep_kamensk_uralskiy',
'yandex_city_sweep_pervouralsk',
'yandex_city_sweep_verkhnyaya_pyshma',
'yandex_city_sweep_serov'
);
-- НЕ тронуто (сознательно, данные не подтвердили действие):
-- avito_detail_backfill (2295 attempted / 494 enriched = 78% брака, но 494
-- обогащения/мес — реальный, не близкий к нулю поток; вне "Предлагаемого набора"
-- задачи, полноценно вне периметра этой миграции).
-- cian_city_sweep (ЕКБ) / newbuilding_only — явный запрет задачи.
-- yandex_city_sweep_<city> (5 областных) enrich_address — address_attempted=0 там,
-- нечего выключать.
COMMIT;

Some files were not shown because too many files have changed in this diff Show more