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:
commit
6820337da0
336 changed files with 45378 additions and 6446 deletions
|
|
@ -30,6 +30,7 @@ jobs:
|
|||
outputs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
browser: ${{ steps.filter.outputs.browser }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
|
|
@ -43,10 +44,23 @@ jobs:
|
|||
# [tool.uv.workspace] меняют реальные зависимости → гейт обязан бежать.
|
||||
- 'tradein-mvp/uv.lock'
|
||||
- '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'
|
||||
frontend:
|
||||
- 'tradein-mvp/frontend/**'
|
||||
- '.forgejo/workflows/ci-tradein.yml'
|
||||
browser:
|
||||
# Сайдкар — сервис ВНЕ uv-воркспейса (tradein-mvp/pyproject.toml
|
||||
# members = backend + packages/*), со своим Dockerfile и без pyproject,
|
||||
# поэтому и фильтр отдельный: backend-гейт его тестов не видел вовсе.
|
||||
- 'tradein-mvp/browser/**'
|
||||
- '.forgejo/workflows/ci-tradein.yml'
|
||||
|
||||
backend-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -89,15 +103,73 @@ jobs:
|
|||
run: uv sync --frozen
|
||||
|
||||
- name: Run pytest (tradein-mvp/backend)
|
||||
# DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает
|
||||
# ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state
|
||||
# leak из другого test-модуля, pre-existing. Второй исторический deselect
|
||||
# (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в
|
||||
# полном прогоне (проверено локально: 2947 passed / 1 failed). Список обязан
|
||||
# совпадать с test-job в deploy-tradein.yml.
|
||||
run: |
|
||||
uv run pytest -q \
|
||||
--deselect "tests/test_search_api.py::test_search_cache_hit"
|
||||
# БЕЗ deselect'ов — сьют гоняется целиком (#2722).
|
||||
#
|
||||
# Здесь два года жил `--deselect tests/test_search_api.py::test_search_cache_hit`
|
||||
# с объяснением «падает ТОЛЬКО в whole-suite ordering, в изоляции проходит —
|
||||
# global-state leak из другого модуля». Объяснение было неверным в обеих
|
||||
# половинах: тест падал и в изоляции тоже (401 vs 200), потому что он —
|
||||
# единственный HTTP-тест в своём файле — ходил в /api/v1/search БЕЗ заголовка
|
||||
# X-Authenticated-User, а RBAC-гард отвечает на такое 401 (ровно то, что
|
||||
# фиксирует tests/test_estimate_idor.py). Причина была в тесте, а не в порядке;
|
||||
# заголовок добавлен, deselect снят, полный прогон зелёный.
|
||||
#
|
||||
# Не добавлять сюда новые deselect'ы: молча выключенный тест — это тот же
|
||||
# класс дефекта, что каталог вне пайплайна (#2722). Тест либо чинится, либо
|
||||
# помечается xfail с причиной В КОДЕ, где её видно рядом с самим тестом.
|
||||
#
|
||||
# NB: в deploy-tradein.yml (post-merge test-job) свой экземпляр этого
|
||||
# deselect'а — он остаётся до #2680, который правит тот файл. Расхождение
|
||||
# безвредно: pre-merge гейт тест гоняет, post-merge просто пропустит зелёный.
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -133,3 +205,9 @@ jobs:
|
|||
- name: Lint (next lint)
|
||||
# Blocking: любая ESLint-ошибка → job RED.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -52,6 +52,14 @@ jobs:
|
|||
backend:
|
||||
- 'backend/**'
|
||||
- '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'
|
||||
frontend:
|
||||
- 'frontend/**'
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ on:
|
|||
- ".forgejo/workflows/deploy.yml"
|
||||
- "data/sql/**"
|
||||
- "ops/glitchtip-auth-forwarder/**"
|
||||
# Bootstrap-SQL (создание БД auth, ALTER ROLE паролем из env) исполняется шагом
|
||||
# деплоя ниже — без этого триггера правка bootstrap-файла молча не доезжала бы
|
||||
# до прода до следующего чужого коммита в backend/.
|
||||
- "ops/db-bootstrap/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
|
@ -320,6 +324,70 @@ jobs:
|
|||
echo "⚠️ GENDESIGN_FDW_PASSWORD not set in backend/.env.runtime — skipping ALTER ROLE for tradein_fdw_reader"
|
||||
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).
|
||||
# Эти services не в GHCR — сборка происходит на VPS на каждом deploy.
|
||||
# Cache-friendly: первый build ~30s, последующие 1-3s если файлы не менялись.
|
||||
|
|
|
|||
38
.forgejo/workflows/perimeter-smoke.yml
Normal file
38
.forgejo/workflows/perimeter-smoke.yml
Normal 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
211
Caddyfile
|
|
@ -11,6 +11,13 @@
|
|||
# Users managed via caddy/users.caddy.snippet (git history = audit trail).
|
||||
# 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.
|
||||
# Without route { }, Caddy executes directives in hard-coded default order
|
||||
# (basic_auth runs before handle), making /health and /preview/* exclusions
|
||||
|
|
@ -70,26 +77,56 @@ gendsgn.ru {
|
|||
# Оба ДО auth-import, иначе ассеты страницы уходят в @tradein (под auth) → 401 → без CSS.
|
||||
@uipreview path /trade-in/ui-preview/* /trade-in/_next/static/*
|
||||
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).
|
||||
import caddy/users.caddy.snippet
|
||||
|
||||
# Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker stack,
|
||||
# подключен через gendesign_shared network. Routes ДО универсального handle
|
||||
# потому что Caddy матчит handle-блоки сверху вниз.
|
||||
# #2558: Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker
|
||||
# stack, подключен через gendesign_shared network. Секция ЦЕЛИКОМ ДО
|
||||
# `import caddy/users.caddy.snippet` ниже — /trade-in имеет собственную
|
||||
# авторизацию (форма входа + opaque session-cookie, #2552; RBAC-проверка
|
||||
# роли внутри tradein-backend, `app/core/rbac.py`), Site Finder basic_auth
|
||||
# ей больше не нужен и не должен применяться (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_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
|
||||
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
|
||||
# префикса basePath /trade-in (Next.js basePath leak).
|
||||
uri strip_prefix /trade-in
|
||||
reverse_proxy tradein-backend:8000 {
|
||||
header_up X-Authenticated-User {http.auth.user.id}
|
||||
# #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-Authenticated-User
|
||||
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
|
||||
# (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL
|
||||
# в адресной строке требует отдельного 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/
|
||||
handle @saleshare {
|
||||
redir /trade-in/sale-share permanent
|
||||
|
|
@ -110,13 +164,17 @@ gendsgn.ru {
|
|||
handle @tradein {
|
||||
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
|
||||
reverse_proxy tradein-frontend:3000 {
|
||||
header_up X-Authenticated-User {http.auth.user.id}
|
||||
# #2213: симметрично с /trade-in/api/* — перезаписываем секрет из env
|
||||
# (стирает клиентский), на случай SSR-forwardʼa фронтом в backend.
|
||||
# См. комментарий над /trade-in/api/* выше — та же логика (явное
|
||||
# удаление вместо Set с пустым {http.auth.user.id}).
|
||||
header_up -X-Authenticated-User
|
||||
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/* {
|
||||
reverse_proxy backend:8000 {
|
||||
header_up X-Authenticated-User {http.auth.user.id}
|
||||
|
|
@ -135,6 +193,129 @@ www.gendsgn.ru {
|
|||
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).
|
||||
# Auto-TLS Let's Encrypt. CORS уже включён на стороне CouchDB через bootstrap
|
||||
# (см. scripts/setup-couchdb.sh). Basic-auth — на стороне CouchDB (admin user).
|
||||
|
|
|
|||
|
|
@ -39,6 +39,39 @@ roles:
|
|||
- "/admin/**"
|
||||
- "/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:
|
||||
# #962 (EPIC18, ТЗ §19): analyst видит ВСЁ (deals, insights, exports,
|
||||
# site-finder, analytics, concept) КРОМЕ admin/data-management.
|
||||
|
|
@ -48,12 +81,28 @@ roles:
|
|||
# для любого role != "admin" → analyst авто-403 на admin-API без доп. кода.
|
||||
# deny ниже драйвит фронтовый RouteGuard (deny_paths из /me) для UI-gating
|
||||
# /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:
|
||||
- "/**"
|
||||
deny:
|
||||
- "/admin/**"
|
||||
- "/api/v1/admin/**"
|
||||
- "/trade-in/api/v1/admin/**"
|
||||
- "/trade-in/sale-share/**"
|
||||
- "/trade-in/api/v1/buildings/**"
|
||||
expired:
|
||||
# Пробный доступ закончился — нет доступа ни к чему. Аккаунт остаётся в
|
||||
# caddy/users.caddy.snippet (basic_auth), чтобы дойти до фронта и увидеть
|
||||
|
|
@ -70,7 +119,8 @@ users:
|
|||
admin: admin
|
||||
kopylov: 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
|
||||
user4: pilot
|
||||
user5: pilot
|
||||
|
|
|
|||
269
backend/app/core/auth_db.py
Normal file
269
backend/app/core/auth_db.py
Normal 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()
|
||||
|
|
@ -1,10 +1,46 @@
|
|||
import os
|
||||
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
|
||||
|
||||
# ── Дефолтные части 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):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
|
@ -371,5 +407,201 @@ class Settings(BaseSettings):
|
|||
# на недоступном сервисе. ENV: DADATA_TIMEOUT_S.
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@
|
|||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import sentry_sdk
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
|
|
@ -41,10 +44,12 @@ from app.api.v1 import (
|
|||
trade_in,
|
||||
users,
|
||||
)
|
||||
from app.core import auth_db
|
||||
from app.core.audit_middleware import audit_log_middleware
|
||||
from app.core.auth import get_role
|
||||
from app.core.config import settings
|
||||
from app.observability.sentry_scrub import scrub_sensitive_query
|
||||
from app.services.auth_session import resolve_session_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -97,6 +102,18 @@ if settings.glitchtip_dsn:
|
|||
|
||||
@asynccontextmanager
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -122,10 +139,212 @@ app.middleware("http")(audit_log_middleware)
|
|||
# 3) /api/v1/admin/* — только role=admin, иначе 403.
|
||||
# Public paths без auth (/health, /docs, /openapi.json) пропускаем без проверки —
|
||||
# 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/")
|
||||
_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")
|
||||
async def rbac_guard(
|
||||
request: Request,
|
||||
|
|
@ -134,6 +353,17 @@ async def rbac_guard(
|
|||
# Test-mode bypass: pytest бьёт по app мимо Caddy → нет X-Authenticated-User.
|
||||
# СТРОГО gated на settings.testing (default False) — прод RBAC не затронут.
|
||||
# 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:
|
||||
return await call_next(request)
|
||||
|
||||
|
|
@ -141,8 +371,42 @@ async def rbac_guard(
|
|||
if path in _PUBLIC_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
username = request.headers.get("X-Authenticated-User")
|
||||
if not username:
|
||||
# Внешний `if` по режиму — не дубль проверки внутри resolve_session_token(), а
|
||||
# гарантия инварианта «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
|
||||
# или прокси-фронт без header_up. 401 точнее чем 403 — "сначала
|
||||
# аутентифицируйся".
|
||||
|
|
@ -150,12 +414,23 @@ async def rbac_guard(
|
|||
status_code=401,
|
||||
content={"detail": "no authenticated user (Caddy basic_auth required)"},
|
||||
)
|
||||
username = header_user
|
||||
|
||||
try:
|
||||
role = get_role(username)
|
||||
except KeyError:
|
||||
# Юзер в Caddy basic_auth, но не в roles.yaml → 403 на ВСЁ.
|
||||
# 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)
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
|
|
|
|||
256
backend/app/services/auth_session.py
Normal file
256
backend/app/services/auth_session.py
Normal 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)
|
||||
182
backend/tests/sql/test_auth_sql_migrations.py
Normal file
182
backend/tests/sql/test_auth_sql_migrations.py
Normal 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."
|
||||
497
backend/tests/test_auth_db.py
Normal file
497
backend/tests/test_auth_db.py
Normal 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"]
|
||||
861
backend/tests/test_auth_session_guard.py
Normal file
861
backend/tests/test_auth_session_guard.py
Normal 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
|
||||
305
backend/tests/test_auth_session_service.py
Normal file
305
backend/tests/test_auth_session_service.py
Normal 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)
|
||||
|
|
@ -42,11 +42,26 @@ def _reset_auth_cache() -> None:
|
|||
# Test app — копия rbac_guard из app/main.py, чтобы не подтягивать тяжёлые
|
||||
# импорты (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/")
|
||||
_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:
|
||||
|
|
@ -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:
|
||||
"""Ловит рассинхрон auth/roles.yaml с ожиданиями теста: roles.yaml лежит вне
|
||||
`backend/**`, поэтому правка ролей не попадает в paths-filter CI и такой
|
||||
рассинхрон CI молча пропускает (так и случилось с user2 → expired)."""
|
||||
assert auth_mod.get_role("admin") == "admin"
|
||||
assert auth_mod.get_role("kopylov") == "pilot"
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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. '
|
||||
'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;
|
||||
|
|
|
|||
22
data/sql/188_regrant_quarter_price_index_fdw.sql
Normal file
22
data/sql/188_regrant_quarter_price_index_fdw.sql
Normal 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;
|
||||
123
data/sql/auth/001_identity_schema.sql
Normal file
123
data/sql/auth/001_identity_schema.sql
Normal 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;
|
||||
82
data/sql/auth/002_auth_app_role.sql
Normal file
82
data/sql/auth/002_auth_app_role.sql
Normal 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;
|
||||
111
data/sql/auth/003_users_seed.sql
Normal file
111
data/sql/auth/003_users_seed.sql
Normal 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;
|
||||
408
data/sql/auth/004_users_roles_and_access_state.sql
Normal file
408
data/sql/auth/004_users_roles_and_access_state.sql
Normal 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;
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
| **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) |
|
||||
| **`/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 стек |
|
||||
| **`caddy/users.caddy.snippet`** (in git) | bcrypt-хеши basic_auth пилотных юзеров | ✅ да (хеши, не plaintext) | Caddy |
|
||||
| **Obsidian vault `meta/00_credentials.md`** | реестр **значений** всех секретов + audit-log ротаций | ❌ (вне репо) | Anton |
|
||||
|
|
@ -62,6 +62,7 @@
|
|||
| `POSTGRES_PASSWORD` | `.env` | Пароль роли `gendesign` (PostGIS 16) | **E** (DB password) |
|
||||
| `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** |
|
||||
| `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** |
|
||||
| `GLITCHTIP_DSN` | `backend/.env.runtime` | Backend GlitchTip DSN (перезаписывается deploy из `GLITCHTIP_BACKEND_DSN`) | **C** |
|
||||
| `GLITCHTIP_DB_PASS` | `.env` | Пароль БД GlitchTip-стека | **E** |
|
||||
|
|
@ -76,7 +77,6 @@
|
|||
|---|---|---|
|
||||
| `TRADEIN_POSTGRES_PASSWORD` / `TRADEIN_POSTGRES_USER` | Пароль/юзер БД `tradein` | **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** |
|
||||
| `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** |
|
||||
|
|
@ -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/**`.
|
||||
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-комментарии).
|
||||
|
||||
1. Перевыпустить/ротировать ключ в кабинете провайдера (Объектив / OpenAI / Yandex Cloud / DaData / Cian-аккаунт).
|
||||
1. Перевыпустить/ротировать ключ в кабинете провайдера (Объектив / OpenAI / DaData / Cian-аккаунт).
|
||||
2. Где живёт:
|
||||
- `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).
|
||||
4. Vault entry.
|
||||
|
||||
|
|
|
|||
67
ops/db-bootstrap/create_auth_db.sql
Normal file
67
ops/db-bootstrap/create_auth_db.sql
Normal 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 живёт внутри этой же БД).';
|
||||
50
ops/db-bootstrap/set_auth_app_password.sql
Normal file
50
ops/db-bootstrap/set_auth_app_password.sql
Normal 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
|
||||
100
scripts/smoke-mera-perimeter.sh
Normal file
100
scripts/smoke-mera-perimeter.sh
Normal 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"
|
||||
|
|
@ -6,12 +6,6 @@ DATABASE_URL=postgresql+psycopg://tradein:tradein@postgres:5432/tradein
|
|||
CORS_ORIGINS=["http://localhost:8080","http://localhost:3000"]
|
||||
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).
|
||||
# Возвращает canonical-форму, kadastr_num, ФИАС, координаты, ближайшее метро.
|
||||
# Demo tier: 100 req/день — хватит для тестов и low-traffic prod.
|
||||
|
|
|
|||
|
|
@ -57,8 +57,7 @@ import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment
|
|||
shell-скриптом deploy через `source .env.runtime` перед `compose up`.
|
||||
2. `/opt/gendesign/tradein-mvp/backend/.env.runtime` — переменные внутри
|
||||
контейнера `tradein-backend` (читаются через `env_file:` в compose). Сюда
|
||||
попадают `YANDEX_GEOCODER_API_KEY`, `COOKIE_ENCRYPTION_KEY` —
|
||||
всё, что нужно scripts/backfill_house_coords.py и application code внутри
|
||||
попадают `COOKIE_ENCRYPTION_KEY` и остальные application-секреты внутри
|
||||
контейнера.
|
||||
|
||||
```bash
|
||||
|
|
@ -66,7 +65,6 @@ import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment
|
|||
TRADEIN_POSTGRES_USER=tradein
|
||||
TRADEIN_POSTGRES_PASSWORD=<сгенерировать openssl rand -hex 32>
|
||||
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
|
||||
YANDEX_GEOCODER_API_KEY= # пусто пока, Nominatim fallback работает
|
||||
|
||||
# Encryption key for Cian session cookies (pgp_sym_encrypt / Stage 9 Calculator).
|
||||
# Empty = Valuation Calculator scraper disabled + /api/v1/cookies/upload returns 503.
|
||||
|
|
@ -77,10 +75,9 @@ COOKIE_ENCRYPTION_KEY=<64-char hex>
|
|||
|
||||
```bash
|
||||
# /opt/gendesign/tradein-mvp/backend/.env.runtime — те же ключи которые
|
||||
# читаются ВНУТРИ container'а (scripts/backfill_house_coords.py, app/*).
|
||||
# читаются ВНУТРИ container'а (app/*, scripts/*.py).
|
||||
# Может быть симлинком на ../.env.runtime если переменные совпадают:
|
||||
# ln -s ../.env.runtime /opt/gendesign/tradein-mvp/backend/.env.runtime
|
||||
YANDEX_GEOCODER_API_KEY=<key или пусто>
|
||||
COOKIE_ENCRYPTION_KEY=<64-char hex>
|
||||
GENDESIGN_FDW_PASSWORD=<password или пусто>
|
||||
GLITCHTIP_DSN=<dsn или пусто>
|
||||
|
|
@ -200,7 +197,6 @@ cat > tradein-mvp/.env.runtime <<EOF
|
|||
TRADEIN_POSTGRES_USER=tradein
|
||||
TRADEIN_POSTGRES_PASSWORD=$(openssl rand -hex 32)
|
||||
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
|
||||
YANDEX_GEOCODER_API_KEY=
|
||||
EOF
|
||||
chmod 600 tradein-mvp/.env.runtime
|
||||
|
||||
|
|
|
|||
|
|
@ -70,8 +70,11 @@ from app.core.db import SessionLocal, get_db
|
|||
from app.schemas.trade_in import ScheduleConfig, ScheduleConfigUpdate
|
||||
from app.services import cian_session as cian_session_svc
|
||||
from app.services import domclick_session as domclick_session_svc
|
||||
from app.services import proxy_rotation as proxy_rotation_svc
|
||||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.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.scraper_adapters import (
|
||||
RealEnrichmentJobs,
|
||||
|
|
@ -197,7 +200,9 @@ async def scrape_around(
|
|||
for source in payload.sources:
|
||||
scraper_ctx: AvitoScraper | CianScraper | YandexRealtyScraper
|
||||
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":
|
||||
scraper_ctx = CianScraper(
|
||||
config, delay_provider=get_scraper_delay, proxy_provider=proxy_provider
|
||||
|
|
@ -227,6 +232,8 @@ async def scrape_around(
|
|||
)
|
||||
else:
|
||||
lots = await scraper.fetch_around(payload.lat, payload.lon, payload.radius_m)
|
||||
# run_id нет и не будет (#2701): ручной admin-скрейп строки в scrape_runs не
|
||||
# заводит — снимок пишется вне прогона, поле честно остаётся NULL.
|
||||
inserted, updated = save_listings(
|
||||
db, lots, matcher=matcher, region_code=DEFAULT_REGION_CODE
|
||||
)
|
||||
|
|
@ -246,7 +253,8 @@ def _clean_address_for_geocode(addr: str) -> str:
|
|||
"""Чистим address для геокодера.
|
||||
|
||||
Cian отдаёт «улица Латвийская, 56/3 · р-н Чкаловский» — суффикс ' · ...'
|
||||
мешает Nominatim. Берём часть до ' · '. N1 отдаёт «Репина, 75/2 стр.» — ок.
|
||||
мешает Nominatim. Берём часть до ' · '. Остальные источники такого суффикса
|
||||
не используют — адрес остаётся без изменений.
|
||||
"""
|
||||
main = addr.split(" · ")[0].strip()
|
||||
return main or addr
|
||||
|
|
@ -260,7 +268,7 @@ async def geocode_missing(
|
|||
) -> dict:
|
||||
"""Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
|
||||
|
||||
target=listings (по умолч.) — объявления Cian/N1; target=deals — сделки Росреестра.
|
||||
target=listings (по умолч.) — объявления; target=deals — сделки Росреестра.
|
||||
Чанк-обработка с бюджетом по времени (~240с, заведомо меньше cron
|
||||
`curl -m 320`): за вызов геокодим сколько успеваем, остаток уходит в
|
||||
`remaining`, cron вызывает в цикле пока `remaining` > 0.
|
||||
|
|
@ -269,17 +277,13 @@ async def geocode_missing(
|
|||
адреса не выбираются повторно 7 дней → cron-loop завершается, не зацикливается.
|
||||
geom обновляется автоматически триггером.
|
||||
"""
|
||||
# Доп. фильтр для listings — у Avito/N1 встречаются плейсхолдер-адреса.
|
||||
extra_filter = (
|
||||
"AND address NOT LIKE '%(Avito)%' AND address NOT LIKE '%(N1)%'"
|
||||
if target == "listings"
|
||||
else ""
|
||||
)
|
||||
# Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса.
|
||||
extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else ""
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT id, address
|
||||
SELECT id, address, city
|
||||
FROM {target}
|
||||
WHERE lat IS NULL
|
||||
AND COALESCE(address, '') != ''
|
||||
|
|
@ -313,7 +317,20 @@ async def geocode_missing(
|
|||
)
|
||||
break
|
||||
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:
|
||||
# Помечаем что пробовали — иначе ретрай на каждом cron.
|
||||
db.execute(
|
||||
|
|
@ -1577,8 +1594,22 @@ def update_schedule(
|
|||
"""UPDATE existing schedule (create если не существует, через INSERT ON CONFLICT)."""
|
||||
from app.services.scheduler import compute_next_run_at
|
||||
|
||||
# Compute new next_run_at если window изменился — recompute, иначе keep existing
|
||||
next_at = compute_next_run_at(payload.window_start_hour, payload.window_end_hour)
|
||||
# #2674: такт берётся из default_params — ровно как его читает планировщик
|
||||
# (_claim_run/_defer_next_run_at). Без него compute_next_run_at падал на default=1 и
|
||||
# ЛЮБОЕ сохранение сбивало источник на «завтра»: недельный avito_full_load после
|
||||
# правки окна побежал бы через сутки. На суточных источниках баг был невидим —
|
||||
# для них «завтра» и есть правильный ответ.
|
||||
# None-safe так же, как в scheduler: `"interval_days": null` в jsonb → 1, не TypeError.
|
||||
_interval_days = payload.default_params.get("interval_days")
|
||||
interval_days = max(1, int(_interval_days)) if _interval_days is not None else 1
|
||||
|
||||
# Явно заданный оператором момент уважается как есть (в т.ч. в прошлом — «запустить
|
||||
# сейчас»). Иначе считаем от такта.
|
||||
next_at = payload.next_run_at or compute_next_run_at(
|
||||
payload.window_start_hour,
|
||||
payload.window_end_hour,
|
||||
interval_days=interval_days,
|
||||
)
|
||||
|
||||
row = (
|
||||
db.execute(
|
||||
|
|
@ -1592,7 +1623,22 @@ def update_schedule(
|
|||
window_start_hour = EXCLUDED.window_start_hour,
|
||||
window_end_hour = EXCLUDED.window_end_hour,
|
||||
default_params = EXCLUDED.default_params,
|
||||
next_run_at = EXCLUDED.next_run_at,
|
||||
-- #2674: не двигаем уже назначенный запуск, если двигать не за чем.
|
||||
-- Раньше next_run_at перезаписывался ВСЕГДА, поэтому правка соседнего
|
||||
-- поля (enabled, request_delay_sec в params) заново разыгрывала момент
|
||||
-- внутри окна и сдвигала прогон. Сохраняем существующий только когда он
|
||||
-- ещё в будущем И ни окно, ни такт не менялись — тогда пересчёт дал бы
|
||||
-- то же самое окно, только с другим random-смещением.
|
||||
next_run_at = CASE
|
||||
WHEN CAST(:explicit AS boolean) THEN EXCLUDED.next_run_at
|
||||
WHEN scrape_schedules.next_run_at > NOW()
|
||||
AND scrape_schedules.window_start_hour = EXCLUDED.window_start_hour
|
||||
AND scrape_schedules.window_end_hour = EXCLUDED.window_end_hour
|
||||
AND COALESCE(scrape_schedules.default_params ->> 'interval_days', '1')
|
||||
= COALESCE(EXCLUDED.default_params ->> 'interval_days', '1')
|
||||
THEN scrape_schedules.next_run_at
|
||||
ELSE EXCLUDED.next_run_at
|
||||
END,
|
||||
updated_at = NOW()
|
||||
RETURNING id, source, enabled, window_start_hour, window_end_hour,
|
||||
default_params, last_run_id, last_run_at, next_run_at, updated_at
|
||||
|
|
@ -1605,6 +1651,7 @@ def update_schedule(
|
|||
"we": payload.window_end_hour,
|
||||
"params": json.dumps(payload.default_params, ensure_ascii=False),
|
||||
"next_at": next_at,
|
||||
"explicit": payload.next_run_at is not None,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
|
|
@ -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):
|
||||
"""Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче)."""
|
||||
"""Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче).
|
||||
|
||||
#2674: поля run_type больше нет. Вид прогона в БД всегда был дефолтом
|
||||
'city_sweep' (3244 из 3244 строк, ни одно место кода его не задавало), и
|
||||
таблица подписывала им прогоны, которые никаким sweep не были —
|
||||
proxy_healthcheck, deactivate_stale_*, sber_index_pull. Что именно бежало,
|
||||
называет `source`.
|
||||
"""
|
||||
|
||||
run_id: int
|
||||
source: str
|
||||
run_type: str | None = None
|
||||
status: str
|
||||
# #2674: чинить фильтр без этого флага было бы регрессом. Пока таблица была
|
||||
# пуста на всех вкладках, кнопка отмены не рендерилась ни разу; теперь оператор
|
||||
# видит все 53 источника — и без флага мог бы «отменить» задачу, которая отмену
|
||||
# не опрашивает (см. scrape_runs.honors_cancel): статус соврал бы, а
|
||||
# has_running_run перестал бы держать single-run guard.
|
||||
cancellable: bool = False
|
||||
# #2686: диагноз для status='banned' — 'platform' (площадка заблокировала) или
|
||||
# 'infra' (не отдал наш браузерный сайдкар). Без него оператор видит только
|
||||
# «забанен» и делает вывод «площадка нас палит» на 80% наших же отказов.
|
||||
ban_kind: str | None = None
|
||||
params: dict | None = None
|
||||
counters: dict | None = None
|
||||
total_seen: int | None = None
|
||||
|
|
@ -2194,6 +2258,12 @@ class UnifiedScrapeRunsResponse(BaseModel):
|
|||
rows: list[UnifiedScrapeRunRow]
|
||||
|
||||
|
||||
class ScrapeRunSourcesResponse(BaseModel):
|
||||
"""Список source'ов для фильтра истории прогонов — из данных, не из литерала."""
|
||||
|
||||
sources: list[str]
|
||||
|
||||
|
||||
class BrowserHealth(BaseModel):
|
||||
reachable: bool
|
||||
browsers: dict[str, bool] = Field(default_factory=dict)
|
||||
|
|
@ -2213,33 +2283,23 @@ class ScraperHealthResponse(BaseModel):
|
|||
providers: list[ProviderHealth]
|
||||
|
||||
|
||||
class RotateIpResponse(BaseModel):
|
||||
ok: bool
|
||||
new_ip: str | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
_ROTATABLE_SOURCES = ("avito", "cian", "yandex")
|
||||
|
||||
|
||||
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 {
|
||||
"avito": settings.avito_proxy_url,
|
||||
"avito": settings.scraper_proxy_url,
|
||||
"cian": settings.cian_proxy_url,
|
||||
"yandex": settings.yandex_proxy_url,
|
||||
}.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]:
|
||||
"""Распарсить host/port из proxy URL (схема http(s)://user:pass@host:port)."""
|
||||
if not proxy_url:
|
||||
|
|
@ -2257,7 +2317,11 @@ def list_scrape_runs_unified(
|
|||
db: Annotated[Session, Depends(get_db)],
|
||||
source: Annotated[str | None, Query()] = None,
|
||||
status: Annotated[
|
||||
Literal["done", "running", "banned", "zombie", "failed", "cancelled"] | None, Query()
|
||||
# 'skipped' (#2658) — пропущенное расписание; без него оператор не может
|
||||
# спросить «что сейчас пропускается» (фильтр отдавал 422 на единственной
|
||||
# поверхности, построенной ровно для этого вопроса).
|
||||
Literal["done", "running", "banned", "zombie", "failed", "cancelled", "skipped"] | None,
|
||||
Query(),
|
||||
] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
|
|
@ -2269,7 +2333,7 @@ def list_scrape_runs_unified(
|
|||
|
||||
Query:
|
||||
source — опц. фильтр по source (avito_city_sweep / cian_city_sweep / ...).
|
||||
status — опц. фильтр (done/running/banned/zombie/failed/cancelled).
|
||||
status — опц. фильтр (done/running/banned/zombie/failed/cancelled/skipped).
|
||||
limit — default 50, max 200.
|
||||
offset — default 0.
|
||||
"""
|
||||
|
|
@ -2284,8 +2348,9 @@ def list_scrape_runs_unified(
|
|||
UnifiedScrapeRunRow(
|
||||
run_id=r["run_id"],
|
||||
source=r["source"],
|
||||
run_type=r.get("run_type"),
|
||||
status=r["status"],
|
||||
cancellable=runs_mod.honors_cancel(str(r["source"])),
|
||||
ban_kind=r.get("ban_kind"),
|
||||
params=r.get("params"),
|
||||
counters=r.get("counters"),
|
||||
total_seen=r.get("total_seen"),
|
||||
|
|
@ -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:
|
||||
"""GET tradein-browser /health (timeout 5с). reachable=False при ошибке."""
|
||||
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).
|
||||
- browser: GET tradein-browser /health (reachable + per-browser ready-флаги).
|
||||
- providers: для avito/cian/yandex — proxy host/port, rotate_supported,
|
||||
best-effort current_ip (параллельный пробинг через прокси на ipify).
|
||||
- providers: для avito/cian/yandex — proxy host/port, rotate_supported
|
||||
(#2616 шаг 2: всегда False — changeip mobileproxy-ротация снята, мёртвый
|
||||
аккаунт #2613; живая ASocks-ротация — POST /admin/proxies/{id}/rotate, #2611,
|
||||
не per-provider-source), best-effort current_ip (пробинг через прокси на ipify).
|
||||
|
||||
Все пробинги параллельны (asyncio.gather) и time-boxed — суммарно ≤10с.
|
||||
"""
|
||||
|
|
@ -2359,7 +2441,7 @@ async def scraper_health() -> ScraperHealthResponse:
|
|||
source=source,
|
||||
proxy_host=host,
|
||||
proxy_port=port,
|
||||
rotate_supported=bool(_provider_rotate_url(source)),
|
||||
rotate_supported=False,
|
||||
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) ──────────────────────────
|
||||
|
||||
|
||||
|
|
@ -2506,6 +2548,12 @@ async def update_scraper_pacing(
|
|||
class SourceCoverage(BaseModel):
|
||||
source: str
|
||||
active_count: int
|
||||
# #2660: «активно» ≠ «живо». is_active снимается только деактиватором протухших,
|
||||
# а он покрывает не все источники — на проде (2026-08-05) cian показывал 18 530
|
||||
# активных при 12 683 не виденных 14+ дней. Из-за этого #2574 месяц читалась как
|
||||
# «всё собирается». Не прячем протухшее из счётчика, а отдаём ВТОРЫМ числом
|
||||
# рядом — тогда «активно» перестаёт читаться как «живо».
|
||||
stale_count: int
|
||||
fields: dict[str, float] # field_name -> fill% (0..100, round 1)
|
||||
|
||||
|
||||
|
|
@ -2520,6 +2568,9 @@ class HousesCoverage(BaseModel):
|
|||
class DataQualityResponse(BaseModel):
|
||||
sources: list[SourceCoverage]
|
||||
houses: HousesCoverage
|
||||
# Порог «не виделись N дней» для stale_count — отдаём в ответе, чтобы UI
|
||||
# подписывал число, а не хардкодил порог у себя вторым определением.
|
||||
stale_days: int
|
||||
|
||||
|
||||
# Поля listings для fill%-аудита. Каждый кортеж: (имя_поля, SQL-выражение IS NOT NULL).
|
||||
|
|
@ -2549,6 +2600,10 @@ def get_data_quality(
|
|||
living_area_m2, ceiling_height (cian), ceiling_height_m (avito), metro_stations.
|
||||
houses: total, avito_validated_at%, rating_score%, house_type%.
|
||||
house_reviews: общий count.
|
||||
|
||||
#2660: рядом с active_count отдаётся stale_count — сколько из «активных» не
|
||||
виделись LISTINGS_FRESH_DAYS дней (last_seen_at). Порог отдаётся в ответе
|
||||
(stale_days), чтобы UI не заводил второе определение.
|
||||
"""
|
||||
# Строим single-pass SELECT для listings полей через FILTER-агрегаты.
|
||||
# Структура: COUNT(*) FILTER (WHERE <expr>) / NULLIF(COUNT(*), 0) * 100
|
||||
|
|
@ -2556,10 +2611,17 @@ def get_data_quality(
|
|||
filter_exprs = ", ".join(
|
||||
f"COUNT(*) FILTER (WHERE {expr}) AS f_{name}" for name, expr in _DQ_LISTING_FIELDS
|
||||
)
|
||||
# last_seen_at, а не scraped_at: счётчик отвечает буквально на «сколько не
|
||||
# виделись». На проде две колонки не расходятся (замер 2026-08-05: 0 активных
|
||||
# строк с разницей ≥ суток), но семантика счётчика — про «видели», и колонка
|
||||
# должна называть ровно её.
|
||||
sql_listings = text(f"""
|
||||
SELECT
|
||||
source,
|
||||
COUNT(*) AS active_count,
|
||||
COUNT(*) FILTER (
|
||||
WHERE last_seen_at <= NOW() - (:fresh_days || ' days')::interval
|
||||
) AS stale_count,
|
||||
{filter_exprs}
|
||||
FROM listings
|
||||
WHERE is_active = true
|
||||
|
|
@ -2567,7 +2629,7 @@ def get_data_quality(
|
|||
ORDER BY source
|
||||
""")
|
||||
|
||||
rows = db.execute(sql_listings).mappings().all()
|
||||
rows = db.execute(sql_listings, {"fresh_days": LISTINGS_FRESH_DAYS}).mappings().all()
|
||||
|
||||
sources: list[SourceCoverage] = []
|
||||
for row in rows:
|
||||
|
|
@ -2581,6 +2643,7 @@ def get_data_quality(
|
|||
SourceCoverage(
|
||||
source=row["source"],
|
||||
active_count=int(row["active_count"]),
|
||||
stale_count=int(row["stale_count"] or 0),
|
||||
fields=fields,
|
||||
)
|
||||
)
|
||||
|
|
@ -2608,7 +2671,7 @@ def get_data_quality(
|
|||
reviews_count=reviews_count,
|
||||
)
|
||||
|
||||
return DataQualityResponse(sources=sources, houses=houses)
|
||||
return DataQualityResponse(sources=sources, houses=houses, stale_days=LISTINGS_FRESH_DAYS)
|
||||
|
||||
|
||||
# ── Proxy pool: хранилище + bulk-загрузка / список (#2161) ───────────────────
|
||||
|
|
@ -2679,6 +2742,52 @@ class ProxyBulkResponse(BaseModel):
|
|||
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):
|
||||
id: int
|
||||
label: str | None
|
||||
|
|
@ -2687,6 +2796,7 @@ class ProxyRow(BaseModel):
|
|||
provider_affinity: str
|
||||
rotate_url: str | None # маскированный
|
||||
enabled: bool
|
||||
disabled_reason: str | None # #2610: NULL = не выключен вручную (авто-воскрешаем)
|
||||
consecutive_fails: int
|
||||
exit_ip: str | None
|
||||
latency_ms: int | None
|
||||
|
|
@ -2699,6 +2809,8 @@ class ProxyRow(BaseModel):
|
|||
expires_at: str | None
|
||||
created_at: str | None
|
||||
updated_at: str | None
|
||||
# #2600 п.2: активные баны площадками. Пустой список = узел выдаётся всем источникам.
|
||||
source_bans: list[ProxySourceBan] = Field(default_factory=list)
|
||||
|
||||
|
||||
@router.post("/proxies/bulk", response_model=ProxyBulkResponse)
|
||||
|
|
@ -2711,8 +2823,18 @@ def bulk_upsert_proxies(
|
|||
Тело: {"proxies": [{"url", "provider_affinity", "kind"?, "rotate_url"?,
|
||||
"label"?, "geo"?, "operator"?}, ...]}.
|
||||
|
||||
Существующий url → DO UPDATE (affinity/kind/rotate_url + enabled=true,
|
||||
label/geo/operator обновляются если переданы). Новый → INSERT.
|
||||
Существующий url → DO UPDATE (affinity/kind/rotate_url + enabled,
|
||||
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.
|
||||
|
||||
Возвращает {inserted, updated}. Дубли по url ВНУТРИ одного запроса
|
||||
|
|
@ -2738,7 +2860,10 @@ def bulk_upsert_proxies(
|
|||
label = COALESCE(EXCLUDED.label, scrape_proxies.label),
|
||||
geo = COALESCE(EXCLUDED.geo, scrape_proxies.geo),
|
||||
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()
|
||||
RETURNING (xmax = 0) AS was_inserted
|
||||
"""
|
||||
|
|
@ -2771,6 +2896,9 @@ def list_proxies(
|
|||
"""Список прокси со статусами. Пароли в url/rotate_url маскируются.
|
||||
|
||||
Фильтры: provider (=provider_affinity), enabled. Без фильтров — все.
|
||||
|
||||
source_bans — активные баны узла площадками (#2600 п.2): узел может быть
|
||||
enabled=true и при этом не выдаваться конкретному источнику.
|
||||
"""
|
||||
clauses: list[str] = []
|
||||
params: dict[str, Any] = {}
|
||||
|
|
@ -2787,8 +2915,9 @@ def list_proxies(
|
|||
text(
|
||||
f"""
|
||||
SELECT id, label, url, kind, provider_affinity, rotate_url, enabled,
|
||||
consecutive_fails, exit_ip, latency_ms, last_check_at, last_ok_at,
|
||||
leased_by, leased_at, geo, operator, expires_at, created_at, updated_at
|
||||
disabled_reason, consecutive_fails, exit_ip, latency_ms,
|
||||
last_check_at, last_ok_at, leased_by, leased_at, geo, operator,
|
||||
expires_at, created_at, updated_at
|
||||
FROM scrape_proxies
|
||||
{where}
|
||||
ORDER BY provider_affinity, id
|
||||
|
|
@ -2804,6 +2933,8 @@ def list_proxies(
|
|||
def _iso(v: Any) -> str | None:
|
||||
return v.isoformat() if v is not None else None
|
||||
|
||||
bans = _fetch_source_bans(db, [int(r["id"]) for r in rows])
|
||||
|
||||
return [
|
||||
ProxyRow(
|
||||
id=r["id"],
|
||||
|
|
@ -2813,6 +2944,7 @@ def list_proxies(
|
|||
provider_affinity=r["provider_affinity"],
|
||||
rotate_url=_mask_proxy_url(r["rotate_url"]),
|
||||
enabled=r["enabled"],
|
||||
disabled_reason=r["disabled_reason"],
|
||||
consecutive_fails=r["consecutive_fails"],
|
||||
exit_ip=r["exit_ip"],
|
||||
latency_ms=r["latency_ms"],
|
||||
|
|
@ -2825,6 +2957,7 @@ def list_proxies(
|
|||
expires_at=_iso(r["expires_at"]),
|
||||
created_at=_iso(r["created_at"]),
|
||||
updated_at=_iso(r["updated_at"]),
|
||||
source_bans=bans.get(int(r["id"]), []),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
|
@ -2832,6 +2965,18 @@ def list_proxies(
|
|||
|
||||
class ProxyPatch(BaseModel):
|
||||
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)
|
||||
|
|
@ -2840,20 +2985,40 @@ def patch_proxy(
|
|||
payload: ProxyPatch,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> 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 = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
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
|
||||
RETURNING id, label, url, kind, provider_affinity, rotate_url, enabled,
|
||||
consecutive_fails, exit_ip, latency_ms, last_check_at, last_ok_at,
|
||||
leased_by, leased_at, geo, operator, expires_at, created_at, updated_at
|
||||
disabled_reason, consecutive_fails, exit_ip, latency_ms,
|
||||
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()
|
||||
.fetchone()
|
||||
|
|
@ -2861,6 +3026,18 @@ def patch_proxy(
|
|||
if row is None:
|
||||
raise HTTPException(status_code=404, detail=f"proxy id={proxy_id} not found")
|
||||
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:
|
||||
return v.isoformat() if v is not None else None
|
||||
|
|
@ -2873,6 +3050,7 @@ def patch_proxy(
|
|||
provider_affinity=row["provider_affinity"],
|
||||
rotate_url=_mask_proxy_url(row["rotate_url"]),
|
||||
enabled=row["enabled"],
|
||||
disabled_reason=row["disabled_reason"],
|
||||
consecutive_fails=row["consecutive_fails"],
|
||||
exit_ip=row["exit_ip"],
|
||||
latency_ms=row["latency_ms"],
|
||||
|
|
@ -2885,4 +3063,46 @@ def patch_proxy(
|
|||
expires_at=_iso(row["expires_at"]),
|
||||
created_at=_iso(row["created_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,
|
||||
)
|
||||
|
|
|
|||
354
tradein-mvp/backend/app/api/v1/auth.py
Normal file
354
tradein-mvp/backend/app/api/v1/auth.py
Normal 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}
|
||||
|
|
@ -21,14 +21,27 @@ router = APIRouter()
|
|||
async def lookup(
|
||||
address: Annotated[str, Query(min_length=3, max_length=500)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
city_hint: Annotated[
|
||||
str | None,
|
||||
Query(
|
||||
max_length=100,
|
||||
description=(
|
||||
"Город, если известен вызывающему (например выбран пользователем "
|
||||
"на предыдущем шаге UI). #2576: без него геокодер БОЛЬШЕ НЕ "
|
||||
"подставляет 'Екатеринбург' молча — ответ может помечаться "
|
||||
"city_ambiguous=true."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
) -> GeocodeResult:
|
||||
"""Геокодинг адреса → lat/lon.
|
||||
|
||||
Примеры:
|
||||
/api/v1/geocode/lookup?address=ул.+Малышева+30+Екатеринбург
|
||||
/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:
|
||||
raise HTTPException(status_code=404, detail=f"Address not found: {address}")
|
||||
return result
|
||||
|
|
@ -55,6 +68,16 @@ async def suggest_addresses(
|
|||
q: Annotated[str, Query(min_length=2, max_length=200, description="Запрос для автокомплита")],
|
||||
limit: Annotated[int, Query(ge=1, le=15)] = 8,
|
||||
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
||||
city_hint: Annotated[
|
||||
str | None,
|
||||
Query(
|
||||
max_length=100,
|
||||
description=(
|
||||
"Город, если известен вызывающему (#2576) — без него подсказки "
|
||||
"БОЛЬШЕ НЕ ограничиваются молчаливо Екатеринбургом."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
) -> SuggestResponse:
|
||||
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
|
||||
остаётся быстрым 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=Ленина+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(
|
||||
items=[
|
||||
SuggestItem(
|
||||
|
|
@ -100,11 +124,11 @@ class ReverseResponse(BaseModel):
|
|||
precision: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Yandex-style: exact/number/street/range/near/locality/other/cadastral. "
|
||||
"exact/number/street/range/near/locality/other/cadastral. "
|
||||
"Фронт двигает marker только если exact/number/cadastral."
|
||||
),
|
||||
)
|
||||
provider: str = Field(..., description="cadastral | yandex | nominatim")
|
||||
provider: str = Field(..., description="cadastral | nominatim")
|
||||
|
||||
|
||||
@router.get("/reverse", response_model=ReverseResponse)
|
||||
|
|
|
|||
|
|
@ -7,16 +7,31 @@ Mounted at /api/v1/me; через Caddy `uri strip_prefix /trade-in` это ст
|
|||
Caddy basic_auth пропускает `X-Authenticated-User: <username>` через
|
||||
`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
|
||||
|
||||
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.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__)
|
||||
|
||||
|
|
@ -25,13 +40,45 @@ router = APIRouter()
|
|||
|
||||
@router.get("/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,
|
||||
) -> UserScope:
|
||||
"""Return the current user's RBAC scope (role + allowed/deny paths)."""
|
||||
) -> UserScope | dict[str, Any]:
|
||||
"""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:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="no authenticated user (Caddy basic_auth required)",
|
||||
detail="no authenticated user (valid session required)",
|
||||
)
|
||||
try:
|
||||
return get_user_scope(x_authenticated_user)
|
||||
|
|
|
|||
|
|
@ -29,20 +29,48 @@ support-моста (`app.services.tgbot.bridge`, data/sql/186_tg_support.sql).
|
|||
`username` — thread_id для отправки не нужен вообще, поэтому эту БД-операцию
|
||||
можно безопасно отложить до после успешного 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
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
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 sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
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.bridge import SERVICE_UNAVAILABLE_TEXT
|
||||
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||
|
|
@ -262,3 +290,204 @@ def mark_support_read(
|
|||
storage.mark_read(db, thread_id=thread_id)
|
||||
db.commit()
|
||||
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()
|
||||
|
|
|
|||
818
tradein-mvp/backend/app/api/v1/team.py
Normal file
818
tradein-mvp/backend/app/api/v1/team.py
Normal 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]
|
||||
|
|
@ -63,7 +63,7 @@ def _assert_estimate_access(created_by: str | None, x_authenticated_user: str |
|
|||
if not x_authenticated_user:
|
||||
raise HTTPException(
|
||||
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
|
||||
|
|
@ -702,7 +702,7 @@ def estimate_history(
|
|||
if not x_authenticated_user:
|
||||
raise HTTPException(
|
||||
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
|
||||
|
|
@ -764,7 +764,15 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
|
|||
trade_in_estimates с непустым address; NULL при отсутствии адресов.
|
||||
NB: это честный best-effort по persisted оценкам, а не hit-rate реального
|
||||
кэша (отдельного счётчика попаданий не ведём).
|
||||
|
||||
#2660: listings_active сам по себе врал — «активно» на проде не означает
|
||||
«живо» (деактиватор протухших покрывает не все источники). Рядом отдаём
|
||||
listings_active_stale — сколько из них не виделись listings_stale_days
|
||||
(= LISTINGS_FRESH_DAYS эстиматора; прод 2026-08-05: 37 900 активных при
|
||||
20 935 не виденных 14+ дней). Счётчик не прячем, а разделяем.
|
||||
"""
|
||||
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
|
|
@ -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())
|
||||
AS geocode_cache_fresh,
|
||||
(SELECT count(*) FROM listings WHERE is_active) AS listings_active,
|
||||
(SELECT count(*) FROM listings
|
||||
WHERE is_active
|
||||
AND last_seen_at <= NOW() - (:fresh_days || ' days')::interval)
|
||||
AS listings_active_stale,
|
||||
(SELECT max(scraped_at) FROM listings) AS listings_last_scraped,
|
||||
(SELECT count(*) FROM deals) AS deals,
|
||||
(SELECT count(*) FROM gendesign_cad_buildings) AS cad_buildings,
|
||||
|
|
@ -790,12 +802,15 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
|
|||
WHERE address IS NOT NULL AND address <> ''
|
||||
) t) AS repeat_address_pct
|
||||
"""
|
||||
)
|
||||
),
|
||||
{"fresh_days": LISTINGS_FRESH_DAYS},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
return dict(row) if row else {}
|
||||
# Порог отдаём рядом с числом — чтобы UI подписывал «не виделись N дней»,
|
||||
# а не заводил второе определение свежести у себя.
|
||||
return (dict(row) | {"listings_stale_days": LISTINGS_FRESH_DAYS}) if row else {}
|
||||
|
||||
|
||||
# ── Stage 4a: house info + IMV benchmark для UI ───────────────────────────────
|
||||
|
|
@ -1820,6 +1835,108 @@ def get_street_deals(
|
|||
|
||||
# ── Sales vs Listings (PR K — Foundation Phase 1 of issue #564) ──────────────
|
||||
|
||||
# #2666 гейт правдоподобия на «медианный торг». Пейринг ДКП↔объявление идёт по
|
||||
# УЛИЦЕ без номера дома (data_quality="street_only", ADR #721): на длинной улице
|
||||
# сделка и объявление могут стоять в разных домах и разных ценовых классах, и
|
||||
# тогда discount_pct — не торг, а разница между двумя чужими друг другу лотами.
|
||||
# Гард #2660 (миграция 211) убрал предвзятые пары «вторичка ↔ новостройка» и тем
|
||||
# самым сделал остаток артефактов ВИДНЫМ: по `%Космонавтов%` 2-комн. медиана
|
||||
# уехала с −11.9% на +36.4%, т.е. пользователю написали бы «продали на 36%
|
||||
# дороже, чем просили». Здесь не чиним пейринг (это ADR-уровень), а перестаём
|
||||
# показывать число, которому нельзя верить.
|
||||
#
|
||||
# Пороги подобраны по проду 2026-08-05 (симуляция эндпоинта на 238 РЕАЛЬНЫХ
|
||||
# пользовательских запросах из trade_in_estimates — тот же address/area/rooms,
|
||||
# что уходил в виджет; 128 из них дали хотя бы одну пару):
|
||||
#
|
||||
# MIN_PAIRS = 10 — бутстрап по 12 «плотным» группам (n ≥ 60 пар): из полной
|
||||
# выборки берём подвыборку размера k и смотрим, насколько медиана подвыборки
|
||||
# отклоняется от полной. p90 |отклонения|: k=5 → 18.8 п.п., k=10 → 12.0,
|
||||
# k=15 → 9.9, k=20 → 8.2. Кривая ломается ровно на 10 (5→10 даёт −6.8 п.п.
|
||||
# шума, 10→15 уже только −2.1, а каждые +5 к порогу стоят ещё ~8-10% улиц).
|
||||
# Совпадает с уже принятым в продукте порогом малой выборки
|
||||
# settings.sell_time_sensitivity_min_n_lots = 10.
|
||||
#
|
||||
# SANE_MIN/MAX = [−60%, +20%] — асимметричны намеренно, у сторон разная природа:
|
||||
# ВЕРХ. В наблюдаемом распределении 128 групп положительный хвост РАЗОРВАН:
|
||||
# +11.1, +10.8, +16.9 — и дальше пусто до +33.7, +34.2, +34.6, +39.0, +52.5,
|
||||
# +70.2, +81.5, +103.1. Отсечка +20% попадает в пустой промежуток, т.е. режет
|
||||
# отдельный кластер, а не край континуума. Сверху её подпирает рынок: ни один
|
||||
# городской бакет asking_to_sold_ratios не даёт плюса вообще (max ratio 0.9132
|
||||
# = −8.7% торга), так что «продали на +20% дороже ask» уже вдвое дальше любого
|
||||
# рыночно объяснимого плюса.
|
||||
# НИЗ. Разрыва нет — минус идёт сплошняком от −5% до −87%, и это ожидаемо:
|
||||
# у большого отрицательного торга есть механизм (занижение цены в ДКП), в
|
||||
# отличие от большого плюса. Поэтому граница грубая, «заведомо не рынок»:
|
||||
# худший городской бакет (студии, ratio 0.7623) = −23.8%, −60% в 2.5 раза
|
||||
# глубже. Режет 6 групп из 128 (−87 … −64).
|
||||
#
|
||||
# Цена гейта на проде: из 128 групп с парами число сохраняют 64 (50%), 59 (46%)
|
||||
# теряют его по «мало пар» и ещё 5 (4%) — по диапазону. Виджет при этом остаётся:
|
||||
# сделки, медиана ₽/м², диапазон и сами пары считаются мимо гейта, гаснет ровно
|
||||
# строка «медианный торг», и вместо неё уходит median_discount_explanation.
|
||||
#
|
||||
# MIN_DISTINCT_LISTINGS = 2 (#2672) — ПАРЫ НЕ ЯВЛЯЮТСЯ НЕЗАВИСИМЫМИ НАБЛЮДЕНИЯМИ,
|
||||
# и MIN_PAIRS этого не видит. DISTINCT ON подбирает по объявлению на сделку, но
|
||||
# ОДНО объявление переиспользуется на многих сделках улицы: у показываемых групп
|
||||
# медиана — 18 сделок на одно различное объявление. До этого порога из 64
|
||||
# показываемых чисел 22 (34%) стояли на ОДНОМ объявлении (худший живой кейс —
|
||||
# `Белинского` 1-комн.: 50 пар, 1 объявление, −50.6%), 50 (78%) — меньше чем на
|
||||
# трёх. «50 пар» там означало не 50 наблюдений рынка, а 50 сделок, поделённых на
|
||||
# ОДНУ цену предложения: число говорило о том, чем эта конкретная квартира
|
||||
# отличалась от типичной сделки, а не о торге на улице.
|
||||
#
|
||||
# Почему именно 2, и почему порог здесь обоснован ИНАЧЕ, чем MIN_PAIRS. Разброс
|
||||
# со стороны объявлений мерили джекнайфом (выкинуть одно объявление, 45 групп,
|
||||
# 118 повторов): p50 3.6, p90 18.8, max 80.4 п.п. — тот же порядок, что и шум
|
||||
# при 5 парах, который при выборе MIN_PAIRS сочли неприемлемым. Но на группах с
|
||||
# ОДНИМ объявлением ни джекнайф, ни кластерный бутстрап не дают числа вообще:
|
||||
# выкидывать нечего, пересэмплировать нечего, отклонение тождественно 0.
|
||||
# Их «нулевая ошибка» — не малая ошибка, а отсутствие измерения, и агрегат по
|
||||
# всем 64 группам от их добавления УЛУЧШАЛСЯ (кластер-бутстрап p90 16.0 → 11.5),
|
||||
# т.е. метрика становилась тем зеленее, чем больше в ней неизмеримого. Поэтому
|
||||
# 2 — не статистический выбор, а граница выразимости: ниже неё нет выборки, о
|
||||
# разбросе которой можно спрашивать, и показывать число = фабриковать точность.
|
||||
# Выше 2 порог уже статистический, и данные (прод 2026-08-06, те же 128 групп)
|
||||
# говорят, что он должен быть выше — но ценой почти всей витрины:
|
||||
# объявлений ≥ 2 → 42 группы (33%), джекнайф p90 17.4;
|
||||
# объявлений ≥ 3 → 14 групп (11%), p90 10.9 (планка MIN_PAIRS — 12.0);
|
||||
# объявлений ≥ 4 → 7 групп ( 5%), p90 5.5.
|
||||
# Порог 3 попадал бы в принятую планку шума, но оставляет 11% витрины и всё
|
||||
# равно не делает число защищаемым (ошибка со стороны СДЕЛОК никуда не делась и
|
||||
# складывается с ней). Выбирать между «9% покрытия» и «выключить строку» —
|
||||
# решение владельца, не гейта; здесь снимается ровно то, что не является
|
||||
# наблюдением рынка в принципе. Понижать MIN_PAIRS в компенсацию нельзя:
|
||||
# вернувшиеся группы стоят на тех же одном-двух объявлениях (ложная точность).
|
||||
#
|
||||
# SANE_MIN ужесточён −60% → −35% (#2672). Исходное подозрение «−60% режет живой
|
||||
# рынок» проверено и ОПРОВЕРГНУТО: до −60% проходило всё, законный механизм
|
||||
# большого минуса (занижение цены в ДКП) сохранён целиком. Ошибка была в другую
|
||||
# сторону — граница пропускала неправдоподобный отрицательный хвост: 26 из 64
|
||||
# показываемых чисел (41%) лежали ниже −23.7%, худшего объяснимого рынком
|
||||
# бакета (asking_to_sold_ratios: студии, ratio 0.7634, 1 519 сделок; ни один
|
||||
# бакет не глубже), самое глубокое показываемое — −58.5%. Мы гасили «+34%» и
|
||||
# показывали «−58.5%», полученный из ТОГО ЖЕ артефакта пейринга. Асимметрия
|
||||
# работала против пользователя: абсурдный плюс сам себя опровергает («продали
|
||||
# дороже, чем просили» — виджету просто не поверят), абсурдный минус выглядит
|
||||
# правдоподобно и подталкивает продавца к выводу, что его улица торгуется за
|
||||
# полцены. −35% ≈ в 1.5 раза глубже худшего рыночного бакета (запас на занижение
|
||||
# в ДКП сохранён) и попадает в разрыв наблюдаемого распределения −37.6 → −33.9.
|
||||
# Живой кейс из ревью: Серов, Ленина 163, 2-комн., 21 пара → −46.5% показывался.
|
||||
#
|
||||
# ПОТОЛОК ГЕЙТА (знать до следующей правки — здесь НЕ чинится):
|
||||
# 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)
|
||||
def get_sales_vs_listings(
|
||||
|
|
@ -1845,7 +1962,7 @@ def get_sales_vs_listings(
|
|||
|
||||
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:
|
||||
return SalesVsListingsResponse(
|
||||
|
|
@ -1866,6 +1983,15 @@ def get_sales_vs_listings(
|
|||
logger.warning("sales-vs-listings: cannot extract street from %r", address)
|
||||
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 = (
|
||||
db.execute(
|
||||
text(
|
||||
|
|
@ -1882,7 +2008,8 @@ def get_sales_vs_listings(
|
|||
CAST(:rooms AS integer),
|
||||
CAST(:window_days AS integer),
|
||||
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,
|
||||
"area_tolerance": area_tolerance,
|
||||
"period_months": period_months,
|
||||
"target_city": target_city,
|
||||
},
|
||||
)
|
||||
.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)
|
||||
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(
|
||||
"sales-vs-listings: street=%r deals=%d with_listings=%d linkage=%.1f%% median_disc=%s",
|
||||
"sales-vs-listings: street=%r deals=%d with_listings=%d distinct_listings=%d "
|
||||
"linkage=%.1f%% median_disc=%s",
|
||||
street_name,
|
||||
total_deals,
|
||||
deals_with_listings,
|
||||
n_distinct_listings,
|
||||
linkage_rate_pct,
|
||||
f"{median_discount:+.2f}%" if median_discount is not None else "n/a",
|
||||
)
|
||||
|
|
@ -1967,6 +2152,7 @@ def get_sales_vs_listings(
|
|||
deals_with_listings=deals_with_listings,
|
||||
linkage_rate_pct=linkage_rate_pct,
|
||||
median_discount_pct=median_discount,
|
||||
median_discount_explanation=median_discount_explanation,
|
||||
# street_sales_vs_listings матчит по УЛИЦЕ (не по дому, #721 ADR) →
|
||||
# даже при deals_with_listings>0 это street-level, не house. house_linked НЕ emit'им.
|
||||
data_quality="street_only" if total_deals > 0 else "no_data",
|
||||
|
|
|
|||
162
tradein-mvp/backend/app/core/auth_db.py
Normal file
162
tradein-mvp/backend/app/core/auth_db.py
Normal 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()
|
||||
|
|
@ -1,10 +1,35 @@
|
|||
"""Минимальный settings для standalone trade-in MVP."""
|
||||
|
||||
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
|
||||
|
||||
# ── Дефолтные части 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):
|
||||
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"
|
||||
)
|
||||
|
||||
# Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/
|
||||
# backfill_house_coords.py + audit_address_mismatch.py + main backend
|
||||
# OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F).
|
||||
yandex_geocoder_api_key: str | None = None # 25K req/day free после регистрации
|
||||
yandex_suggest_key: str | None = None # для frontend autocomplete (proxy через backend)
|
||||
# ── #2550: DB-auth foundation (bcrypt password hashing + session cookie) ────
|
||||
# Подготовительные поля для #2549 (эпик). Enforcement непустого session_secret
|
||||
# (fail-fast при пустом значении в prod) добавится в #2552 — здесь дефолт
|
||||
# намеренно пустой, чтобы прод-контейнер не падал на старте до того как
|
||||
# секрет проставлен в .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)
|
||||
contact_email: str = "erginrajpopxbe@outlook.com"
|
||||
|
||||
|
|
@ -466,63 +706,67 @@ class Settings(BaseSettings):
|
|||
"www.domclick.ru",
|
||||
}
|
||||
|
||||
# ── Scraper mobile proxy (#806) ──────────────────────────────────────────
|
||||
# Мобильный прокси (RU, mobileproxy.space) используется ВСЕМИ scraper-сессиями:
|
||||
# Avito (#623) + Cian (#806). Datacenter-IP блокируется обоими сайтами.
|
||||
# ── Scraper mobile proxy (#806, #2616 шаг 2) ─────────────────────────────
|
||||
# Мобильный резидентный прокси (ASocks) используется ВСЕМИ scraper-сессиями:
|
||||
# Avito (#623) + Cian (#806) + Yandex. Datacenter-IP блокируется всеми тремя.
|
||||
# Пусто = прямое подключение (dev/staging без прокси).
|
||||
#
|
||||
# Приоритет ENV-переменных (precedence):
|
||||
# 1. SCRAPER_PROXY_URL — новый общий ENV; когда задан — используется первым.
|
||||
# 2. AVITO_PROXY_URL — legacy ENV; fallback, чтобы prod-серверы с уже
|
||||
# настроенным AVITO_PROXY_URL работали без изменений .env.runtime (#806).
|
||||
# property `scraper_proxy_url` реализует эту логику; используй его везде.
|
||||
# #2616 шаг 2: per-provider legacy-переменные (AVITO_PROXY_URL/CIAN_PROXY_URL/
|
||||
# YANDEX_PROXY_URL и их *_ROTATE_URL, changeip mobileproxy) удалены — указывали
|
||||
# на закрытые аккаунты (407/connection refused, проверено вживую #2613).
|
||||
# SCRAPER_PROXY_URL — единственный живой источник, общий для всех провайдеров.
|
||||
# validation_alias привязывает поле к env SCRAPER_PROXY_URL (без него
|
||||
# pydantic-settings читал бы SCRAPER_PROXY_URL_ENV по имени поля — #806 fixup).
|
||||
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
|
||||
def scraper_proxy_url(self) -> str | None:
|
||||
"""Единый прокси URL для всех scraper-сессий (Avito + Cian).
|
||||
"""Единый прокси URL для всех scraper-сессий (Avito + Cian + Yandex).
|
||||
|
||||
Приоритет: SCRAPER_PROXY_URL > AVITO_PROXY_URL > None (прямое подключение).
|
||||
Prod-серверы с существующим AVITO_PROXY_URL работают без изменений env.
|
||||
Прямая проекция SCRAPER_PROXY_URL (#2616 шаг 2: legacy AVITO_PROXY_URL
|
||||
fallback снят — мёртвая mobileproxy-переменная).
|
||||
"""
|
||||
return self.scraper_proxy_url_env or self.avito_proxy_url
|
||||
return self.scraper_proxy_url_env
|
||||
|
||||
# changeip-ссылка mobileproxy: GET меняет мобильный IP за ~9с. Дёргается при
|
||||
# детекте бана Avito перед повтором. Пусто = ротация выключена (raise сразу).
|
||||
# ENV: AVITO_PROXY_ROTATE_URL.
|
||||
avito_proxy_rotate_url: str | None = None
|
||||
# Сколько раз сменить IP при блоке прежде чем сдаться (на одну страницу).
|
||||
# #1731: 2→4 — больше шансов восстановиться mid-sweep после проактивной
|
||||
# ротации на старте (Datadome ban recovery).
|
||||
# ── Ban-recovery budget knobs (changeip-механизм снят #2616 шаг 2) ────────
|
||||
# Раньше эти поля тюнили retry/settle для GET-changeip mobileproxy
|
||||
# (AVITO_PROXY_ROTATE_URL и т.д., см. историю выше) — сама ссылка удалена
|
||||
# (закрытый аккаунт), поэтому IP-ротация сейчас всегда no-op (_rotate_ip /
|
||||
# _rotate_proxy_ip возвращают False без сетевого похода). Поля оставлены:
|
||||
# `*_proxy_max_rotations` продолжают гейтить бюджет попыток в ban-rotation
|
||||
# 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
|
||||
# Settle-sleep после changeip-вызова: мобильный модем поднимает новый IP.
|
||||
# ~9с по умолчанию (эмпирика mobileproxy.space). ENV: AVITO_PROXY_ROTATE_SETTLE_S.
|
||||
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_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,
|
||||
# ставим 'done' а не 'banned' — partial intake сохранён, 'banned' лишний.
|
||||
# False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED.
|
||||
avito_serp_ok_not_banned: bool = True
|
||||
|
||||
# ── Cian dedicated mobile proxy (separate egress from Avito) ──────────────
|
||||
# Cian и Avito делят один мобильный IP при общем scraper_proxy_url → конкуренция
|
||||
# за единственный egress → взаимные таймауты/баны при параллельных прогонах.
|
||||
# Отдельный прокси для Cian устраняет contention. Если не задан — fallback на
|
||||
# общий scraper_proxy_url (backward-compat). ENV: CIAN_PROXY_URL.
|
||||
cian_proxy_url_env: str | None = Field(default=None, validation_alias="CIAN_PROXY_URL")
|
||||
# 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.
|
||||
# ── Cian proxy budget (#2616 шаг 2: dedicated CIAN_PROXY_URL/ROTATE_URL снят) ──
|
||||
# Раньше Cian мог получить СВОЙ мобильный прокси отдельно от Avito (контеншен на
|
||||
# общем egress); CIAN_PROXY_URL указывал на закрытый аккаунт — удалён,
|
||||
# cian_proxy_url ниже теперь = scraper_proxy_url. cian_proxy_max_rotations
|
||||
# остаётся: гейтит бюджет в ban-rotation state machine наравне с avito/yandex
|
||||
# (см. комментарий у avito_proxy_max_rotations выше — сама ротация no-op).
|
||||
# ENV: CIAN_PROXY_MAX_ROTATIONS.
|
||||
cian_proxy_max_rotations: int = 4
|
||||
|
||||
|
|
@ -542,25 +786,21 @@ class Settings(BaseSettings):
|
|||
|
||||
@property
|
||||
def cian_proxy_url(self) -> str | None:
|
||||
"""Прокси для Cian-скраперов. CIAN_PROXY_URL > scraper_proxy_url (fallback)."""
|
||||
return self.cian_proxy_url_env or self.scraper_proxy_url
|
||||
"""Прокси для Cian-скраперов (#2616 шаг 2: = scraper_proxy_url, per-provider
|
||||
override снят — свойство оставлено для scraper_kit.contracts.ScraperConfig
|
||||
совместимости)."""
|
||||
return self.scraper_proxy_url
|
||||
|
||||
# ── Yandex dedicated mobile proxy (separate egress from Avito/Cian) ────────
|
||||
# Отдельный прокси для Yandex устраняет contention при параллельных прогонах.
|
||||
# Если не задан — fallback на общий scraper_proxy_url (backward-compat).
|
||||
# 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.
|
||||
# ── Yandex proxy budget (#2616 шаг 2: dedicated YANDEX_PROXY_URL/ROTATE_URL снят) ──
|
||||
# Симметрично Cian выше — YANDEX_PROXY_URL указывал на закрытый аккаунт.
|
||||
# yandex_proxy_max_rotations остаётся для ban-rotation budget-гейта.
|
||||
# ENV: YANDEX_PROXY_MAX_ROTATIONS.
|
||||
yandex_proxy_max_rotations: int = 4
|
||||
|
||||
@property
|
||||
def yandex_proxy_url(self) -> str | None:
|
||||
"""Прокси для Yandex-скраперов. YANDEX_PROXY_URL > scraper_proxy_url (fallback)."""
|
||||
return self.yandex_proxy_url_env or self.scraper_proxy_url
|
||||
"""Прокси для Yandex-скраперов (#2616 шаг 2: = scraper_proxy_url)."""
|
||||
return self.scraper_proxy_url
|
||||
|
||||
# full_load повторный прогон в день пропускает листинги уже обновлённые сегодня
|
||||
# (last_seen_at MSK) — экономит upsert + price-trigger churn; False = всегда
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ This helper:
|
|||
- applies idempotent CREATE or ALTER mapping on every backend startup so
|
||||
password rotation through .env.runtime is picked up after restart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
|
@ -37,7 +38,7 @@ def ensure_fdw_user_mapping(db: Session) -> None:
|
|||
logger.warning(
|
||||
"GENDESIGN_FDW_PASSWORD not set — skipping FDW user mapping "
|
||||
"(gendesign_cad_buildings queries will fail; cadastral lookups will "
|
||||
"fall back to Yandex/Nominatim)"
|
||||
"fall back to Nominatim)"
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -62,16 +63,20 @@ def ensure_fdw_user_mapping(db: Session) -> None:
|
|||
).first()
|
||||
|
||||
if exists is None:
|
||||
db.execute(text(
|
||||
db.execute(
|
||||
text(
|
||||
f"CREATE USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
|
||||
f"OPTIONS (user 'tradein_fdw_reader', password '{password}')"
|
||||
))
|
||||
)
|
||||
)
|
||||
logger.info("created FDW user mapping for gendesign_remote")
|
||||
else:
|
||||
db.execute(text(
|
||||
db.execute(
|
||||
text(
|
||||
f"ALTER USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
|
||||
f"OPTIONS (SET password '{password}')"
|
||||
))
|
||||
)
|
||||
)
|
||||
logger.info("refreshed FDW user mapping password for gendesign_remote")
|
||||
|
||||
try:
|
||||
|
|
|
|||
254
tradein-mvp/backend/app/core/password.py
Normal file
254
tradein-mvp/backend/app/core/password.py
Normal 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)
|
||||
|
|
@ -114,8 +114,13 @@ class SlidingWindowLimiter:
|
|||
return self._window_s - (now - bucket[0])
|
||||
return None
|
||||
|
||||
def record(self, key: str) -> None:
|
||||
"""Регистрирует одну успешную попытку под *key*."""
|
||||
def record(self, key: str) -> int:
|
||||
"""Регистрирует одну попытку под *key* и возвращает их число в окне ПОСЛЕ неё.
|
||||
|
||||
Счётчик нужен вызывающим, которым мало булева «за лимитом / нет»: login
|
||||
(#2571) по нему считает НАСКОЛЬКО перебран порог и растит задержку ответа
|
||||
пропорционально. Значение можно игнорировать — `check()` так и делает.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
bucket = self._hits[key]
|
||||
self._prune(bucket, now)
|
||||
|
|
@ -125,6 +130,7 @@ class SlidingWindowLimiter:
|
|||
if len(self._hits) > 10000:
|
||||
for k in [k for k, v in self._hits.items() if not v]:
|
||||
del self._hits[k]
|
||||
return len(bucket)
|
||||
|
||||
def check(self, key: str) -> float | None:
|
||||
"""Комбинированная проверка+регистрация (peek+record за один вызов) —
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
so a regression in that check would NOT have failed CI.
|
||||
|
||||
This module holds the real guard with no DB/lifespan/scheduler side effects
|
||||
(only ``app.core.auth`` + ``app.core.config``, both side-effect-free at
|
||||
import time beyond requiring ``DATABASE_URL`` in the environment for
|
||||
``Settings()``). ``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.
|
||||
This module holds the real guard. Historically it had "no DB/lifespan/scheduler
|
||||
side effects" beyond ``app.core.auth``/``app.core.config`` (both side-effect-free
|
||||
at import time). #2552 (dual-mode DB-session auth) adds a conditional per-request
|
||||
DB round trip via ``app.services.identity_store.identity_session`` — но ТОЛЬКО
|
||||
когда запрос реально несёт session-cookie
|
||||
(``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
|
||||
|
|
@ -21,12 +31,15 @@ import logging
|
|||
import re
|
||||
import secrets
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from app.core.auth import get_role, is_path_allowed
|
||||
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__)
|
||||
|
||||
|
|
@ -40,7 +53,37 @@ logger = logging.getLogger(__name__)
|
|||
# Public paths без auth (/health, /docs, /openapi.json) пропускаем —
|
||||
# X-Authenticated-User там не приходит из Caddy.
|
||||
_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) перед
|
||||
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
|
||||
# scope-проверки восстанавливаем внешний путь.
|
||||
|
|
@ -51,6 +94,77 @@ _EXTERNAL_PREFIX = "/trade-in"
|
|||
_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(
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
|
|
@ -59,11 +173,56 @@ async def rbac_guard(
|
|||
if path in _PUBLIC_PATHS:
|
||||
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")
|
||||
if not username:
|
||||
return JSONResponse(
|
||||
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
|
||||
|
|
@ -94,6 +253,9 @@ async def rbac_guard(
|
|||
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":
|
||||
logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path)
|
||||
return JSONResponse(
|
||||
|
|
@ -101,15 +263,14 @@ async def rbac_guard(
|
|||
content={"detail": "admin only"},
|
||||
)
|
||||
|
||||
# #R2-H3: энфорсим roles.yaml scope (paths/deny) для ВСЕХ non-admin путей, а не
|
||||
# только /admin/*. Иначе revoked (role=expired, paths:[] deny:/**) или узко-
|
||||
# скоупленный аккаунт достаёт non-admin API (напр. POST /api/v1/search —
|
||||
# экспорт листингов), который roles.yaml ему запрещает. Bootstrap-пути (/me,
|
||||
# /brand) исключены выше по списку. roles.yaml globs внешние → восстанавливаем
|
||||
# внешний путь (Caddy срезал /trade-in). На сбой парса — fail-open + громкий
|
||||
# лог: не лочим платящего pilot из-за конфиг-бага (admin-гейт выше остаётся).
|
||||
# #R2-H3: энфорсим scope (paths/deny) для ВСЕХ non-admin путей, а не
|
||||
# только /admin/*. Bootstrap-пути (/me, /brand) исключены — иначе revoked/
|
||||
# scope-narrowed юзер не смог бы получить свою роль вовсе.
|
||||
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
|
||||
external_path = _EXTERNAL_PREFIX + path
|
||||
if from_session:
|
||||
allowed = _db_role_path_allowed(role, external_path)
|
||||
else:
|
||||
try:
|
||||
allowed = is_path_allowed(role, external_path)
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from sentry_sdk.integrations.starlette import StarletteIntegration
|
|||
from app.api.v1 import (
|
||||
admin,
|
||||
audit,
|
||||
auth,
|
||||
brand,
|
||||
buildings,
|
||||
geocode,
|
||||
|
|
@ -31,8 +32,10 @@ from app.api.v1 import (
|
|||
privacy_admin,
|
||||
search,
|
||||
support,
|
||||
team,
|
||||
trade_in,
|
||||
)
|
||||
from app.core.auth_db import get_auth_engine
|
||||
from app.core.config import settings
|
||||
from app.core.db import SessionLocal
|
||||
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)"
|
||||
)
|
||||
|
||||
# #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.
|
||||
# Best-effort: failure does not abort startup, just logs.
|
||||
try:
|
||||
|
|
@ -159,6 +196,7 @@ def health() -> dict[str, str]:
|
|||
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(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
||||
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(search.router, prefix="/api/v1", tags=["search"])
|
||||
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
||||
app.include_router(team.router, prefix="/api/v1/team", tags=["team"])
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
# Query-string секреты в исходящих URL сторонних API (аудит-фикс, #security-audit):
|
||||
# mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др., admin.py
|
||||
# rotate_proxy_ip) несёт провайдерский API-ключ в query (`?...&proxy_key=...`).
|
||||
# исторически — mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др.,
|
||||
# admin.rotate_proxy_ip) несла провайдерский API-ключ в query
|
||||
# (`?...&proxy_key=...`). Ручка и переменные удалены (#2616 шаг 2/3, мёртвая
|
||||
# подписка) — редактор оставлен как generic safety net (не ключ-based, любой
|
||||
# будущий query-секрет с распространённым именем параметра тоже покрыт).
|
||||
# Два независимых пути утечки в GlitchTip, зеркалящих TG-токен выше:
|
||||
# 1. `HttpxIntegration.send()` парсит URL через `parse_url(str(request.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 не сэмплится/не уходит), но молча перестанет спасать, если трейсинг
|
||||
# когда-нибудь включат.
|
||||
# 2. `include_local_variables=True` (sentry_sdk default в app/main.py — в отличие
|
||||
# от tgbot_main.py, где явно False) кладёт stack-frame locals (`rotate_url`,
|
||||
# `exc` в rotate_proxy_ip) в traceback открытым текстом.
|
||||
# от tgbot_main.py, где явно False) кладёт stack-frame locals в traceback
|
||||
# открытым текстом (был прецедент: `rotate_url`/`exc` в удалённом
|
||||
# admin.rotate_proxy_ip).
|
||||
# Как и TG-токен — full-text regex по КАЖДОЙ строке event (не ключ-based): секрет
|
||||
# может всплыть где угодно (frame locals, breadcrumb, exception message). НЕ
|
||||
# завязано на конкретного провайдера — покрывает любой query-параметр из
|
||||
# общеупотребимого набора секретных имён (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(
|
||||
r"(?i)([?&](?:api[_-]?key|proxy[_-]?key|token|secret|password|pwd|"
|
||||
|
|
|
|||
|
|
@ -40,7 +40,9 @@ class SearchParams(BaseModel):
|
|||
floors_total_max: int | None = Field(default=None, ge=1)
|
||||
|
||||
# --- Quality / cross-source ---
|
||||
has_kadastr: bool = False
|
||||
# has_kadastr снят (#2674): listings.cadastral_number пуст у всех 93 408 строк,
|
||||
# фильтр мог вернуть только пустую выдачу. Лишний query-param FastAPI игнорирует,
|
||||
# так что старые клиенты не ломаются.
|
||||
sources: list[Literal["avito", "cian", "yandex_realty"]] | None = None
|
||||
multi_source_only: bool = False
|
||||
require_avito: bool = False
|
||||
|
|
|
|||
116
tradein-mvp/backend/app/schemas/team.py
Normal file
116
tradein-mvp/backend/app/schemas/team.py
Normal 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
|
||||
|
|
@ -27,6 +27,12 @@ class TradeInEstimateInput(BaseModel):
|
|||
# geocode() (который падает на DaData-формах при мёртвом Yandex-ключе).
|
||||
lat: float | None = Field(default=None, ge=-90, le=90)
|
||||
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
|
||||
# (SuggestItem.fias_id у house-level кандидата). Прокидывается в матчер
|
||||
# (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_lat: 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']
|
||||
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
|
||||
# абсолютный timestamp самого свежего парсинга аналогов
|
||||
|
|
@ -258,6 +270,16 @@ class AggregatedEstimate(BaseModel):
|
|||
# null — нет данных / оценка не построена
|
||||
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
|
||||
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: премиальный дом (флаг, НЕ ценовой сигнал) ──
|
||||
# premium_building — целевой дом признан премиальным. Источник — curated overlay
|
||||
# `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_end_hour: int = Field(default=5, ge=0, le=23)
|
||||
default_params: dict[str, Any] = Field(default_factory=dict)
|
||||
# #2674: явная воля оператора по времени следующего запуска. None (умолчание) —
|
||||
# «не трогай, посчитай сам от такта». Заданное значение уважается как есть, включая
|
||||
# прошедшее/now() — это и есть «запустить сейчас» (планировщик берёт строки с
|
||||
# next_run_at <= NOW()), у которого до сих пор не было API и его делали UPDATE'ом.
|
||||
next_run_at: datetime | None = None
|
||||
|
||||
|
||||
# ── House analytics (house_placement_history backfill) ───────────────────────
|
||||
|
|
@ -591,6 +618,13 @@ class SalesVsListingsResponse(BaseModel):
|
|||
deals_with_listings: int # сколько имеют связанный listing
|
||||
linkage_rate_pct: float # deals_with_listings / total_deals * 100
|
||||
median_discount_pct: float | None # медиана по парам с listing
|
||||
# #2666: None вместе с median_discount_pct=None означает «медианы просто нет»
|
||||
# (пар не нашлось). Непустая строка = медиана посчиталась, но не прошла гейт
|
||||
# правдоподобия (мало пар / значение вне санитарного диапазона — см. пороги
|
||||
# SALES_VS_LISTINGS_* в api/v1/trade_in.py) и намеренно не показывается.
|
||||
# Форма отказа зеркалит confidence_explanation оценщика: пользователю нужен
|
||||
# текст «почему числа нет», иначе пустое место читается как поломка виджета.
|
||||
median_discount_explanation: str | None = None
|
||||
data_quality: str # "house_linked" | "street_only" | "no_data" (#721, ADR v3)
|
||||
pairs: list[SalesListingPair] # все пары, sorted by deal_date DESC
|
||||
|
||||
|
|
|
|||
334
tradein-mvp/backend/app/services/auth_session.py
Normal file
334
tradein-mvp/backend/app/services/auth_session.py
Normal 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, ([], ["/**"]))
|
||||
|
|
@ -8,6 +8,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from curl_cffi.requests import AsyncSession
|
||||
|
|
@ -23,6 +24,11 @@ from app.core.config import settings
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# За сколько дней до протухания кук предупреждать (#2658). Обновление кук — РУЧНАЯ
|
||||
# операция (залить дамп через админку), человеку нужен запас: алерт по факту протухания
|
||||
# приходит, когда сбор уже встал. save_session ставит ttl 30 дней, так что окно широкое.
|
||||
COOKIE_EXPIRY_WARN_DAYS = 5
|
||||
|
||||
# Cookies критичные для Cian auth — фильтр перед сохранением.
|
||||
# Список обновлён по реальному DevTools-дампу из logged-in сессии cian.ru (2026-05-23).
|
||||
# Старые записи оставлены как fallback (backward compat).
|
||||
|
|
@ -294,6 +300,37 @@ def load_session(db: Session) -> dict[str, str] | None:
|
|||
return cookies
|
||||
|
||||
|
||||
def session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None:
|
||||
"""Когда протухают самые свежезагруженные куки (#2658).
|
||||
|
||||
`load_session` отбирает только ещё валидные записи (expires_at_estimate > NOW()) и на
|
||||
протухших отдаёт None — вызывающий не мог отличить «кук никогда не загружали» от
|
||||
«протухли позавчера» и не мог предупредить ЗАРАНЕЕ.
|
||||
|
||||
valid_only=False (диагностика после None от load_session) — свежайшая запись любая:
|
||||
валидных по определению нет, нужен именно срок протухшей. valid_only=True — та же
|
||||
запись, которую взял бы load_session: для предупреждения «скоро протухнут» нужен срок
|
||||
ИМЕННО используемых кук, иначе при нескольких аккаунтах посчитаем по чужой строке.
|
||||
"""
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT expires_at_estimate FROM cian_session_cookies
|
||||
WHERE NOT CAST(:valid_only AS boolean)
|
||||
OR (expires_at_estimate > NOW()
|
||||
AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at))
|
||||
ORDER BY uploaded_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"valid_only": valid_only},
|
||||
).first()
|
||||
if row is None:
|
||||
return None
|
||||
expires_at: datetime | None = row[0]
|
||||
return expires_at
|
||||
|
||||
|
||||
def mark_session_invalid(db: Session, account_user_id: int) -> None:
|
||||
"""Flag session как expired/invalid (например после 401 во время scrape)."""
|
||||
db.execute(
|
||||
|
|
|
|||
|
|
@ -343,6 +343,10 @@ async def suggest_addresses(
|
|||
жёсткий фильтр (не boost) на уровне указанного admin-поля — доп.
|
||||
параметров не требуется. По умолчанию не задан — поведение (и body
|
||||
запроса) для существующих вызовов не меняется.
|
||||
ВАЖНО: значение сравнивается с полем DaData `region`, где имя лежит
|
||||
БЕЗ типа («Свердловская», а тип — отдельно в `region_type`="обл").
|
||||
Передашь «Свердловская область» — совпадений не будет, и запрос
|
||||
вернёт ПУСТО без всякой ошибки (hard-filter, не boost).
|
||||
|
||||
Returns:
|
||||
list[DadataSuggestion] — пустой список если:
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -25,6 +26,13 @@ from app.core.config import settings
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# За сколько дней до протухания кук предупреждать (#2674, по образцу #2658 для Циана).
|
||||
# Обновление кук — РУЧНАЯ операция (залить дамп через админку), человеку нужен запас:
|
||||
# сигнал по факту протухания приходит, когда обогащение уже встало. Прод 2026-08-03:
|
||||
# куки протухли, единственным следом был WARNING в docker-логе, который к тому же
|
||||
# теряется при редеплое. save_session ставит ttl 30 дней, так что окно широкое.
|
||||
COOKIE_EXPIRY_WARN_DAYS = 5
|
||||
|
||||
# Cookies критичные для DomClick auth (Sber ID) — фильтр перед сохранением.
|
||||
# Список составлен по реальному DevTools/Cookie-Editor дампу авторизованной
|
||||
# test-аккаунт сессии (Sber ID login), 2026-07-04.
|
||||
|
|
@ -148,6 +156,37 @@ def load_session(db: Session) -> dict[str, str] | None:
|
|||
return cookies
|
||||
|
||||
|
||||
def session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None:
|
||||
"""Когда протухают самые свежезагруженные куки (#2674, зеркалит cian_session #2658).
|
||||
|
||||
`load_session` отбирает только ещё валидные записи (expires_at_estimate > NOW()) и на
|
||||
протухших отдаёт None — вызывающий не мог отличить «кук никогда не загружали» от
|
||||
«протухли позавчера» и не мог предупредить ЗАРАНЕЕ.
|
||||
|
||||
valid_only=False (диагностика после None от load_session) — свежайшая запись любая:
|
||||
валидных по определению нет, нужен именно срок протухшей. valid_only=True — та же
|
||||
запись, которую взял бы load_session: для предупреждения «скоро протухнут» нужен срок
|
||||
ИМЕННО используемых кук, иначе при нескольких аккаунтах посчитаем по чужой строке.
|
||||
"""
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT expires_at_estimate FROM domclick_session_cookies
|
||||
WHERE NOT CAST(:valid_only AS boolean)
|
||||
OR (expires_at_estimate > NOW()
|
||||
AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at))
|
||||
ORDER BY uploaded_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"valid_only": valid_only},
|
||||
).first()
|
||||
if row is None:
|
||||
return None
|
||||
expires_at: datetime | None = row[0]
|
||||
return expires_at
|
||||
|
||||
|
||||
def mark_session_invalid(db: Session, account_cas_id: int) -> None:
|
||||
"""Flag session как expired/invalid (например после блока во время scrape)."""
|
||||
db.execute(
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ from scraper_kit.providers.cian.valuation import (
|
|||
estimate_via_cian_valuation,
|
||||
)
|
||||
from scraper_kit.providers.yandex.valuation import (
|
||||
ValuationHouseMeta,
|
||||
YandexValuationResult,
|
||||
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.scraper_adapters import RealScraperConfig
|
||||
from app.services.scraper_settings import get_scraper_delay
|
||||
from app.tasks.asking_to_sold_ratio import area_bucket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -165,6 +167,24 @@ def _estimate_consent_persist_fields(
|
|||
DKP_CORRIDOR_CITY_WIDE_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.
|
||||
# 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 = ЕКБ).
|
||||
|
|
@ -264,6 +284,55 @@ def _load_city_price_bands(db: Session) -> dict[str, tuple[int, int]]:
|
|||
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).
|
||||
# Если 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(
|
||||
db: Session,
|
||||
rooms: int | None,
|
||||
area_m2: float | None = None,
|
||||
anchor_ppm2: float | None = 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 строка
|
||||
(WHERE rooms_bucket = bucket AND district = '') → fallback на global -1
|
||||
|
|
@ -395,7 +475,7 @@ def _get_asking_sold_ratio(
|
|||
Таблицы нет / любая ошибка → (None, None), НЕ raise (graceful).
|
||||
Кэшируется на ключ 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)
|
||||
if cached is not None:
|
||||
|
|
@ -811,10 +891,15 @@ def _save_yandex_history_items(
|
|||
(address|publish_date|area|floor|prices) hash.
|
||||
|
||||
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]
|
||||
# — stable across re-runs, distinguishes pages for different addresses.
|
||||
address_seed = (result.address or "").strip().lower()
|
||||
|
|
@ -850,6 +935,12 @@ def _save_yandex_history_items(
|
|||
result.address,
|
||||
)
|
||||
|
||||
# Наблюдение о доме не зависит от того, есть ли на странице история объявлений.
|
||||
_save_yandex_house_panorama(db, house_id, result.house)
|
||||
|
||||
if not result.history_items:
|
||||
return 0
|
||||
|
||||
rows = []
|
||||
skipped_area = 0
|
||||
for item in result.history_items:
|
||||
|
|
@ -931,6 +1022,60 @@ def _save_yandex_history_items(
|
|||
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) ─────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -1686,6 +1831,20 @@ def _normalize_building_key(
|
|||
(корпус) схлопываются к base (тот же дом). Литеры — РАЗНЫЕ дома (204г ≠ 204д).
|
||||
- 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 если не извлёкся.
|
||||
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(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -1798,9 +1969,19 @@ def _fetch_anchor_comps(
|
|||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""Тированный набор комплов для same-building якоря. Стоп на 1-м тире с ≥ min_comps.
|
||||
|
||||
Tier A — SAME BUILDING: normalized street + base house no (+ литера если есть).
|
||||
RELAXED rooms (без фильтра), БЕЗ area±15%. Не группируем по house_id_fk —
|
||||
один дом дробится на несколько fk (Хохрякова 48 = 7085/9878/12797).
|
||||
Tier A — SAME BUILDING: normalized street + base house no (+ литера если есть)
|
||||
+ ST_DWithin(ANCHOR_TIER_A_RADIUS_M) от subject lat/lon (#2581 — до этого
|
||||
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
|
||||
вторичка + rooms match + area±25%. (Tier B «тот же ЖК» — skip: complex_id/cian_zhk_url
|
||||
ненадёжны.)
|
||||
|
|
@ -1816,7 +1997,7 @@ def _fetch_anchor_comps(
|
|||
|
||||
# ── Tier A: same building ────────────────────────────────────────────────
|
||||
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,
|
||||
# оператор ~. Boundary-regex вынесен в _house_boundary_regex (общий с
|
||||
# Tier S radius-fallback ниже, см. _fetch_analogs).
|
||||
|
|
@ -1835,11 +2016,20 @@ def _fetch_anchor_comps(
|
|||
AND price_per_m2 > 0
|
||||
AND lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like
|
||||
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 + "%",
|
||||
"house_re": house_re,
|
||||
"lon": lon,
|
||||
"lat": lat,
|
||||
"radius": ANCHOR_TIER_A_RADIUS_M,
|
||||
},
|
||||
)
|
||||
.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) —
|
||||
EKB-secondary-market calibration constants, но применяются ENGINE-WIDE (нет
|
||||
city-параметра ни здесь, ни у единственного вызывающего
|
||||
`_compute_same_building_anchor`). Реального импакта на не-ЕКБ область пока нет
|
||||
(same-building anchor pool для oblast сейчас не формируется — anchor_ppm2 сюда
|
||||
просто не доходит), но это доверие к отсутствию данных, а не к дизайну. Как
|
||||
только oblast anchor pools появятся (см. #oblast-D fallback выше), эти пороги
|
||||
нужно пересмотреть/сделать per-city — не оставлять ЕКБ-калибровку по умолчанию
|
||||
для другого рынка. No behavior change here (doc-only).
|
||||
`_compute_same_building_anchor`). #2581 update: та формулировка была НЕВЕРНОЙ —
|
||||
same-building anchor pool для oblast ВСЕГДА мог сформироваться (Tier A до
|
||||
#2581 не имел гео-предиката вообще, поэтому for oblast-subject'ов он либо
|
||||
молча тянул ЕКБ-листинги по одноимённой улице/дому, либо — для действительно
|
||||
уникальных названий — честно матчил местные листинги, если они были). После
|
||||
#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:
|
||||
return 0.04
|
||||
|
|
@ -2367,6 +2562,12 @@ class PricingResult:
|
|||
# headline. Anchor-путь → CV комплов (anchor["cv"]); radius-путь → CV
|
||||
# радиусной ₽/м²-выборки. None если <2 цен (недостаточно данных).
|
||||
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(
|
||||
|
|
@ -2445,10 +2646,45 @@ def _price_from_inputs(
|
|||
n_analogs = 0
|
||||
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_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)
|
||||
range_low = int(range_low * repair_coef)
|
||||
range_high = int(range_high * repair_coef)
|
||||
|
|
@ -2489,6 +2725,20 @@ def _price_from_inputs(
|
|||
area_widened,
|
||||
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 = ""
|
||||
|
|
@ -3041,9 +3291,20 @@ def _price_from_inputs(
|
|||
# generic ghost-anchor). No repair_state adjustment: the deal corridor
|
||||
# mixes conditions across sold units — unlike the listings comp pool,
|
||||
# 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 (
|
||||
median_ppm2 <= 0
|
||||
and anchor_tier is None
|
||||
and anchor is None
|
||||
and dkp_raw is not None
|
||||
and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N
|
||||
and dkp_raw.get("median_ppm2", 0) > 0
|
||||
|
|
@ -3055,16 +3316,27 @@ def _price_from_inputs(
|
|||
n_analogs = 0
|
||||
confidence = "low"
|
||||
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 "") + (
|
||||
" Рядом нет актуальных объявлений — оценка построена по реальным "
|
||||
f"{no_listings_clause} оценка построена по реальным "
|
||||
f"сделкам Росреестра ({dkp_raw['count']} шт. за {dkp_raw['period_months']} мес.),"
|
||||
" точность ориентировочная."
|
||||
)
|
||||
logger.info(
|
||||
"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),
|
||||
dkp_raw["count"],
|
||||
listings_headline_thin_n,
|
||||
)
|
||||
|
||||
# ── #652: ДКП-коридор реальных сделок (advisory) ─────────────────────────
|
||||
|
|
@ -3196,6 +3468,7 @@ def _price_from_inputs(
|
|||
sources_used_pre=sources_used_pre,
|
||||
listings_clean=listings_clean,
|
||||
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",
|
||||
)
|
||||
|
||||
# 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).
|
||||
geo: GeocodeResult | None = None
|
||||
# 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
|
||||
# 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
|
||||
# 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,
|
||||
|
|
@ -3274,8 +3548,12 @@ async def estimate_quality(
|
|||
payload.lon,
|
||||
)
|
||||
if geo is None and payload.address:
|
||||
# #2576: city_hint прокидывается из payload — БЕЗ него geocode() больше не
|
||||
# подставляет "Екатеринбург" молча (см. app.services.geocoder). Опционально:
|
||||
# фронт пока (до отдельного изменения UI) его не шлёт, geo.city_ambiguous
|
||||
# честно сигнализирует об этом ниже.
|
||||
geo = await _with_budget(
|
||||
geocode(payload.address, db),
|
||||
geocode(payload.address, db, city_hint=payload.city_hint),
|
||||
settings.estimate_geocode_budget_s,
|
||||
label="geocode",
|
||||
)
|
||||
|
|
@ -3380,6 +3658,16 @@ async def estimate_quality(
|
|||
if target_house_type is None:
|
||||
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):
|
||||
# 0) 1km + ±15% area + cohort match (year_built — если задан)
|
||||
# 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
|
||||
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)
|
||||
|
||||
if cohort_range is not None:
|
||||
|
|
@ -3456,6 +3751,7 @@ async def estimate_quality(
|
|||
listings = listings_wide
|
||||
fallback_used = True
|
||||
analog_tier = analog_tier_wide
|
||||
search_radius_m = fallback_radius_m
|
||||
|
||||
# Tier C: если даже на 2км мало — расширяем area tolerance до ±25%
|
||||
# (актуально для отдалённых районов / новостроек с нестандартной планировкой)
|
||||
|
|
@ -3480,6 +3776,7 @@ async def estimate_quality(
|
|||
fallback_used = True
|
||||
area_widened = True
|
||||
analog_tier = analog_tier_wa
|
||||
search_radius_m = fallback_radius_m
|
||||
|
||||
# ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ──────────────
|
||||
# #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы
|
||||
|
|
@ -3627,7 +3924,8 @@ async def estimate_quality(
|
|||
def _ratio_resolver(
|
||||
appm2: float | 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:
|
||||
return _lookup_quarter_index(
|
||||
|
|
@ -3692,6 +3990,7 @@ async def estimate_quality(
|
|||
ratio_basis = pr.ratio_basis
|
||||
listings_clean = pr.listings_clean
|
||||
cv = pr.cv
|
||||
listings_headline_thin_n = pr.listings_headline_thin_n
|
||||
|
||||
# 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals.
|
||||
# Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков
|
||||
|
|
@ -3727,6 +4026,14 @@ async def estimate_quality(
|
|||
# иначе «обновлено N мин назад»/дата парсинга/срок продажи относятся к другому
|
||||
# набору (или = None при пустом listings_clean, хотя у комплов данные есть).
|
||||
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:
|
||||
# display-consistency fix: только ЦЕНОВЫЕ листинги — та же популяция, что
|
||||
# дала n_analogs = len(prices_ppm2) в radius-ветке _price_from_inputs.
|
||||
|
|
@ -3984,6 +4291,7 @@ async def estimate_quality(
|
|||
target_address=geo.full_address,
|
||||
target_lat=geo.lat,
|
||||
target_lon=geo.lon,
|
||||
target_city_ambiguous=geo.city_ambiguous,
|
||||
sources_used=sources_used,
|
||||
data_freshness_minutes=freshness_min,
|
||||
last_scraped_at=last_scraped_at,
|
||||
|
|
@ -4033,6 +4341,11 @@ async def estimate_quality(
|
|||
metro_nearest=(dadata.metro if dadata and dadata.metro else []),
|
||||
address_precision=_qc_geo_to_precision(dadata.qc_geo if dadata else None),
|
||||
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_median_ppm2=premium_building_median_ppm2,
|
||||
premium_building_class=premium_building_class,
|
||||
|
|
@ -5621,11 +5934,6 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]:
|
|||
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(
|
||||
lot: dict[str, Any],
|
||||
*,
|
||||
|
|
@ -5663,15 +5971,13 @@ def _lot_dedup_components(
|
|||
return cad_s, house, cad_key, street_key
|
||||
|
||||
|
||||
def _phys_dedup_key(lot: dict[str, Any]) -> tuple[str, Any, int, int] | None:
|
||||
"""Первичный физический ключ (building, floor, area_bucket, price_bucket).
|
||||
|
||||
building = cadnum (надёжнее) ИЛИ street_token (#2265). None, если нет
|
||||
площади/цены или не из чего построить building. Сохраняет 4-кортежную форму
|
||||
(canonical-ключ; union-find в _dedup_cross_source использует оба композита).
|
||||
"""
|
||||
_cad_s, _house, cad_key, street_key = _lot_dedup_components(lot)
|
||||
return cad_key or street_key
|
||||
# #2674: здесь жили `_phys_dedup_key` и `_extract_street_token` — однострочные обёртки
|
||||
# над _lot_dedup_components / _parse_street_house. Прод не звал ни ту, ни другую ни разу
|
||||
# (25 ссылок, все из тестов). Хуже: _phys_dedup_key утверждала правило «первичный ключ =
|
||||
# кадастр ИЛИ улица», которого в проде нет — живой путь (_union_find_phys_dedup) держит
|
||||
# ОБА композита и сливает по любому совпадению, с guard'ами на разные кадастры/номера
|
||||
# домов. Тесты, проверявшие обёртку, проверяли не тот алгоритм; они переведены на живые
|
||||
# функции (tests/test_estimator_dedup_cross_source_2087.py).
|
||||
|
||||
|
||||
def _dedup_rep_key(lot: dict[str, Any]) -> tuple[float, int, str, str]:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -132,9 +132,16 @@ _COMPLETENESS_EXPR = """
|
|||
|
||||
# Keeper ORDER BY, shared by the ROW_NUMBER() rank and the first_value() keeper pick so they
|
||||
# agree row-for-row. Priority: geom present → most linked listings → most-populated → min id.
|
||||
#
|
||||
# NULLS LAST на listing_cnt (#2674): счётчик приходит из LEFT JOIN listing_counts, поэтому у дома
|
||||
# БЕЗ объявлений он NULL, а `DESC` в Postgres по умолчанию NULLS FIRST — то есть строка с нулём
|
||||
# объявлений обгоняла строку со 192 и забирала роль keeper'а, ровно наоборот задокументированному
|
||||
# правилу. Последствие не косметическое: объявления проигравшего переезжают на запись, на которую
|
||||
# корпус никогда не ссылался, а COALESCE-перенос полей неполон (год постройки / тип дома /
|
||||
# этажность / застройщик не переносятся) — данные богатого проигравшего удаляются безвозвратно.
|
||||
_KEEPER_ORDER = f"""
|
||||
(h.geom IS NOT NULL) DESC,
|
||||
listing_cnt DESC,
|
||||
listing_cnt DESC NULLS LAST,
|
||||
({_COMPLETENESS_EXPR}) DESC,
|
||||
h.id ASC
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from dataclasses import dataclass, field
|
|||
from typing import Literal
|
||||
|
||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||
from scraper_kit.house_type_normalizer import normalize_house_type
|
||||
|
||||
# #2337 (Group E4, эпик #2277): переключено на scraper_kit — тот же периметр риска,
|
||||
# что и estimator.py (обе точки читают/пишут house_imv_evaluations, #651 IMV/Yandex
|
||||
|
|
@ -65,22 +66,76 @@ _HEARTBEAT_EVERY_N_HOUSES = 5
|
|||
|
||||
# ── house_type normalisation ─────────────────────────────────────────────────
|
||||
|
||||
# Ключи — КАНОНИЧНЫЕ значения listings.house_type (после normalize_house_type),
|
||||
# значения — вокабуляр Avito IMV.
|
||||
_HOUSE_TYPE_TO_IMV: dict[str, str] = {
|
||||
"panel": "panel",
|
||||
"brick": "brick",
|
||||
"monolith": "monolithic",
|
||||
"monolithic": "monolithic",
|
||||
"monolith_brick": "monolithic", # Avito API не принимает гибриды
|
||||
"block": "block",
|
||||
"wood": "wood",
|
||||
}
|
||||
_HOUSE_TYPE_DEFAULT = "panel" # самый распространённый в ЕКБ
|
||||
|
||||
|
||||
def _map_house_type(raw: str | None) -> str:
|
||||
if not raw:
|
||||
return _HOUSE_TYPE_DEFAULT
|
||||
return _HOUSE_TYPE_TO_IMV.get(raw.lower().strip(), _HOUSE_TYPE_DEFAULT)
|
||||
def _map_house_type(raw: str | None) -> str | None:
|
||||
"""Наш house_type → вокабуляр Avito IMV. None = тип неизвестен, запрос не шлём.
|
||||
|
||||
Сырое значение сначала прогоняем через общий normalize_house_type (#2007): он
|
||||
знает camelCase-вокабуляр Циана (monolithBrick / gasSilicateBlock /
|
||||
aerocreteBlock / stalin / ...) и SCREAMING-вокабуляр Яндекса, а нераспознанное
|
||||
('other', 'wireframe', пустое) схлопывает в None. Приведения к нижнему регистру
|
||||
тут мало: ключ канона пишется через подчёркивание (monolith_brick), поэтому
|
||||
'monolithbrick' в словарь не попадал.
|
||||
|
||||
#2674: раньше здесь стоял дефолт 'panel' — и когда типа нет вовсе, и когда он
|
||||
есть, но не распознан. Панель — почти самый дешёвый класс (медиана по нашим же
|
||||
2685 оценкам: block 122.6k < panel 128.8k < brick 131.1k < monolithic 145.9k
|
||||
₽/м²), то есть дефолт систематически ЗАНИЖАЛ оценку: на проде 363 дома совсем
|
||||
без типа + 75 домов с camelCase-типом (56 из них monolithBrick, −11.7% к
|
||||
monolithic) уехали как панель. Теперь неизвестный тип → None → дом помечается
|
||||
и запрос к площадке не тратится (см. _process_one_house).
|
||||
"""
|
||||
canon = normalize_house_type(raw)
|
||||
if canon is None:
|
||||
return None
|
||||
return _HOUSE_TYPE_TO_IMV.get(canon)
|
||||
|
||||
|
||||
def _map_renovation_type(repair_state: str | None) -> str:
|
||||
"""listings.repair_state → renovation_type вокабуляра Avito IMV.
|
||||
|
||||
Переиспользуем _IMV_REPAIR_MAP эстиматора — единственный источник правды для
|
||||
этого соответствия (needs_repair→required / standard→cosmetic / good→euro /
|
||||
excellent→designer). Импорт ленивый: estimator тянет scraper_adapters, а тот
|
||||
импортирует этот модуль (circular — см. блок импортов выше).
|
||||
|
||||
#2674: раньше здесь стоял литерал 'cosmetic' — все 2685 запросов ушли как
|
||||
«косметический ремонт», хотя мода по объявлениям этих же домов совсем другая
|
||||
(standard 4564 / good 4118 / needs_repair 2279 / excellent 1631 — косметика
|
||||
лишь 36%).
|
||||
|
||||
Неизвестный ремонт (498 домов из 2685 — ни одного объявления с repair_state)
|
||||
ОСТАЁТСЯ 'cosmetic', в отличие от неизвестного типа дома: 'cosmetic'
|
||||
(=standard) — это одновременно МОДА и МЕДИАННАЯ категория популяции
|
||||
(standard 7984 / good 7116 / needs_repair 4738 / excellent 2562; кумулятивно
|
||||
needs_repair 21.2%, +standard 56.8%), то есть наилучшая одиночная догадка.
|
||||
У типа дома такой догадки нет: 'panel' — почти край шкалы, а не её середина.
|
||||
|
||||
Асимметрия осознанная, а не недосмотр: поштучный путь эстиматора при
|
||||
неизвестном ремонте IMV вообще не зовёт (estimator.py, `imv_renovation is not
|
||||
None`), а домовой дефолтит — иначе теряем ещё ~32% домов очереди поверх тех,
|
||||
что уже отсекает неизвестный тип дома.
|
||||
"""
|
||||
from app.services.estimator import _IMV_REPAIR_MAP # lazy — см. import-блок
|
||||
|
||||
mapped = _IMV_REPAIR_MAP.get(repair_state)
|
||||
if mapped is None and repair_state:
|
||||
# Непустое, но незнакомое значение — признак дрейфа вокабуляра на ингесте
|
||||
# (сырых repair-значений в listings больше, чем нормализованных). Паритет
|
||||
# с house_type_normalizer, который такой случай уже логирует.
|
||||
logger.debug("house_imv: unmapped repair_state %r — падаем в 'cosmetic'", repair_state)
|
||||
return mapped or "cosmetic"
|
||||
|
||||
|
||||
# ── Region bbox prefix для Avito geocoder ────────────────────────────────────
|
||||
|
|
@ -135,7 +190,8 @@ def pick_lot_params(db: Session, house_id: int) -> dict:
|
|||
AS integer) AS floor,
|
||||
CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY total_floors)
|
||||
AS integer) AS total_floors,
|
||||
mode() WITHIN GROUP (ORDER BY house_type) AS house_type
|
||||
mode() WITHIN GROUP (ORDER BY house_type) AS house_type,
|
||||
mode() WITHIN GROUP (ORDER BY repair_state) AS repair_state
|
||||
FROM listings
|
||||
WHERE house_id_fk = :hid
|
||||
AND rooms IS NOT NULL
|
||||
|
|
@ -173,7 +229,12 @@ def pick_lot_params(db: Session, house_id: int) -> dict:
|
|||
"floor": floor,
|
||||
"floor_at_home": floor_at_home,
|
||||
"house_type": _map_house_type(row["house_type"] or (house and house["house_type"])),
|
||||
"renovation_type": "cosmetic",
|
||||
"renovation_type": _map_renovation_type(row["repair_state"]),
|
||||
# has_balcony/has_loggia остаются константами намеренно (#2674): покрытие
|
||||
# listings.has_balcony 13.8%, listings.balcony_loggia 9.4%, и две колонки
|
||||
# противоречат друг другу (по has_balcony «есть» у 62%, а по
|
||||
# balcony_loggia самый частый случай — loggia 5650 против balcony 2794).
|
||||
# Мода по одному-двум объявлениям на таком покрытии — шум, а не данные.
|
||||
"has_balcony": True,
|
||||
"has_loggia": False,
|
||||
}
|
||||
|
|
@ -284,26 +345,37 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat
|
|||
)
|
||||
|
||||
# 3. Suggestions
|
||||
# #2674: до этого фикса в INSERT не входили image_link + area_m2/rooms/floor/
|
||||
# total_floors — колонки есть с миграции 064, но писатель их не заполнял
|
||||
# (25 055 строк на проде с NULL во всех пяти). Ссылка на фото приходит в
|
||||
# suggestions.items[].imageLink, метрики квартиры парсятся из title.
|
||||
for sug in result.suggestions:
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO house_suggestions (
|
||||
house_id, ext_item_id, title, address, price_rub,
|
||||
area_m2, rooms, floor, total_floors,
|
||||
exposure_days, publish_date,
|
||||
item_link, metro_name, metro_distance,
|
||||
item_link, image_link, metro_name, metro_distance,
|
||||
has_good_price_badge, raw_payload, fetched_at
|
||||
) VALUES (
|
||||
:hid, :ext, :title, :addr, :price,
|
||||
CAST(:area AS numeric), :rooms, :floor, :total_floors,
|
||||
:exp, :pdate,
|
||||
:link, :mname, :mdist,
|
||||
:link, :img, :mname, :mdist,
|
||||
:gpb, CAST(:raw AS jsonb), NOW()
|
||||
)
|
||||
ON CONFLICT (house_id, ext_item_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
price_rub = EXCLUDED.price_rub,
|
||||
area_m2 = EXCLUDED.area_m2,
|
||||
rooms = EXCLUDED.rooms,
|
||||
floor = EXCLUDED.floor,
|
||||
total_floors = EXCLUDED.total_floors,
|
||||
exposure_days = EXCLUDED.exposure_days,
|
||||
publish_date = EXCLUDED.publish_date,
|
||||
item_link = EXCLUDED.item_link,
|
||||
image_link = EXCLUDED.image_link,
|
||||
metro_name = EXCLUDED.metro_name,
|
||||
metro_distance = EXCLUDED.metro_distance,
|
||||
has_good_price_badge = EXCLUDED.has_good_price_badge,
|
||||
|
|
@ -316,9 +388,14 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat
|
|||
"title": sug.title,
|
||||
"addr": sug.address,
|
||||
"price": sug.price_rub,
|
||||
"area": sug.area_m2,
|
||||
"rooms": sug.rooms,
|
||||
"floor": sug.floor,
|
||||
"total_floors": sug.total_floors,
|
||||
"exp": sug.exposure_days,
|
||||
"pdate": sug.publish_date,
|
||||
"link": sug.item_url,
|
||||
"img": sug.image_link,
|
||||
"mname": sug.metro_name,
|
||||
"mdist": sug.metro_distance,
|
||||
"gpb": sug.has_good_price_badge,
|
||||
|
|
@ -500,8 +577,33 @@ async def backfill_house_imv(
|
|||
# + прокси переиспользуются всеми домами; обходит datacenter-403, #562/#853).
|
||||
# Флаг OFF → _bf=None → evaluate_via_imv делает свою curl-сессию как раньше
|
||||
# (поведение байт-в-байт идентично доспринтовому).
|
||||
#
|
||||
# #2698: proxy_provider/use_pool/environment — обязательная часть проводки, а не
|
||||
# опция. Без них BrowserFetcher не кладёт "proxy" в тело POST /fetch-json, и сайдкар
|
||||
# берёт свой env-прокси SCRAPER_PROXY_URL — на проде это узел пула id=1
|
||||
# (asocks-residential-1, provider_affinity='domclick'), который proxy_pool.acquire
|
||||
# («affinity IN (provider,'any')» + защита последнего узла выделенной affinity от
|
||||
# fallback) для avito не выдал бы НИКОГДА. Результат: 03.07-05.08 все 35 из 35 попыток
|
||||
# каждого прогона падали на геокодере A (1240 домов — 503 «browser unavailable», затем
|
||||
# 500 «Page.goto: NS_ERROR_PROXY_BAD_GATEWAY» и 403 от самого Авито), пока
|
||||
# avito_city_sweep/avito_newbuilding_sweep в те же дни тянули сотни объявлений через
|
||||
# ТОТ ЖЕ сайдкар и тот же инстанс камуфокса — они пул подключают (pipeline.py). Хуже:
|
||||
# запрос без "proxy" в теле ещё и роняет сайдкару желаемый прокси на env → relaunch
|
||||
# камуфокса на каждый дом (server.py::_ensure_browser).
|
||||
if settings.avito_imv_use_browser_fetcher:
|
||||
async with BrowserFetcher(source="avito", endpoint=settings.browser_http_endpoint) as _bf:
|
||||
# lazy import — тот же цикл scraper_adapters↔этот модуль, что и у RealScraperConfig.
|
||||
from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
|
||||
|
||||
_cfg = RealScraperConfig()
|
||||
async with BrowserFetcher(
|
||||
source="avito",
|
||||
endpoint=settings.browser_http_endpoint,
|
||||
proxy_provider=RealProxyProvider(),
|
||||
use_pool=_cfg.use_proxy_pool_browser,
|
||||
# #2616 шаг 1: без environment прод-отказ «пул пуст» мёртв на этом пути —
|
||||
# фетчер молча ушёл бы на тот самый env-прокси (см. _acquire_lease).
|
||||
environment=_cfg.environment,
|
||||
) as _bf:
|
||||
await _run_loop(_bf)
|
||||
else:
|
||||
await _run_loop(None)
|
||||
|
|
@ -652,6 +754,14 @@ async def _process_one_house(
|
|||
_mark_status(db, hid, "no_params", "no listings with rooms+area")
|
||||
return "no_params"
|
||||
|
||||
# #2674: тип дома неизвестен (нет ни в объявлениях, ни в houses — либо
|
||||
# вокабуляр не распознан). Раньше такой дом молча уезжал как 'panel' и
|
||||
# занижал оценку. Лучше не тратить запрос и честно пометить дом — тот же
|
||||
# путь, что и при отсутствии комнат/площади.
|
||||
if params["house_type"] is None:
|
||||
_mark_status(db, hid, "no_params", "unknown house_type")
|
||||
return "no_params"
|
||||
|
||||
address = house.get("address") or house.get("full_address")
|
||||
if not address:
|
||||
_mark_status(db, hid, "no_address", "house.address is NULL")
|
||||
|
|
|
|||
291
tradein-mvp/backend/app/services/identity_store.py
Normal file
291
tradein-mvp/backend/app/services/identity_store.py
Normal 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
|
||||
|
|
@ -13,9 +13,13 @@
|
|||
POI-score его не улавливал (POI ranking ≠ цена).
|
||||
|
||||
НОВЫЙ ПОКАЗАТЕЛЬ (location index):
|
||||
location_index_pct = (медиана ₽/м² сопоставимых активных листингов в радиусе точки −
|
||||
location_index_pct = (медиана ₽/м² сопоставимых листингов в радиусе точки −
|
||||
медиана ₽/м² по всему ЕКБ) / медиана по ЕКБ * 100
|
||||
|
||||
«Сопоставимые» = ровно тот же пул, что берёт эстиматор (#2660): активные И свежие
|
||||
(scraped_at в пределах LISTINGS_FRESH_DAYS — `is_active` на проде не равно «живо») И
|
||||
только вторичка (гард #1186 — девелоперский прайс новостроек завышал обе медианы).
|
||||
|
||||
Самообновляем (те же `listings`, что уже скрейпятся под estimator), интерпретируем напрямую
|
||||
("район на N% дороже/дешевле среднего по городу"), устойчив к выбросам (percentile_cont(0.5) —
|
||||
медиана самой природой игнорирует единичные экстремумы, в отличие от mean/min/max), и НЕ зажат
|
||||
|
|
@ -45,6 +49,11 @@ from typing import Any
|
|||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
|
||||
# #2660: окно свежести берём ИЗ эстиматора — единственное определение в проекте.
|
||||
# Дублировать значение здесь нельзя: две константы разъедутся при первой же
|
||||
# перекалибровке, и витрина начнёт показывать другой пул, чем считает цена.
|
||||
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Гео-охват продукта: только Екатеринбург ──────────────────────────────────
|
||||
|
|
@ -159,6 +168,43 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
|
|||
# price_per_m2 BETWEEN sanity-границы — не бизнес-калибровка, а защита от битых строк
|
||||
# (см. _PRICE_PER_M2_SANITY_MIN/MAX выше).
|
||||
#
|
||||
# #2660 свежесть + сегмент — оба предиката ЗЕРКАЛЯТ _COMMON_WHERE эстиматора.
|
||||
# Вклад у них РАЗНЫЙ, и не тот, на который легко подумать. Прод-разложение
|
||||
# (2026-08-05, пул location_index — bbox ЕКБ + sanity ₽/м² + geo_precision):
|
||||
#
|
||||
# было (только is_active) 30 222 строк 172 984 ₽/м²
|
||||
# + только свежесть 11 453 строк 163 363 ₽/м²
|
||||
# + только сегмент 11 219 строк 147 632 ₽/м²
|
||||
# стало (оба) 7 715 строк 147 368 ₽/м²
|
||||
#
|
||||
# - listing_segment guard (#1186) — ЭТО и есть исправление смещения: из −14.8%
|
||||
# сдвига городской медианы он даёт −14.7 п.п. Девелоперский прайс новостроек
|
||||
# завышал и локальную, и городскую медиану. NULL = legacy вторичка до м.011.
|
||||
# Мертвецы, кстати, живут почти целиком тут же: из 18 769 протухших строк
|
||||
# пула 15 265 — новостройки, и гард выносит их заодно.
|
||||
# - scraped_at > NOW() - LISTINGS_FRESH_DAYS — даёт ПОВЕРХ сегмента всего
|
||||
# −0.18 п.п. Для ЭТОЙ метрики он не коррекция смещения, а СТРАХОВКА на
|
||||
# будущее (пул совпадает с пулом цены; если завтра протухнет вторичка —
|
||||
# виджет не соврёт), и страховка не бесплатная: выбрасывает 3 504 вторичных
|
||||
# строки, из которых 2 724 — живые объявления, отскрейпленные 15-30 дней
|
||||
# назад. Пул −31%, шум растёт: на центре ЕКБ (r=800) n падает 423 → 86, а
|
||||
# сам индекс гуляет по выбору окна на 12-14 п.п. (7д +75.7% / 14д +77.0% /
|
||||
# 21д +79.1% / 30д +64.7%) — при n=86 это в пределах шума выборки медианы.
|
||||
# Размен «меньше смещения ↔ больше дисперсии» сделан осознанно: старое число
|
||||
# было предвзятым, новое — шумным, но честным. Окно менять здесь НЕ надо,
|
||||
# LISTINGS_FRESH_DAYS живёт в estimator.py (см. импорт выше).
|
||||
#
|
||||
# НОВЫЙ РЕЖИМ ОТКАЗА (знать обязательно): свежесть связала витрину со здоровьем
|
||||
# СБОРА. Встанет скрейпинг на LISTINGS_FRESH_DAYS — городская выборка не наберёт
|
||||
# MIN_SAMPLE_SIZE, и "insufficient_data" прилетит ВСЕМ пользователям разом; до
|
||||
# этой правки виджет продолжал бы показывать устаревшее число. Учитывая, что
|
||||
# #2574 — ровно месяц молчаливой поломки сбора, сценарий не гипотетический.
|
||||
# Деградация честная (прочерк, а не выдуманное число), но она теперь массовая.
|
||||
#
|
||||
# Порог MIN_SAMPLE_SIZE после сужения пула набирается реже, но лестница радиусов
|
||||
# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок:
|
||||
# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246.
|
||||
#
|
||||
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если
|
||||
# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index
|
||||
# явно ограничил владелец продукта.
|
||||
|
|
@ -173,6 +219,8 @@ _MEDIAN_PPM2_LOCAL_SQL = text(
|
|||
AND price_per_m2 IS NOT NULL
|
||||
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
|
||||
AND (geo_precision IS DISTINCT FROM 'city')
|
||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||||
AND CAST(:bbox_north AS double precision)
|
||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||
|
|
@ -196,6 +244,8 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
|
|||
AND price_per_m2 IS NOT NULL
|
||||
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
|
||||
AND (geo_precision IS DISTINCT FROM 'city')
|
||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||||
AND CAST(:bbox_north AS double precision)
|
||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||
|
|
@ -235,6 +285,7 @@ def _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[
|
|||
"lat": lat,
|
||||
"lon": lon,
|
||||
"radius_m": radius_m,
|
||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||
"bbox_south": _EKB_BBOX_SOUTH,
|
||||
|
|
@ -257,6 +308,7 @@ def _citywide_median_ppm2(db: Any) -> tuple[float | None, int]:
|
|||
db.execute(
|
||||
_MEDIAN_PPM2_CITYWIDE_SQL,
|
||||
{
|
||||
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||
"bbox_south": _EKB_BBOX_SOUTH,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,41 @@
|
|||
"""House cross-source matching — tiered algorithm.
|
||||
|
||||
`match_or_create_house` (путь скрейпинга, создаёт дома):
|
||||
Tier 0 (confidence 1.0): cadastral_number exact match on houses table.
|
||||
Tier 0.5 (confidence 0.95): house_fias_id (ГАР OBJECTGUID) exact match, case-insensitive.
|
||||
Tier 1 (confidence 1.0): ext_source + ext_id already in house_sources.
|
||||
Tier 2 (confidence 0.9): address_fingerprint match in house_address_aliases.
|
||||
Tier 3 (confidence 0.7): geo-proximity within 30 m (PostGIS ST_DWithin).
|
||||
New (confidence 1.0): INSERT new canonical house.
|
||||
|
||||
`match_house_readonly` (путь estimate-таргета, ничего не создаёт) дополнительно
|
||||
имеет Tier 0.5 fias_exact — у него ЕСТЬ источник ФИАС (DaData /suggest в
|
||||
`estimator.resolve_target_house`), см. docstring функции.
|
||||
|
||||
ЧЕСТНОСТЬ ТИРОВ (#2674, замер на проде 2026-08-05, 49 502 строки house_sources):
|
||||
fingerprint 58.97% · new 22.65% · geo_proximity 18.36% ·
|
||||
**cadastr_exact 0 · fias_exact 0** — верхние тиры не срабатывали НИ РАЗУ.
|
||||
|
||||
• Tier 0.5 fias_exact из `match_or_create_house` УДАЛЁН: параметра `house_fias_id`
|
||||
нет ни в Protocol `scraper_kit.contracts.HouseMatcher`, ни в
|
||||
`app.services.scraper_adapters.RealMatcherAdapter`, ни у двух прямых вызывающих
|
||||
(`estimator._save_yandex_history_items`, `scripts/backfill_listing_sources.py`) —
|
||||
передать его было НЕКОМУ. Регресс сторожит
|
||||
tests/test_matching_tier_reachability_2674.py.
|
||||
• Tier 0 cadastr_exact ОСТАВЛЕН: он достижим по построению (`ScrapedLot.
|
||||
building_cadastral_number` → адаптер → сюда), но площадки кадастр не отдают:
|
||||
`listings.cadastral_number` 0/93 408, а все 28 504 заполненных
|
||||
`listings.building_cadastral_number` — на 100% из локального гео-зеркала ЕГРН
|
||||
(`tasks/cadastral_geo_match.py`, KNN ≤50 м), т.е. появляются ПОСЛЕ матчинга и
|
||||
обратно в матчер не подаются. Подавать их сюда НЕЛЬЗЯ: как ключ здания KNN-кадастр
|
||||
не инъективен — 656 из 3 260 значений накрывают >1 ГАР-здание (20.1%), это был бы
|
||||
over-merge с confidence 1.0. Оставлен как рабочий приёмник на случай, если площадка
|
||||
начнёт отдавать настоящий кадастр — но приёмник СУЖЕН до кадастра ЗДАНИЯ: параметр
|
||||
`cadastral_number` (кадастр КВАРТИРЫ) убран из сигнатуры, Protocol и обоих вызывающих.
|
||||
Он был отложенной миной: у каждой квартиры свой номер, Tier 0 не сматчил бы никогда,
|
||||
падение в New-house INSERT записало бы номер квартиры в `houses.cadastral_number` и
|
||||
попутно сняло P1-страж «безномерный адрес без кадастра не создаём» — по дому на
|
||||
квартиру. В `listings` оба поля пишутся как раньше; из ключа дома ушло только ложное.
|
||||
|
||||
Algorithm reference: decisions/Cross_Source_Matching_Strategy.md sec 3
|
||||
"""
|
||||
|
||||
|
|
@ -46,8 +75,6 @@ def match_or_create_house(
|
|||
*,
|
||||
year_built: int | None = None,
|
||||
building_cadastral_number: str | None = None,
|
||||
cadastral_number: str | None = None,
|
||||
house_fias_id: str | None = None,
|
||||
source_url: str | None = None,
|
||||
) -> tuple[int | None, float, str]:
|
||||
"""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
|
||||
house row. Closes finding #1 from 2026-05-24 audit.
|
||||
|
||||
Args:
|
||||
house_fias_id: ГАР OBJECTGUID (UUID) of the building, when known upstream
|
||||
(e.g. DaData /clean/address). Enables Tier 0.5 fias_exact — additive and
|
||||
optional, existing callers are unaffected.
|
||||
NB: параметра `house_fias_id` здесь НЕТ намеренно (#2674) — см. шапку модуля.
|
||||
ФИАС-тир живёт только в `match_house_readonly`, у которого есть источник ФИАС.
|
||||
|
||||
Returns:
|
||||
(house_id, confidence ∈ [0.0, 1.0], method ∈ {
|
||||
'cadastr_exact', 'fias_exact', 'source_exact', 'fingerprint',
|
||||
'cadastr_exact', 'source_exact', 'fingerprint',
|
||||
'geo_proximity', 'new', 'no_house_number'
|
||||
})
|
||||
house_id is None only for the 'no_house_number' terminal case below.
|
||||
|
||||
Method values:
|
||||
'cadastr_exact' — matched by cadastral number (confidence 1.0)
|
||||
'fias_exact' — matched by house_fias_id (ГАР OBJECTGUID) (confidence 0.95)
|
||||
'source_exact' — already in house_sources for this source+ext_id (confidence 1.0)
|
||||
'fingerprint' — matched by address fingerprint (confidence 0.9)
|
||||
'geo_proximity' — matched by geo within 30 m (confidence 0.7)
|
||||
|
|
@ -87,7 +111,16 @@ def match_or_create_house(
|
|||
'р-н Чкаловский, мкр. Вторчермет' 480). A cadastral number is a precise building
|
||||
identity, so cad-carrying rows stay exempt (Tier 0 owns them).
|
||||
"""
|
||||
cad = building_cadastral_number or cadastral_number
|
||||
# ТОЛЬКО кадастр ЗДАНИЯ (#2674). Раньше было `building_cadastral_number or cadastral_number`,
|
||||
# где второе — кадастр КВАРТИРЫ (у каждой свой), и параметр `cadastral_number` тоже убран из
|
||||
# сигнатуры. Пока площадки не отдают ни того ни другого, фолбэк спал; но он и есть ловушка,
|
||||
# ради которой мы «оставили рабочий приёмник»: начни Циан отдавать `offer["cadastralNumber"]`
|
||||
# (парсер читает именно его), квартирный номер поехал бы в ключ ЗДАНИЯ. Tier 0 не сматчил бы
|
||||
# никогда (у каждой квартиры свой номер) → падение в New-house INSERT → номер КВАРТИРЫ
|
||||
# проштампован в houses.cadastral_number, плюс снят P1-страж ниже («безномерный адрес без
|
||||
# кадастра не создаём» — `cad` там же и разрешает создание). Две квартиры одного дома дали бы
|
||||
# два дома — то самое дробление, против которого Tier 0 и заведён.
|
||||
cad = building_cadastral_number
|
||||
|
||||
# Compute fingerprint early so we can acquire the advisory lock before any tier reads.
|
||||
fp = address_fingerprint(address, lat, lon)
|
||||
|
|
@ -136,34 +169,10 @@ def match_or_create_house(
|
|||
logger.info("house match cadastr_exact house_id=%s cad=%s", house_id, cad)
|
||||
return (house_id, 1.0, "cadastr_exact")
|
||||
|
||||
# Tier 0.5: house_fias_id (ГАР OBJECTGUID) exact match, case-insensitive.
|
||||
# Stable ORDER BY id so concurrent/duplicate rows resolve deterministically.
|
||||
if house_fias_id:
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"SELECT id FROM houses "
|
||||
"WHERE lower(house_fias_id) = lower(CAST(:fias AS text)) "
|
||||
"ORDER BY id ASC LIMIT 1"
|
||||
),
|
||||
{"fias": house_fias_id},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
if row:
|
||||
house_id = int(row["id"])
|
||||
_upsert_house_source(
|
||||
db,
|
||||
house_id=house_id,
|
||||
ext_source=ext_source,
|
||||
ext_id=ext_id,
|
||||
method="fias_exact",
|
||||
confidence=0.95,
|
||||
)
|
||||
_insert_alias(db, house_id=house_id, address=address, fp=fp, source=ext_source)
|
||||
logger.info("house match fias_exact house_id=%s fias=%s", house_id, house_fias_id)
|
||||
return (house_id, 0.95, "fias_exact")
|
||||
# Tier 0.5 fias_exact удалён (#2674): передать `house_fias_id` в этот путь было
|
||||
# некому — ни Protocol HouseMatcher, ни RealMatcherAdapter, ни оба прямых вызывающих
|
||||
# такого параметра не имели, поэтому за всю историю тир не сработал ни разу (0 из
|
||||
# 49 502 house_sources). Живой ФИАС-тир остался в match_house_readonly.
|
||||
|
||||
# Tier 1: source+ext_id already registered in house_sources
|
||||
row = (
|
||||
|
|
|
|||
17
tradein-mvp/backend/app/services/payments/__init__.py
Normal file
17
tradein-mvp/backend/app/services/payments/__init__.py
Normal 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
|
||||
143
tradein-mvp/backend/app/services/payments/receipt.py
Normal file
143
tradein-mvp/backend/app/services/payments/receipt.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""Сборка объекта `Receipt` (54-ФЗ, ФФД 1.05) для чека Т-Банк эквайринга.
|
||||
|
||||
Продукт продаёт УСЛУГУ (не товар) — везде фиксированы `PaymentObject="service"`
|
||||
и `PaymentMethod="full_payment"` (одномоментная оплата за уже готовую услугу,
|
||||
без предоплат/кредита/частичных расчётов).
|
||||
|
||||
Схема (`Receipt` в `Init`, ФФД 1.05) — источник, снят живым запросом
|
||||
2026-08-06: https://developer.tbank.ru/eacq/api/init
|
||||
|
||||
- `Email` ИЛИ `Phone` — обязательно хотя бы одно (перекрёстный required).
|
||||
- `Taxation` — обязателен: `osn|usn_income|usn_income_outcome|esn|patent`.
|
||||
- `Items[].Name` — <=128 символов, обязателен.
|
||||
- `Items[].Price`/`Quantity`/`Amount` — числа, В КОПЕЙКАХ; `Amount` — это
|
||||
произведение `Price * Quantity` (дословно из API-reference).
|
||||
- `Items[].Tax` — ставка НДС. Актуальный список 2026 (Init API reference):
|
||||
`none|vat0|vat5|vat7|vat10|vat22|vat105|vat107|vat110|vat122`.
|
||||
`vat20`/`vat120` В СПИСКЕ НЕТ — сняты, не использовать (см. recon §6/§11
|
||||
в `mera-tbank-acquiring-recon.md`, корень репо).
|
||||
|
||||
ВАЖНО: `Receipt` НЕ участвует в расчёте `Token` (`token.py` отсекает любые
|
||||
вложенные `dict`/`list` из подписи) — это архитектурно гарантировано самой
|
||||
функцией `token.sign`, а не соглашением здесь.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
TaxRate = Literal[
|
||||
"none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122"
|
||||
]
|
||||
|
||||
Taxation = Literal["osn", "usn_income", "usn_income_outcome", "esn", "patent"]
|
||||
|
||||
_ALLOWED_TAX_RATES: frozenset[str] = frozenset(
|
||||
{"none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122"}
|
||||
)
|
||||
_ALLOWED_TAXATION: frozenset[str] = frozenset(
|
||||
{"osn", "usn_income", "usn_income_outcome", "esn", "patent"}
|
||||
)
|
||||
|
||||
_MAX_ITEM_NAME_LEN = 128
|
||||
_MAX_ITEMS = 100 # "Количество товаров в чеке — не больше 100" (API reference)
|
||||
|
||||
|
||||
class ReceiptBuildError(ValueError):
|
||||
"""Невалидные данные для сборки Receipt — не пройдёт валидацию Т-Банка."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReceiptItem:
|
||||
"""Одна позиция чека — услуга. `price_kopecks`/`quantity` — целые копейки/штуки."""
|
||||
|
||||
name: str
|
||||
price_kopecks: int
|
||||
quantity: int = 1
|
||||
tax: TaxRate = "none"
|
||||
|
||||
@property
|
||||
def amount_kopecks(self) -> int:
|
||||
"""Items[].Amount = Price * Quantity (дословно из API reference)."""
|
||||
return self.price_kopecks * self.quantity
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
if not self.name or len(self.name) > _MAX_ITEM_NAME_LEN:
|
||||
raise ReceiptBuildError(
|
||||
f"Items[].Name должен быть 1..{_MAX_ITEM_NAME_LEN} символов, "
|
||||
f"получено {len(self.name)}"
|
||||
)
|
||||
if self.price_kopecks <= 0:
|
||||
raise ReceiptBuildError("Items[].Price должен быть > 0 (в копейках)")
|
||||
if self.quantity <= 0:
|
||||
raise ReceiptBuildError("Items[].Quantity должен быть > 0")
|
||||
if self.tax not in _ALLOWED_TAX_RATES:
|
||||
raise ReceiptBuildError(
|
||||
f"Items[].Tax={self.tax!r} не входит в актуальный список Т-Банка "
|
||||
f"({sorted(_ALLOWED_TAX_RATES)}) — vat20/vat120 сняты, не используются"
|
||||
)
|
||||
return {
|
||||
"Name": self.name,
|
||||
"Price": self.price_kopecks,
|
||||
"Quantity": self.quantity,
|
||||
"Amount": self.amount_kopecks,
|
||||
"Tax": self.tax,
|
||||
"PaymentMethod": "full_payment",
|
||||
"PaymentObject": "service",
|
||||
}
|
||||
|
||||
|
||||
def build_receipt(
|
||||
*,
|
||||
items: list[ReceiptItem],
|
||||
taxation: Taxation,
|
||||
email: str | None = None,
|
||||
phone: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Собирает `Receipt` (ФФД 1.05) для одного заказа (может быть >1 позиции).
|
||||
|
||||
Инвариант «сумма Items[].Amount == Init.Amount» здесь НЕ проверяется —
|
||||
`Receipt` строится независимо от `Init`-payload заказа. Сверка — на
|
||||
вызывающей стороне (`service.py`, следующий PR) через
|
||||
`receipt_total_kopecks(receipt) == init_amount_kopecks`. См. тест
|
||||
`test_receipt_total_matches_order_amount_invariant` в
|
||||
`tests/test_payments_receipt.py`, который проверяет именно эту сверку.
|
||||
"""
|
||||
if not items:
|
||||
raise ReceiptBuildError("Receipt.Items не может быть пустым")
|
||||
if len(items) > _MAX_ITEMS:
|
||||
raise ReceiptBuildError(f"Receipt.Items — не больше {_MAX_ITEMS} позиций")
|
||||
if taxation not in _ALLOWED_TAXATION:
|
||||
raise ReceiptBuildError(
|
||||
f"Taxation={taxation!r} не входит в допустимый список ({sorted(_ALLOWED_TAXATION)})"
|
||||
)
|
||||
|
||||
email_norm = (email or "").strip() or None
|
||||
phone_norm = (phone or "").strip() or None
|
||||
if not email_norm and not phone_norm:
|
||||
raise ReceiptBuildError("Нужно указать Email или Phone (хотя бы одно)")
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"Taxation": taxation,
|
||||
"Items": [item.to_payload() for item in items],
|
||||
}
|
||||
if email_norm:
|
||||
payload["Email"] = email_norm
|
||||
if phone_norm:
|
||||
payload["Phone"] = phone_norm
|
||||
return payload
|
||||
|
||||
|
||||
def receipt_total_kopecks(receipt: dict[str, Any]) -> int:
|
||||
"""Сумма `Items[].Amount` — для сверки вызывающей стороной с `Init.Amount`."""
|
||||
items = receipt.get("Items")
|
||||
if not isinstance(items, list):
|
||||
return 0
|
||||
total = 0
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
amount = item.get("Amount")
|
||||
if isinstance(amount, int):
|
||||
total += amount
|
||||
return total
|
||||
249
tradein-mvp/backend/app/services/payments/tbank_client.py
Normal file
249
tradein-mvp/backend/app/services/payments/tbank_client.py
Normal 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)
|
||||
98
tradein-mvp/backend/app/services/payments/token.py
Normal file
98
tradein-mvp/backend/app/services/payments/token.py
Normal 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)
|
||||
|
|
@ -21,8 +21,10 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from scraper_kit.orchestration import runs as kit_runs
|
||||
from scraper_kit.orchestration.scheduler import (
|
||||
Handler,
|
||||
reschedule_after_minutes,
|
||||
|
|
@ -39,39 +41,97 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
# ── cian_history_backfill — cookie-gated backfill ────────────────────────────
|
||||
# Машиночитаемые причины пропуска (#2658) — пишутся в scrape_runs.error строки
|
||||
# со status='skipped'. Отделены от kit-причин (already_running и т.п.): по слагу
|
||||
# видно, встал ли сбор из-за кук или из-за конкурентного прогона.
|
||||
SKIP_CIAN_COOKIES_MISSING = "cian_cookies_missing"
|
||||
SKIP_CIAN_COOKIES_EXPIRED = "cian_cookies_expired"
|
||||
SKIP_CIAN_COOKIES_INVALID = "cian_cookies_invalid"
|
||||
|
||||
|
||||
def _alert_cian_cookies(source: str, detail: str) -> None:
|
||||
"""Громкий алерт «сбор встал из-за кук» — logger.error, НЕ capture_message(warning).
|
||||
|
||||
В scraper-контейнере GlitchTip поднят с LoggingIntegration(event_level=ERROR)
|
||||
(scheduler_main.py) — ERROR-запись сама становится событием, а прежний
|
||||
`capture_message(..., level="warning")` до этого уровня не дотягивал (и стоял в
|
||||
недостижимой ветке, см. докстринг _cian_pre_claim). Заодно причина остаётся в
|
||||
docker-логах и в строке scrape_runs, которая переживает редеплой.
|
||||
"""
|
||||
logger.error(
|
||||
"scheduler: %s пропущен — %s. Перезалейте куки Циана через админку "
|
||||
"(до этого backfill истории стоит)",
|
||||
source,
|
||||
detail,
|
||||
)
|
||||
|
||||
|
||||
async def _cian_pre_claim(db: Session, schedule_row: dict[str, Any], ctx: SchedulerContext) -> bool:
|
||||
"""Pre-claim gate: проверить наличие/валидность cian-cookies ДО claim (#1522).
|
||||
|
||||
Cookies отсутствуют/протухли → defer next_run_at на следующее окно и skip
|
||||
(иначе get_due_schedules переотбирает schedule каждые 60с и verify_session
|
||||
долбит Cian круглосуточно). Дословно из боевого trigger_cian_backfill_run.
|
||||
"""
|
||||
import sentry_sdk
|
||||
Cookies отсутствуют/протухли → пишем строку прогона status='skipped' с причиной,
|
||||
двигаем next_run_at на следующее окно и skip (иначе get_due_schedules переотбирает
|
||||
schedule каждые 60с и verify_session долбит Cian круглосуточно).
|
||||
|
||||
from app.services.cian_session import load_session, verify_session
|
||||
#2658 — что было не так. Первая ветка (load_session вернул None) молчала: warning в
|
||||
docker-лог, сдвиг next_run_at, `return False`. Ни строки в scrape_runs, ни изменения
|
||||
last_run_at — снаружи 37 дней простоя выглядели как «всё по расписанию». Sentry-алерт
|
||||
стоял во ВТОРОЙ ветке (verify_session вернул None), до которой на протухших куках
|
||||
исполнение не доходит НИКОГДА: load_session сам фильтрует expires_at_estimate > NOW()
|
||||
и отдаёт None ещё в первой. Теперь громко в обеих + предупреждение ЗАРАНЕЕ, пока куки
|
||||
ещё валидны (COOKIE_EXPIRY_WARN_DAYS) — обновление кук ручное, ему нужен запас.
|
||||
"""
|
||||
from app.services.cian_session import (
|
||||
COOKIE_EXPIRY_WARN_DAYS,
|
||||
load_session,
|
||||
session_expires_at,
|
||||
verify_session,
|
||||
)
|
||||
|
||||
source: str = schedule_row["source"]
|
||||
now = datetime.now(tz=UTC)
|
||||
|
||||
cookies = load_session(db)
|
||||
if cookies is None:
|
||||
logger.warning("scheduler: cian_history_backfill skipped — no valid session cookies in DB")
|
||||
expires_at = session_expires_at(db)
|
||||
if expires_at is None:
|
||||
reason, detail = SKIP_CIAN_COOKIES_MISSING, "кук Циана нет в БД"
|
||||
elif expires_at <= now:
|
||||
reason = SKIP_CIAN_COOKIES_EXPIRED
|
||||
detail = (
|
||||
f"куки Циана протухли {expires_at:%Y-%m-%d} ({(now - expires_at).days} дн. назад)"
|
||||
)
|
||||
else:
|
||||
reason = SKIP_CIAN_COOKIES_INVALID
|
||||
detail = "куки Циана помечены невалидными (last_invalid_at)"
|
||||
_alert_cian_cookies(source, detail)
|
||||
kit_runs.mark_skipped(db, source=source, reason=reason, details=detail)
|
||||
kit_defer_next_run_at(db, schedule_row)
|
||||
return False
|
||||
|
||||
state = await verify_session(cookies)
|
||||
if state is None:
|
||||
logger.warning(
|
||||
"scheduler: cian_history_backfill — cookies expired or invalid, skipping run"
|
||||
)
|
||||
try:
|
||||
sentry_sdk.capture_message(
|
||||
"cian_history_backfill skipped: Cian session cookies expired — "
|
||||
"please re-upload via admin UI",
|
||||
level="warning",
|
||||
)
|
||||
except Exception:
|
||||
pass # sentry_sdk not initialised in dev
|
||||
# verify вернул именно None (401 / isAuthenticated=false) — куки числятся
|
||||
# валидными по сроку, но Циан их не принимает. Sentinel-ответы (бан / источник
|
||||
# недоступен / сменилась вёрстка) сюда НЕ попадают, они truthy — см. cian_session.
|
||||
detail = "Циан не принимает куки (разлогин)"
|
||||
_alert_cian_cookies(source, detail)
|
||||
kit_runs.mark_skipped(db, source=source, reason=SKIP_CIAN_COOKIES_INVALID, details=detail)
|
||||
kit_defer_next_run_at(db, schedule_row)
|
||||
return False
|
||||
|
||||
# Куки рабочие — предупреждаем, пока есть время их обновить без простоя сбора.
|
||||
# valid_only=True: срок ИМЕННО той записи, которую взял load_session (при нескольких
|
||||
# аккаунтах свежайшая-любая может быть чужой протухшей строкой).
|
||||
expires_at = session_expires_at(db, valid_only=True)
|
||||
if expires_at is not None and expires_at - now <= timedelta(days=COOKIE_EXPIRY_WARN_DAYS):
|
||||
logger.error(
|
||||
"scheduler: куки Циана протухнут %s (осталось %.1f дн.) — обновите заранее, "
|
||||
"иначе %s встанет молча",
|
||||
expires_at.date().isoformat(),
|
||||
(expires_at - now).total_seconds() / 86400,
|
||||
source,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
|
|
@ -94,13 +154,15 @@ async def _job_rosreestr_dkp(
|
|||
|
||||
|
||||
# ── 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(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
) -> None:
|
||||
from app.tasks.listing_source_snapshot import snapshot_listing_sources
|
||||
|
||||
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 ─────────────────
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
# ── 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) ────
|
||||
async def _job_refresh_search_matview(
|
||||
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
|
||||
) -> None:
|
||||
from app.core.config import settings as _settings
|
||||
from app.tasks.deactivate_stale_avito import deactivate_stale_listings
|
||||
from app.tasks.deactivate_stale_avito import (
|
||||
DEFAULT_MIN_CONFIRMATIONS,
|
||||
deactivate_stale_listings,
|
||||
)
|
||||
|
||||
listing_source: str = params.get("listing_source", "avito")
|
||||
ttl_days: int = params.get("ttl_days", _settings.avito_stale_ttl_days)
|
||||
segments: list[str] | None = params.get("segments")
|
||||
staleness_column: str = params.get("staleness_column", "last_seen_at")
|
||||
# Гейт по здоровью сбора (#2659) включён по умолчанию: незасеянное расписание
|
||||
# получает страховочный порог, а не «деактивируй вслепую». Посчитанные по
|
||||
# источнику пороги приходят из default_params (миграция 219).
|
||||
min_confirmations: int = params.get("min_confirmations", DEFAULT_MIN_CONFIRMATIONS)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
|
|
@ -161,6 +240,7 @@ async def _job_deactivate_stale(
|
|||
ttl_days=ttl_days,
|
||||
segments=segments,
|
||||
staleness_column=staleness_column,
|
||||
min_confirmations=min_confirmations,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -329,17 +409,38 @@ async def _job_house_imv_backfill(
|
|||
only_status=only_status,
|
||||
heartbeat=_heartbeat,
|
||||
)
|
||||
ctx.runs.mark_done(
|
||||
db,
|
||||
run_id,
|
||||
{
|
||||
counters = {
|
||||
"checked": result.checked,
|
||||
"saved": result.saved,
|
||||
"skipped": result.skipped,
|
||||
"errors": result.errors,
|
||||
"duration_sec": int(result.duration_sec),
|
||||
},
|
||||
# #2674: _column_counts (scrape_runs.py) берёт выделенные колонки из
|
||||
# ключей total_seen|lots_fetched и new_count|lots_inserted — ни одного
|
||||
# из них тут не было, поэтому все 39 прогонов этого source лежат в БД
|
||||
# с total_seen=0. А mark_done по этой же колонке шлёт алерт «3 подряд
|
||||
# done с нулевым результатом» (#2625) — то есть даже идеальный прогон
|
||||
# с 50 сохранёнными считался бы нулевым и через три дня выстрелил бы
|
||||
# ложной тревогой про капчу.
|
||||
# Трейд-офф: на исчерпанной очереди checked=0 три дня подряд тоже даст
|
||||
# алерт — но пустая очередь при ежедневном расписании это и правда сигнал.
|
||||
"total_seen": result.checked,
|
||||
"new_count": result.saved,
|
||||
}
|
||||
# Честный статус (#2674, тот же класс, что #2670/#2657): успех — это
|
||||
# «сделали то, что собирались», а не «не поймали известное исключение».
|
||||
# На проде так ушли в done 31 прогон подряд: saved=0 при errors≈35 из 50.
|
||||
# Ноль сохранённых БЕЗ ошибок (всё отфильтровано в skipped) — честная
|
||||
# пустота, она по-прежнему done.
|
||||
if result.saved == 0 and result.errors > 0:
|
||||
ctx.runs.mark_failed(
|
||||
db,
|
||||
run_id,
|
||||
f"saved=0 при errors={result.errors} (checked={result.checked})",
|
||||
counters,
|
||||
)
|
||||
else:
|
||||
ctx.runs.mark_done(db, run_id, counters)
|
||||
except Exception as exc:
|
||||
logger.exception("scheduler: house_imv_backfill crashed run_id=%d", run_id)
|
||||
try:
|
||||
|
|
@ -348,6 +449,57 @@ async def _job_house_imv_backfill(
|
|||
logger.exception("scheduler: mark_failed crashed run_id=%d", run_id)
|
||||
|
||||
|
||||
# ── domrf_kapremont_load — sync загрузка open data ДОМ.РФ в executor ─────────
|
||||
# #2674: loader (services/domrf_kapremont_loader.py) и CLI (tasks/domrf_kapremont_load.py)
|
||||
# написаны и покрыты тестами с #2013, но Handler'а и строки расписания не было — источник
|
||||
# запускали руками ровно один раз, 12.07.2026 (29 978 строк, один и тот же loaded_at у всех).
|
||||
# Это не мёртвый код, а оборванная проводка: нечему было его вызвать.
|
||||
async def _job_domrf_kapremont_load(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
) -> None:
|
||||
"""Скачать КР1.1+КР1.2 ДОМ.РФ → staging → backfill houses → propagate listings.
|
||||
|
||||
Тело переиспользует те же три функции, что и CLI (дизайн-инвариант модуля: не
|
||||
дублируем логику). Lifecycle не свой — mark_done/mark_failed здесь, как у
|
||||
_job_yandex_newbuilding_sweep.
|
||||
|
||||
Счётчики кладём в total_seen/new_count: `scrape_runs._column_counts` берёт выделенные
|
||||
колонки именно из этих ключей, и по ним же mark_done ловит «три подряд нулевых
|
||||
прогона» (#2625) — без них идеальный прогон лежал бы в БД как нулевой (тот же
|
||||
промах, что чинили у house_imv_backfill).
|
||||
"""
|
||||
from app.services.domrf_kapremont_loader import (
|
||||
backfill_houses_from_domrf,
|
||||
load_domrf_kapremont,
|
||||
propagate_listings_year_from_houses,
|
||||
)
|
||||
|
||||
def _run() -> dict[str, int]:
|
||||
load_counts = load_domrf_kapremont(db)
|
||||
db.commit()
|
||||
houses_counts = backfill_houses_from_domrf(db)
|
||||
listings_counts = propagate_listings_year_from_houses(db)
|
||||
db.commit()
|
||||
return {
|
||||
"kr11_rows": load_counts["kr11_rows"],
|
||||
"upserted": load_counts["upserted"],
|
||||
"houses_updated": houses_counts["houses_updated"],
|
||||
"listings_updated": listings_counts["listings_updated"],
|
||||
# см. докстринг: выделенные колонки прогона + гейт «нулевой прогон».
|
||||
"total_seen": load_counts["kr11_rows"],
|
||||
"new_count": houses_counts["houses_updated"] + listings_counts["listings_updated"],
|
||||
}
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
counters = await loop.run_in_executor(None, _run)
|
||||
ctx.runs.mark_done(db, run_id, counters)
|
||||
except Exception as exc:
|
||||
logger.exception("scheduler: domrf_kapremont_load crashed run_id=%d", run_id)
|
||||
db.rollback()
|
||||
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
|
||||
|
||||
|
||||
# ── purge_expired_trade_in_data — ЭТАП 4 B2C retention (152-ФЗ) ───────────────
|
||||
async def _job_purge_expired_trade_in_data(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
|
|
@ -400,8 +552,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
|||
"""Реестр НЕ-sweep продуктовых source→Handler для kit build_registry.
|
||||
|
||||
Kit-native sweeps (avito/yandex/cian/domclick city/full-load/newbuilding) НЕ здесь —
|
||||
их даёт build_registry(_default_kit_handlers). Здесь — 18 именованных + 1 wildcard
|
||||
их даёт build_registry(_default_kit_handlers). Здесь — именованные + 1 wildcard
|
||||
(deactivate_stale_*), покрывающие каждый НЕ-sweep source боевого scheduler-dispatch.
|
||||
(Число намеренно не названо: прежнее «19» разошлось с реальностью на пять записей.)
|
||||
|
||||
`ctx` — принят для симметрии контракта; сами Handler-job'ы получают ctx во время
|
||||
dispatch (см. kit `_dispatch`), поэтому здесь он не замыкается.
|
||||
|
|
@ -417,6 +570,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
|||
"asking_to_sold_ratio_refresh": Handler(
|
||||
_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"),
|
||||
"yandex_address_backfill": Handler(_job_yandex_address_backfill, "yandex_address_backfill"),
|
||||
"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"),
|
||||
"house_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"),
|
||||
"house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"),
|
||||
"domrf_kapremont_load": Handler(_job_domrf_kapremont_load, "domrf_kapremont_load"),
|
||||
"purge_expired_trade_in_data": Handler(
|
||||
_job_purge_expired_trade_in_data, "purge_expired_trade_in_data"
|
||||
),
|
||||
|
|
|
|||
|
|
@ -15,11 +15,68 @@ ipify-пробу через каждый прокси и обновляет heal
|
|||
за одну строку — второй параллельный вызов пропустит залоченную и возьмёт следующую).
|
||||
|
||||
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 прокси
|
||||
авто-disable (enabled=false), чтобы битый узел выпал из пула.
|
||||
- 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.
|
||||
"""
|
||||
|
||||
|
|
@ -36,16 +93,23 @@ from sqlalchemy.orm import Session
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"DISABLED_RECHECK_MINUTES",
|
||||
"DISABLE_THRESHOLD",
|
||||
"MAX_CONSECUTIVE_FAILS",
|
||||
"NON_RUN_LEASE_MARKER",
|
||||
"SOURCE_BAN_BASE_HOURS",
|
||||
"SOURCE_BAN_MAX_HOURS",
|
||||
"SOURCE_BAN_PURGE_DAYS",
|
||||
"STALE_LEASE_MINUTES",
|
||||
"ProxyLease",
|
||||
"acquire",
|
||||
"clear_source_bans",
|
||||
"mark_banned",
|
||||
"mark_health",
|
||||
"reap_stale_leases",
|
||||
"release",
|
||||
"run_proxy_healthcheck",
|
||||
"touch",
|
||||
]
|
||||
|
||||
# ── Пороги ───────────────────────────────────────────────────────────────────
|
||||
|
|
@ -62,14 +126,42 @@ DISABLE_THRESHOLD = 5
|
|||
# освобождается reap_stale_leases — иначе прокси навсегда «занят» мёртвым run'ом.
|
||||
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).
|
||||
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
|
||||
# /scraper/health (_probe_current_ip).
|
||||
_HEALTH_PROBE_URL = "https://api.ipify.org"
|
||||
_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
|
||||
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 (или
|
||||
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 берёт следующую свободную.
|
||||
|
||||
Returns ProxyLease или None если свободных здоровых прокси нет.
|
||||
Returns ProxyLease или None если свободных здоровых прокси нет вообще.
|
||||
"""
|
||||
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 provider_affinity IN (:provider, 'any')
|
||||
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
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
|
|
@ -117,6 +236,65 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
|||
.mappings()
|
||||
.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:
|
||||
db.rollback() # снять FOR UPDATE-транзакцию (ничего не залочено, но чисто)
|
||||
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},
|
||||
)
|
||||
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(
|
||||
"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)
|
||||
|
||||
|
||||
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(
|
||||
db: Session,
|
||||
proxy_id: int,
|
||||
|
|
@ -167,15 +395,34 @@ def mark_health(
|
|||
*,
|
||||
exit_ip: str | None = None,
|
||||
latency_ms: int | None = None,
|
||||
fail_kind: str | None = None,
|
||||
) -> None:
|
||||
"""Записать результат health-check'а прокси.
|
||||
|
||||
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 прокси
|
||||
авто-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:
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -185,12 +432,26 @@ def mark_health(
|
|||
last_check_at = now(),
|
||||
exit_ip = CAST(:exit_ip AS text),
|
||||
latency_ms = CAST(:latency_ms AS integer),
|
||||
enabled = CASE
|
||||
WHEN disabled_reason IS NULL THEN true ELSE enabled
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE id = CAST(:id AS bigint)
|
||||
RETURNING disabled_reason
|
||||
"""
|
||||
),
|
||||
{"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:
|
||||
# consecutive_fails+1 >= порог → enabled=false (авто-вывод битого узла).
|
||||
db.execute(
|
||||
|
|
@ -210,7 +471,245 @@ def mark_health(
|
|||
{"disable_threshold": DISABLE_THRESHOLD, "id": proxy_id},
|
||||
)
|
||||
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:
|
||||
|
|
@ -237,10 +736,17 @@ def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES
|
|||
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).
|
||||
|
||||
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] обрабатывает оба.
|
||||
"""
|
||||
started = time.monotonic()
|
||||
|
|
@ -250,10 +756,23 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None]:
|
|||
resp.raise_for_status()
|
||||
ip = resp.json().get("ip")
|
||||
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:
|
||||
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:
|
||||
|
|
@ -269,16 +788,29 @@ def _mask(url: str) -> str:
|
|||
|
||||
|
||||
async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||
"""Периодический health-check всех enabled-прокси пула (#2162).
|
||||
"""Периодический health-check прокси пула — enabled каждый прогон, disabled реже (#2162, #2600).
|
||||
|
||||
Сначала reap_stale_leases (освобождает протухшие lease'ы), затем для каждого
|
||||
enabled-прокси гоняет ipify-пробу через сам прокси и пишет результат через
|
||||
mark_health (успех → сброс fails + свежий exit_ip/latency; фейл → инкремент,
|
||||
авто-disable при DISABLE_THRESHOLD).
|
||||
Сначала reap_stale_leases (освобождает протухшие lease'ы), затем гоняет ipify-пробу
|
||||
через каждый кандидат и пишет результат через mark_health (успех → сброс fails +
|
||||
enabled=true + свежий exit_ip/latency; фейл → инкремент, авто-disable при
|
||||
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
|
||||
{reaped, checked, ok, failed}.
|
||||
{reaped, checked, ok, failed, revived, bans_purged}.
|
||||
"""
|
||||
reaped = reap_stale_leases(db)
|
||||
|
||||
|
|
@ -286,12 +818,17 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, url, kind
|
||||
SELECT id, url, kind, enabled, disabled_reason
|
||||
FROM scrape_proxies
|
||||
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
|
||||
"""
|
||||
)
|
||||
),
|
||||
{"disabled_recheck_minutes": DISABLED_RECHECK_MINUTES},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
|
|
@ -300,22 +837,64 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
checked = 0
|
||||
ok_count = 0
|
||||
failed = 0
|
||||
revived = 0
|
||||
for row in proxies:
|
||||
proxy_id = int(row["id"])
|
||||
url = str(row["url"])
|
||||
ok, exit_ip, latency_ms = await _probe_proxy(url)
|
||||
mark_health(db, proxy_id, ok, exit_ip=exit_ip, latency_ms=latency_ms)
|
||||
was_disabled = not bool(row["enabled"])
|
||||
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
|
||||
if ok:
|
||||
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:
|
||||
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(
|
||||
"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,
|
||||
checked,
|
||||
ok_count,
|
||||
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,
|
||||
}
|
||||
|
|
|
|||
376
tradein-mvp/backend/app/services/proxy_rotation.py
Normal file
376
tradein-mvp/backend/app/services/proxy_rotation.py
Normal 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)),
|
||||
)
|
||||
|
|
@ -50,9 +50,23 @@ sber_index.py для sberindex.ru (см. #922, тот же паттерн: пу
|
|||
отвечает HTTP 403 без браузерного User-Agent — шлём Chrome UA (тот же паттерн,
|
||||
что DEFAULT_UA в zhkh_flats_loader.py).
|
||||
|
||||
При сетевой ошибке / HTTP 5xx / таймауте — логируем warning, возвращаем
|
||||
available=False. Отсутствие папки/файла квартала → available=False (штатный
|
||||
случай до публикации квартала, до начала следующего месяца после конца квартала).
|
||||
УРОВНИ СИГНАЛОВ (#2674 — в контейнере скрапера событием GlitchTip становится только
|
||||
запись ERROR, см. scheduler_main.py LoggingIntegration(event_level=ERROR)):
|
||||
- Портал ответил не-200 на листинг каталога/папки → ERROR. Каталог — единственная
|
||||
опора поллера; портал УЖЕ один раз переехал (см. "ИСТОРИЯ"), и тогда поллер молча
|
||||
врал целыми кварталами. Такое обязано быть событием.
|
||||
- Файл датасета НАЙДЕН в листинге, но HEAD не отдал zip / размер ниже порога →
|
||||
ERROR. Тот же класс: это ровно поведение старой Bitrix-заглушки (200 + text/html).
|
||||
Ветка может сработать легитимно (файл выложили в листинг раньше, чем докачали),
|
||||
но цена асимметрична — ложное срабатывание стоит одного события в месяц (такт
|
||||
28 дней), пропуск стоит квартала молчания.
|
||||
- Таймаут / сетевая ошибка → WARNING, как раньше. Это транспортный блип раз в месяц
|
||||
(такт поллера), сам пройдёт; а «квартал так и не приехал» ловит отдельный
|
||||
deals_freshness_monitor ERROR-ом по max(deal_date).
|
||||
- Папки/файла квартала нет → INFO. Штатное состояние до публикации: квартал выходит
|
||||
4 раза в год, поллер ходит 12 — большинство прогонов ЗАКОННО пустые.
|
||||
- Квартал вышел → INFO + ЯВНОЕ событие capture_message(level="info"), см.
|
||||
poll_rosreestr_new_quarter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -63,6 +77,7 @@ from typing import Any
|
|||
from urllib.parse import quote, unquote, urljoin
|
||||
|
||||
import httpx
|
||||
import sentry_sdk
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -243,7 +258,8 @@ async def check_new_quarter_available(
|
|||
try:
|
||||
index_resp = await client.get(_DATA_SETS_BASE_URL, follow_redirects=True)
|
||||
if index_resp.status_code != 200:
|
||||
logger.warning(
|
||||
# ERROR (#2674): без каталога поллер слеп — см. "УРОВНИ СИГНАЛОВ".
|
||||
logger.error(
|
||||
"rosreestr_poll: unexpected HTTP %d listing %s — treating Q%d %d as unavailable",
|
||||
index_resp.status_code,
|
||||
_DATA_SETS_BASE_URL,
|
||||
|
|
@ -265,7 +281,9 @@ async def check_new_quarter_available(
|
|||
folder_url = urljoin(_DATA_SETS_BASE_URL, folder_href)
|
||||
folder_resp = await client.get(folder_url, follow_redirects=True)
|
||||
if folder_resp.status_code != 200:
|
||||
logger.warning(
|
||||
# ERROR (#2674): папка квартала НАЙДЕНА в каталоге, но не открывается —
|
||||
# это уже не «ещё не опубликовали», а поломка портала.
|
||||
logger.error(
|
||||
"rosreestr_poll: unexpected HTTP %d listing folder %s — "
|
||||
"treating Q%d %d as unavailable",
|
||||
folder_resp.status_code,
|
||||
|
|
@ -309,7 +327,13 @@ async def check_new_quarter_available(
|
|||
)
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
# ERROR (#2674, ревью PR #2681): файл ЕСТЬ в листинге, но HEAD отдал не zip
|
||||
# либо размер ниже порога — это буквально тот сбой, из-за которого поллер уже
|
||||
# врал (Bitrix-заглушка отвечала 200 с text/html вместо архива, см. "ИСТОРИЯ").
|
||||
# Ветка может сработать и легитимно — файл появился в листинге раньше, чем
|
||||
# докачался, — но цена асимметрична: такт 28 дней, значит ложное срабатывание
|
||||
# стоит максимум одного события в месяц, а пропуск стоит квартала молчания.
|
||||
logger.error(
|
||||
"rosreestr_poll: Q%d %d file found (%s) but failed availability check "
|
||||
"(HTTP %d, Content-Type=%r, Content-Length=%d) — soft-404 guard, "
|
||||
"treating as unavailable",
|
||||
|
|
@ -338,12 +362,14 @@ async def check_new_quarter_available(
|
|||
exc,
|
||||
)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"rosreestr_poll: unexpected error checking Q%d %d: %s — treating as unavailable",
|
||||
except Exception:
|
||||
# ERROR + traceback (#2674): сюда попадает НАШ баг (сменилась разметка, упал
|
||||
# парсер href'ов), а не сбой сети. Под WARNING он молча превращался в
|
||||
# «квартала нет» — ровно тот сценарий, из-за которого поллер врал кварталами.
|
||||
logger.exception(
|
||||
"rosreestr_poll: unexpected error checking Q%d %d — treating as unavailable",
|
||||
quarter,
|
||||
year,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
|
@ -409,6 +435,21 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
|
|||
rosreestr_dataset_url(next_year, next_quarter),
|
||||
_DATA_SETS_BASE_URL,
|
||||
)
|
||||
# #2674: это ХОРОШАЯ новость, но она требует ручного шага оператора (импорт
|
||||
# много-гигабайтного ZIP), а INFO-строка живёт только в docker-логах и
|
||||
# теряется на редеплое. Отсюда явный capture_message вместо logger.error:
|
||||
# событие в GlitchTip будет, а error-rate и стрик-алерты не соврут «сбой».
|
||||
# Шума не создаёт: такт поллера — раз в 28 дней, квартал выходит 4 раза в
|
||||
# год, а повтор до самого импорта — это и есть нужное напоминание (#2670).
|
||||
try:
|
||||
sentry_sdk.capture_message(
|
||||
f"Rosreestr: доступен новый квартал Q{next_quarter} {next_year} — "
|
||||
"нужен ручной импорт (02_load_all_quarters.sh + import-rosreestr.sh)",
|
||||
level="info",
|
||||
)
|
||||
except Exception:
|
||||
# Алертинг best-effort: падение отправки события не должно валить поллер.
|
||||
logger.warning("rosreestr_poll: capture_message failed", exc_info=True)
|
||||
|
||||
return {
|
||||
"available": available,
|
||||
|
|
|
|||
|
|
@ -464,8 +464,16 @@ async def pull_sber_indices(
|
|||
# path or its filter dims are stale (sber renames slugs / changes
|
||||
# dimension codes). Surface it loudly with the slug + filter so the
|
||||
# next breakage is diagnosable instead of a silent error-counter bump.
|
||||
#
|
||||
# #2674: "loudly" было сказано, но написано WARNING — тише, чем
|
||||
# соседние 5xx/сетевые ветки, и НЕ событие в скрапере
|
||||
# (LoggingIntegration event_level=ERROR). При этом 404 — самая
|
||||
# ПЕРМАНЕНТНАЯ из трёх: 5xx и сетевой сбой сами пройдут, а
|
||||
# переименованный slug будет 404-ить каждый месяц, пока человек не
|
||||
# перезахватит dataset-path. Ровно тот сбой, из-за которого бенчмарк
|
||||
# перестаёт обновляться.
|
||||
if exc.response.status_code == 404:
|
||||
logger.warning(
|
||||
logger.error(
|
||||
"sber_index: 404 for dashboard=%s ref_area=%s filter=%s — "
|
||||
"dataset-path invalid? slug renamed or filter dims stale "
|
||||
"(re-capture /dataset/v1/<slug> via dashboard route-interception)",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ scheduling-путь (`app/scheduler_main.py` безусловно запуска
|
|||
Что осталось в этом модуле — НЕ scheduler-loop, а функции с живыми потребителями вне
|
||||
удалённой machinery:
|
||||
- `compute_next_run_at` — читается admin.py (операторский предпросмотр "next run").
|
||||
С #2674 это re-export kit-версии, а не вторая копия формулы.
|
||||
- `has_running_run` — читается admin.py (UI-индикатор "уже бежит").
|
||||
- `import_rosreestr_dkp` — job-тело, вызываемое kit-handler'ом
|
||||
product_handlers._job_rosreestr_dkp (lazy import).
|
||||
|
|
@ -25,16 +26,24 @@ Zombie-reap, advisory-lock claim и tick-loop теперь целиком в
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from datetime import UTC, datetime, time, timedelta
|
||||
from typing import Any
|
||||
|
||||
# compute_next_run_at жил здесь ВТОРОЙ, побайтово одинаковой копией kit-версии (#2674).
|
||||
# Обе копии одинаково умели interval_days — но такт доезжал до next_run_at только через
|
||||
# kit (_claim_run/_defer_next_run_at читают default_params["interval_days"]); admin.py
|
||||
# звал эту копию БЕЗ аргумента, получал default=1 и сбивал любой источник на «завтра».
|
||||
# Копия удалена, а не подправлена: пока формула лежит в двух файлах, следующая правка
|
||||
# такта снова разъедется по одному из них. Re-export (а не правка импорта у вызывающих)
|
||||
# сохраняет `from app.services.scheduler import compute_next_run_at` в admin.py и тестах.
|
||||
from scraper_kit.orchestration.scheduler import compute_next_run_at
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.shutdown import shutdown_requested
|
||||
from app.services import scrape_runs as runs_mod
|
||||
|
||||
__all__ = ["compute_next_run_at", "has_running_run"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# import_rosreestr_dkp: доля per-row INSERT-ошибок (rows_errored / rows_fetched), выше
|
||||
|
|
@ -43,54 +52,6 @@ logger = logging.getLogger(__name__)
|
|||
DKP_IMPORT_ERROR_RATE_THRESHOLD = 0.05
|
||||
|
||||
|
||||
def compute_next_run_at(
|
||||
window_start_hour: int,
|
||||
window_end_hour: int,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
interval_days: int = 1,
|
||||
) -> datetime:
|
||||
"""Pick random datetime в window [start, end) UTC, через interval_days суток после now.
|
||||
|
||||
interval_days задаёт каденс источника: 1 (default) = daily (back-compat), 7 = weekly.
|
||||
Берётся из schedule.default_params["interval_days"] вызывающим кодом; отсутствие ключа
|
||||
→ 1 → прежнее ежедневное поведение.
|
||||
|
||||
Если window_end_hour <= window_start_hour → cross-midnight window
|
||||
(например 22→3 → окно 22:00-23:59 ИЛИ 00:00-02:59).
|
||||
"""
|
||||
now = now or datetime.now(tz=UTC)
|
||||
interval_days = max(1, int(interval_days))
|
||||
# Целевая дата = now + interval_days суток (interval_days=1 → завтра, как раньше).
|
||||
target = (now + timedelta(days=interval_days)).date()
|
||||
|
||||
if window_end_hour > window_start_hour:
|
||||
# Обычное окно (например 2..5 → 02:00-04:59)
|
||||
start_seconds = window_start_hour * 3600
|
||||
end_seconds = window_end_hour * 3600
|
||||
rand_seconds = random.randint(start_seconds, end_seconds - 1)
|
||||
return datetime.combine(target, time(0, 0), tzinfo=UTC) + timedelta(seconds=rand_seconds)
|
||||
else:
|
||||
# Cross-midnight (22..3 → 22:00-23:59 + 00:00-02:59)
|
||||
# Длина окна = (24-start) + end часов
|
||||
total_seconds = ((24 - window_start_hour) + window_end_hour) * 3600
|
||||
rand_seconds = random.randint(0, total_seconds - 1)
|
||||
# Если rand попадает в первую часть (start..24)
|
||||
first_half = (24 - window_start_hour) * 3600
|
||||
if rand_seconds < first_half:
|
||||
# interval_days=1: текущая дата (если окно ещё не наступило сегодня) или next day.
|
||||
# interval_days>1: всегда целевая дата (стаггер на N суток вперёд).
|
||||
today_ok = interval_days == 1 and now.hour < window_start_hour
|
||||
base_date = now.date() if today_ok else target
|
||||
return datetime.combine(base_date, time(0, 0), tzinfo=UTC) + timedelta(
|
||||
seconds=window_start_hour * 3600 + rand_seconds
|
||||
)
|
||||
else:
|
||||
# Во второй части (0..end), целевого дня
|
||||
offset = rand_seconds - first_half
|
||||
return datetime.combine(target, time(0, 0), tzinfo=UTC) + timedelta(seconds=offset)
|
||||
|
||||
|
||||
def has_running_run(db: Session, source: str) -> bool:
|
||||
"""Есть ли активный run для source (status='running')."""
|
||||
row = db.execute(
|
||||
|
|
@ -115,7 +76,16 @@ async def _execute_cian_backfill(
|
|||
"""Orchestrate Cian history backfill with heartbeat + checkpoint.
|
||||
|
||||
Wraps backfill_cian_history(), updating scrape_runs counters (via update_heartbeat)
|
||||
before and after the batch call for zombie-detection visibility.
|
||||
НА КАЖДОЙ сущности батча, а не только до и после него (#2725). Раньше сигнал
|
||||
живости слался ровно один раз — до батча, — а `reap_zombies` меряет именно
|
||||
heartbeat_at с порогом 6 ч, и добивал живые прогоны строго на 6-м часу: 6 прод-
|
||||
прогонов этого источника помечены 'zombie' со сдвигом heartbeat 16-32 мс, при том
|
||||
что у пятерых внутри окна писались строки offer_price_history (у прогона 304 — до
|
||||
5.4 ч после старта), а штатная длительность источника доходит до 5.06 ч (346).
|
||||
Цена ошибки не косметическая: mark_done апдейтит WHERE status='running', так что
|
||||
после ложной пометки собственный финал прогона становится no-op (отсюда нулевые
|
||||
counters у всех шести), а has_running_run перестаёт видеть прогон и следующий тик
|
||||
может запустить второй такой же батч поверх работающего.
|
||||
|
||||
Checkpoint/resume semantics: backfill_cian_history() queries rows WHERE history IS
|
||||
NULL via LEFT JOIN — so re-running after a partial completion naturally skips
|
||||
|
|
@ -124,9 +94,33 @@ async def _execute_cian_backfill(
|
|||
Params (from default_params jsonb):
|
||||
batch_size: int — rows per run (listings + houses counted separately).
|
||||
"""
|
||||
from app.tasks.cian_history_backfill import backfill_cian_history
|
||||
from app.tasks.cian_history_backfill import CianBackfillResult, backfill_cian_history
|
||||
|
||||
batch_size = int(params.get("batch_size", 100))
|
||||
|
||||
def _counters(result: CianBackfillResult) -> dict[str, int]:
|
||||
return {
|
||||
"listings_processed": result.listings_processed,
|
||||
"listings_succeeded": result.listings_succeeded,
|
||||
"listings_failed": result.listings_failed_fetch + result.listings_failed_save,
|
||||
"houses_processed": result.houses_processed,
|
||||
"houses_succeeded": result.houses_succeeded,
|
||||
"houses_failed": result.houses_failed_fetch + result.houses_failed_save,
|
||||
}
|
||||
|
||||
def _heartbeat(progress: CianBackfillResult) -> None:
|
||||
"""Сигнал живости из середины батча. Best-effort: сбой heartbeat не должен
|
||||
ронять уже идущую работу — прогон в худшем случае вернётся к прежнему
|
||||
поведению (пометка 'zombie' на 6-м часу)."""
|
||||
try:
|
||||
runs_mod.update_heartbeat(db, run_id, _counters(progress))
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"scheduler: cian_history_backfill run_id=%d heartbeat failed (ignored)",
|
||||
run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
counters: dict[str, int] = {
|
||||
"listings_processed": 0,
|
||||
"listings_succeeded": 0,
|
||||
|
|
@ -145,17 +139,10 @@ async def _execute_cian_backfill(
|
|||
do_listings=True,
|
||||
do_houses=True,
|
||||
do_valuations=False,
|
||||
on_progress=_heartbeat,
|
||||
)
|
||||
|
||||
counters = {
|
||||
"listings_processed": result.listings_processed,
|
||||
"listings_succeeded": result.listings_succeeded,
|
||||
"listings_failed": result.listings_failed_fetch + result.listings_failed_save,
|
||||
"houses_processed": result.houses_processed,
|
||||
"houses_succeeded": result.houses_succeeded,
|
||||
"houses_failed": result.houses_failed_fetch + result.houses_failed_save,
|
||||
"duration_sec": int(result.duration_sec),
|
||||
}
|
||||
counters = {**_counters(result), "duration_sec": int(result.duration_sec)}
|
||||
runs_mod.mark_done(db, run_id, counters)
|
||||
logger.info(
|
||||
"scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs",
|
||||
|
|
|
|||
|
|
@ -2,12 +2,39 @@
|
|||
|
||||
Таблица scrape_runs создана в 015_scrape_runs.sql.
|
||||
Расширена в 051_scrape_runs_extend.sql: params/counters/error/finished_at/cancelled.
|
||||
|
||||
ВРЕМЯ ПИШЕТСЯ clock_timestamp(), А НЕ now() (#2702). `now()` в PostgreSQL —
|
||||
синоним `transaction_timestamp()`: он замерзает на СТАРТЕ транзакции и не двигается,
|
||||
сколько бы та ни жила. Финализаторы (mark_done/mark_failed/mark_banned) выполняются
|
||||
ТОЙ ЖЕ сессией, что и работа задачи, — и если рабочая транзакция всё это время
|
||||
оставалась открытой (задача ничего не коммитила: нечего было сохранять, батч читающий,
|
||||
сохранение шло чужой сессией), их UPDATE попадал ВНУТРЬ неё, и `finished_at` получал
|
||||
время НАЧАЛА работы, а не её конца.
|
||||
|
||||
Замер на проде 2026-08-06 (487 прогонов, у которых есть и finished_at, и счётчик
|
||||
counters.duration_sec): у 153 заявленная длительность превышала собственное окно
|
||||
finished_at − started_at более чем в 1.5 раза, у 133 окно было меньше секунды при
|
||||
работе дольше 10 с. 126 из этих 133 окон лежат в диапазоне 9-64 мс — это не разброс,
|
||||
а подпись механизма: столько проходит от коммита claim'а до первого запроса рабочей
|
||||
транзакции. Крайний случай — прогон 346 (cian_history_backfill): 18230 с работы,
|
||||
окно 32 мс.
|
||||
|
||||
Дефект был не сплошной ровно потому, что зависел от того, коммитила ли задача перед
|
||||
финалом: cadastral_geo_match / house_imv_backfill / avito_detail_backfill коммитят
|
||||
поштучно, у них окно совпадало с работой; yandex_address_backfill (45 из 50 прогонов),
|
||||
newbuilding_enrich, cian_history_backfill — нет.
|
||||
|
||||
Побочно это чинит и `heartbeat_at`: он писался тем же `now()` и по той же причине
|
||||
отставал от реальности на возраст открытой транзакции, а на нём стоит поиск зависших
|
||||
прогонов (reap_zombies, порог 6 ч).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable, Mapping
|
||||
from functools import cache
|
||||
from typing import Any
|
||||
|
||||
import sentry_sdk
|
||||
|
|
@ -21,6 +48,130 @@ logger = logging.getLogger(__name__)
|
|||
# (anti-spam: не на каждой последующей).
|
||||
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]:
|
||||
"""Извлечь значения для 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 ← 'total_seen' (если уже есть в counters) иначе 'lots_fetched'
|
||||
- total_seen ← _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched)
|
||||
- new_count ← 'new_count' (если уже есть) иначе 'lots_inserted'
|
||||
|
||||
Возвращает (total_seen, new_count); None для ключа, которого нет в counters —
|
||||
тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE).
|
||||
"""
|
||||
|
||||
def _pick(*keys: str) -> int | None:
|
||||
for key in keys:
|
||||
val = counters.get(key)
|
||||
if val is not None:
|
||||
try:
|
||||
return int(val)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
return _pick("total_seen", "lots_fetched"), _pick("new_count", "lots_inserted")
|
||||
return _run_result_count(counters), _pick_int(counters, "new_count", "lots_inserted")
|
||||
|
||||
|
||||
def _alert_if_consecutive_failures(db: Session, source: str) -> None:
|
||||
"""Отправить Sentry alert если последние CONSECUTIVE_FAILURE_ALERT_THRESHOLD
|
||||
завершённых запусков для данного source имеют статус 'failed' или 'banned'.
|
||||
"""Sentry alert на серию из CONSECUTIVE_FAILURE_ALERT_THRESHOLD неудач подряд
|
||||
(статусы 'failed'/'banned') у данного source.
|
||||
|
||||
Anti-spam: алерт срабатывает ТОЛЬКО когда стрик РОВНО равен порогу — т.е. запрос
|
||||
возвращает ровно N последних (failed|banned) и (N+1)-й, если существует, НЕ является
|
||||
failed/banned. Это предотвращает повторный алерт на каждой ошибке сверх порога.
|
||||
Anti-spam: не на каждой неудаче, а по разреженной лестнице вех (см.
|
||||
_streak_alert_due). До #2670 алерт приходил РОВНО на N-й неудаче и дальше не
|
||||
повторялся никогда: серия, ставшая длиннее порога, замолкала навсегда. На проде
|
||||
это дало avito_full_load — 31 неудача подряд, 34 дня без единого успешного
|
||||
прогона, один алерт за всё время.
|
||||
|
||||
Стрик прерывается любым завершением, кроме failed/banned, — по данным прода это
|
||||
достижимо и достигается (у domclick_city_sweep текущий стрик равен 1 при 47
|
||||
завершённых прогонах), поэтому лестница не вырождается в постоянный алерт.
|
||||
|
||||
Best-effort: весь блок обёрнут в try/except — сбой запроса или неинициализированный
|
||||
Sentry НЕ должен нарушать вызывающий mark_* путь.
|
||||
"""
|
||||
if sentry_sdk is None:
|
||||
return
|
||||
n = CONSECUTIVE_FAILURE_ALERT_THRESHOLD
|
||||
try:
|
||||
# Берём последние N+1 завершённых (non-running) запусков по source.
|
||||
# Сортируем по finished_at DESC чтобы самые свежие шли первыми.
|
||||
# Завершённые (non-running) прогоны источника, самые свежие первыми.
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -77,39 +224,116 @@ def _alert_if_consecutive_failures(db: Session, source: str) -> None:
|
|||
LIMIT :limit
|
||||
"""
|
||||
),
|
||||
{"source": source, "limit": n + 1},
|
||||
{"source": source, "limit": STREAK_SCAN_LIMIT},
|
||||
).fetchall()
|
||||
|
||||
if len(rows) < n:
|
||||
# Ещё не набралось N завершённых запусков вообще — алерт не нужен.
|
||||
streak = _leading_streak(rows, lambda r: r.status in ("failed", "banned"))
|
||||
capped = streak >= STREAK_SCAN_LIMIT
|
||||
if not capped and not _streak_alert_due(streak, n):
|
||||
return
|
||||
|
||||
# Первые N должны быть все failed/banned.
|
||||
first_n = rows[:n]
|
||||
if not all(r.status in ("failed", "banned") for r in first_n):
|
||||
return
|
||||
|
||||
# (N+1)-й запуск, если есть, тоже должен НЕ быть failed/banned — иначе мы уже
|
||||
# должны были отправить алерт раньше и не стоит дублировать.
|
||||
if len(rows) > n and rows[n].status in ("failed", "banned"):
|
||||
return
|
||||
|
||||
# Стрик ровно достиг порога — отправляем алерт.
|
||||
sentry_sdk.capture_message(
|
||||
f"Scraper source '{source}' has {n} consecutive failed/banned runs — "
|
||||
f"Scraper source '{source}' has {streak} consecutive failed/banned runs — "
|
||||
"manual intervention may be required (expired cookies / ban / broken parser).",
|
||||
level="error",
|
||||
)
|
||||
logger.error(
|
||||
"sentry alert sent: source=%s has %d consecutive failed/banned runs", source, n
|
||||
"sentry alert sent: source=%s has %d consecutive failed/banned runs", source, streak
|
||||
)
|
||||
except Exception:
|
||||
pass # sentry_sdk not initialised in dev, or query failed — best-effort only
|
||||
|
||||
|
||||
def _alert_on_run_id(db: Session, run_id: int) -> None:
|
||||
"""Вспомогательная обёртка: извлекает source по run_id и вызывает
|
||||
_alert_if_consecutive_failures. Best-effort — не бросает исключений.
|
||||
def _alert_if_consecutive_zero_results(db: Session, source: str) -> None:
|
||||
"""Отправить Sentry alert если последние CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD
|
||||
завершённых '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:
|
||||
row = db.execute(
|
||||
|
|
@ -118,22 +342,29 @@ def _alert_on_run_id(db: Session, run_id: int) -> None:
|
|||
).fetchone()
|
||||
if row is None:
|
||||
return
|
||||
_alert_if_consecutive_failures(db, str(row.source))
|
||||
checker(db, str(row.source))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int:
|
||||
"""INSERT scrape_runs(source, status='running', params, started_at=NOW()).
|
||||
"""INSERT scrape_runs(source, status='running', params, started_at=clock_timestamp()).
|
||||
|
||||
run_type DEFAULT 'city_sweep' (из 051 миграции).
|
||||
started_at пишется СВОЕЙ транзакцией (db.commit() ниже) — откат рабочей
|
||||
транзакции задачи его уже не достаёт (#2702).
|
||||
|
||||
Вид прогона несёт сам `source` (avito_city_sweep / domclick_detail_backfill / …);
|
||||
отдельной колонки run_type больше нет — она 3244 прогона подряд молчала
|
||||
дефолтом 'city_sweep' и подписывала им, например, proxy_healthcheck (#2674).
|
||||
Returns run_id (bigint).
|
||||
"""
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scrape_runs (source, status, params, started_at, heartbeat_at)
|
||||
VALUES (:source, 'running', CAST(:params AS jsonb), NOW(), NOW())
|
||||
VALUES (
|
||||
:source, 'running', CAST(:params AS jsonb), clock_timestamp(), clock_timestamp()
|
||||
)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
|
|
@ -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:
|
||||
"""UPDATE heartbeat_at=NOW(), counters=:counters + total_seen/new_count колонки.
|
||||
"""UPDATE heartbeat_at + counters=:counters + total_seen/new_count колонки.
|
||||
|
||||
total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и
|
||||
пишутся в выделенные колонки, чтобы observability не показывала 0 (audit #1926).
|
||||
|
|
@ -156,7 +387,7 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None
|
|||
text(
|
||||
"""
|
||||
UPDATE scrape_runs
|
||||
SET heartbeat_at = NOW(),
|
||||
SET heartbeat_at = clock_timestamp(),
|
||||
counters = CAST(:counters AS jsonb),
|
||||
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
||||
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
||||
|
|
@ -173,6 +404,27 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None
|
|||
db.commit()
|
||||
|
||||
|
||||
# Источники, чей джоб РЕАЛЬНО опрашивает status='cancelled' в своём цикле.
|
||||
# Всё остальное отменить нельзя: строка стала бы 'cancelled', а задача продолжила бы
|
||||
# работать — это, во-первых, ещё один врущий статус, во-вторых (хуже) обход guard'а
|
||||
# has_running_run: он перестанет видеть прогон как running и пустит второй свип на том
|
||||
# же прокси-IP → бан (инцидент 2026-05-31, runs #26+#27).
|
||||
# Состав проверен по call-site'ам runs.is_cancelled: kit pipeline (city-sweep'ы всех
|
||||
# площадок и городов, full-load'ы, avito_newbuilding_sweep) + rosreestr_dkp_import
|
||||
# (scheduler.py). yandex_newbuilding_sweep отмену НЕ опрашивает — поэтому правило не
|
||||
# «любой *_sweep». Актуально с #2674: до починки фильтра таблица прогонов была пуста
|
||||
# на всех вкладках, кнопка отмены не рендерилась ни разу и дыра не проявлялась.
|
||||
_CANCEL_HONORING_EXACT = frozenset({"avito_newbuilding_sweep", "rosreestr_dkp_import"})
|
||||
_CANCEL_HONORING_SUBSTRINGS = ("city_sweep", "full_load")
|
||||
|
||||
|
||||
def honors_cancel(source: str) -> bool:
|
||||
"""True, если джоб этого source опрашивает отмену и реально остановится."""
|
||||
return source in _CANCEL_HONORING_EXACT or any(
|
||||
key in source for key in _CANCEL_HONORING_SUBSTRINGS
|
||||
)
|
||||
|
||||
|
||||
def is_cancelled(db: Session, run_id: int) -> bool:
|
||||
"""Проверить status='cancelled' (cooperative cancel в long-running pipeline)."""
|
||||
row = db.execute(
|
||||
|
|
@ -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:
|
||||
"""Финализация run: status='done', finished_at=NOW(), counters + total_seen/new_count.
|
||||
"""Финализация run: status='done', finished_at + counters + total_seen/new_count.
|
||||
|
||||
total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся
|
||||
в выделенные колонки — иначе admin/observability показывает 0 (audit #1926).
|
||||
|
|
@ -193,7 +445,8 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
|
|||
text(
|
||||
"""
|
||||
UPDATE scrape_runs
|
||||
SET status = 'done', finished_at = NOW(), heartbeat_at = NOW(),
|
||||
SET status = 'done',
|
||||
finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(),
|
||||
counters = CAST(:counters AS jsonb),
|
||||
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
||||
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
||||
|
|
@ -211,6 +464,10 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
|
|||
if row is None:
|
||||
logger.warning("mark_done no-op: run_id=%d not in 'running' state", run_id)
|
||||
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:
|
||||
|
|
@ -228,7 +485,8 @@ def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int])
|
|||
text(
|
||||
"""
|
||||
UPDATE scrape_runs
|
||||
SET status = 'failed', finished_at = NOW(), heartbeat_at = NOW(),
|
||||
SET status = 'failed',
|
||||
finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(),
|
||||
error = :error, counters = CAST(:counters AS jsonb),
|
||||
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
||||
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None:
|
||||
"""Финализация run: status='banned' (IP заблокирован Avito — 403/captcha).
|
||||
def mark_banned(
|
||||
db: Session,
|
||||
run_id: int,
|
||||
error: str,
|
||||
counters: dict[str, int],
|
||||
*,
|
||||
ban_kind: str = BAN_KIND_PLATFORM,
|
||||
) -> None:
|
||||
"""Финализация run: status='banned' + диагноз ban_kind (#2686).
|
||||
|
||||
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,
|
||||
он мог оставить сессию в error state — rollback сбрасывает состояние.
|
||||
|
|
@ -269,8 +545,10 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int])
|
|||
text(
|
||||
"""
|
||||
UPDATE scrape_runs
|
||||
SET status = 'banned', finished_at = NOW(), heartbeat_at = NOW(),
|
||||
SET status = 'banned',
|
||||
finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(),
|
||||
error = :error, counters = CAST(:counters AS jsonb),
|
||||
ban_kind = :ban_kind,
|
||||
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
||||
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
||||
WHERE id = :run_id AND status = 'running'
|
||||
|
|
@ -281,6 +559,7 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int])
|
|||
"run_id": run_id,
|
||||
"error": error[:1000],
|
||||
"counters": json.dumps(counters),
|
||||
"ban_kind": ban_kind,
|
||||
"total_seen": total_seen,
|
||||
"new_count": new_count,
|
||||
},
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
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:
|
||||
"""Set status='cancelled' если currently 'running'. Returns True если cancelled."""
|
||||
"""Set status='cancelled' если currently 'running'. Returns True если cancelled.
|
||||
|
||||
Отказ (False) для source'ов, чей джоб отмену не опрашивает — см. honors_cancel:
|
||||
там 'cancelled' был бы враньём в статусе и снял бы has_running_run-guard.
|
||||
Ручки отмены source не проверяют (любая из пяти принимает любой run_id), поэтому
|
||||
гейт стоит здесь — на общем узле всех пяти.
|
||||
"""
|
||||
row = db.execute(
|
||||
text("SELECT source FROM scrape_runs WHERE id = :run_id"),
|
||||
{"run_id": run_id},
|
||||
).fetchone()
|
||||
if row is not None and not honors_cancel(str(row.source)):
|
||||
logger.warning(
|
||||
"mark_cancelled отказ: run_id=%d source=%s не опрашивает отмену — "
|
||||
"задача продолжила бы работать под статусом 'cancelled'",
|
||||
run_id,
|
||||
row.source,
|
||||
)
|
||||
return False
|
||||
result = db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE scrape_runs
|
||||
SET status = 'cancelled', finished_at = NOW()
|
||||
SET status = 'cancelled', finished_at = clock_timestamp()
|
||||
WHERE id = :run_id AND status = 'running'
|
||||
RETURNING id
|
||||
"""
|
||||
|
|
@ -366,8 +725,8 @@ def list_all(
|
|||
db.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT id AS run_id, source, run_type, status, params, counters,
|
||||
total_seen, new_count, started_at, finished_at,
|
||||
SELECT id AS run_id, source, status, params, counters,
|
||||
ban_kind, total_seen, new_count, started_at, finished_at,
|
||||
heartbeat_at, error AS error_text
|
||||
FROM scrape_runs
|
||||
WHERE {where_sql}
|
||||
|
|
@ -381,3 +740,22 @@ def list_all(
|
|||
.all()
|
||||
)
|
||||
return total, [dict(r) for r in rows]
|
||||
|
||||
|
||||
def distinct_sources(db: Session) -> list[str]:
|
||||
"""Все значения source, которые РЕАЛЬНО есть в scrape_runs (по алфавиту).
|
||||
|
||||
#2674: фильтр источников в админке был захардкожен тремя площадками
|
||||
(avito/cian/yandex), а в таблице 53 разных source и ни одной строки с таким
|
||||
точным значением — все три пункта фильтра давали пустую выдачу, а 76%
|
||||
прогонов (включая всю площадку Домклик) отфильтровать было нечем.
|
||||
Список обязан приходить из данных: новый source появляется в фильтре сам,
|
||||
без правки кода.
|
||||
|
||||
Игнорирует фильтры /scrape/runs — иначе выбор источника вырезал бы из
|
||||
выпадающего списка все остальные.
|
||||
"""
|
||||
rows = db.execute(
|
||||
text("SELECT DISTINCT source FROM scrape_runs WHERE source IS NOT NULL ORDER BY source")
|
||||
).fetchall()
|
||||
return [str(r.source) for r in rows]
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ class RealMatcherAdapter:
|
|||
*,
|
||||
year_built: int | None = None,
|
||||
building_cadastral_number: str | None = None,
|
||||
cadastral_number: str | None = None,
|
||||
source_url: str | None = None,
|
||||
) -> tuple[int | None, float, str]:
|
||||
# house_id is None when the matcher refuses a numberless address without a
|
||||
|
|
@ -80,7 +79,6 @@ class RealMatcherAdapter:
|
|||
lon,
|
||||
year_built=year_built,
|
||||
building_cadastral_number=building_cadastral_number,
|
||||
cadastral_number=cadastral_number,
|
||||
source_url=source_url,
|
||||
)
|
||||
|
||||
|
|
@ -136,10 +134,6 @@ class RealScraperConfig:
|
|||
def scraper_proxy_url(self) -> str | None:
|
||||
return _settings.scraper_proxy_url
|
||||
|
||||
@property
|
||||
def avito_proxy_rotate_url(self) -> str | None:
|
||||
return _settings.avito_proxy_rotate_url
|
||||
|
||||
@property
|
||||
def avito_proxy_max_rotations(self) -> int:
|
||||
return _settings.avito_proxy_max_rotations
|
||||
|
|
@ -148,10 +142,6 @@ class RealScraperConfig:
|
|||
def avito_serp_ekb_only(self) -> bool:
|
||||
return _settings.avito_serp_ekb_only
|
||||
|
||||
@property
|
||||
def yandex_proxy_rotate_url(self) -> str | None:
|
||||
return _settings.yandex_proxy_rotate_url
|
||||
|
||||
@property
|
||||
def cian_proxy_url(self) -> str | None:
|
||||
return _settings.cian_proxy_url
|
||||
|
|
@ -189,10 +179,6 @@ class RealScraperConfig:
|
|||
def proxy_rotate_attempt_timeout_s(self) -> float:
|
||||
return _settings.proxy_rotate_attempt_timeout_s
|
||||
|
||||
@property
|
||||
def cian_proxy_rotate_url(self) -> str | None:
|
||||
return _settings.cian_proxy_rotate_url
|
||||
|
||||
@property
|
||||
def cian_proxy_max_rotations(self) -> int:
|
||||
return _settings.cian_proxy_max_rotations
|
||||
|
|
@ -219,6 +205,11 @@ class RealScraperConfig:
|
|||
def use_proxy_pool_browser(self) -> bool:
|
||||
return _settings.use_proxy_pool_browser
|
||||
|
||||
# ── #2616 шаг 1: признак окружения для отказа вместо мёртвого env-fallback ──
|
||||
@property
|
||||
def environment(self) -> str:
|
||||
return _settings.environment
|
||||
|
||||
|
||||
class RealProxyProvider:
|
||||
"""ProxyProvider-адаптер над `app.services.proxy_pool` (#2163).
|
||||
|
|
@ -267,6 +258,20 @@ class RealProxyProvider:
|
|||
finally:
|
||||
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:
|
||||
"""SessionFactory-адаптер над `app.core.db.SessionLocal`."""
|
||||
|
|
|
|||
|
|
@ -95,8 +95,12 @@ def build_search_query(params: SearchParams) -> tuple[str, dict[str, object]]:
|
|||
where.append("total_floors <= CAST(:fl_total_max AS integer)")
|
||||
args["fl_total_max"] = params.floors_total_max
|
||||
|
||||
if params.has_kadastr:
|
||||
where.append("cadastral_number IS NOT NULL")
|
||||
# Фильтр has_kadastr удалён (#2674): `listings.cadastral_number` (кадастр КВАРТИРЫ)
|
||||
# пуст у всех 93 408 объявлений — площадки его не отдают (единственный писатель,
|
||||
# парсер Циана, читает offer["cadastralNumber"], которого в ответе нет). Предикат
|
||||
# `cadastral_number IS NOT NULL` мог вернуть только пустую выдачу, т.е. обещал
|
||||
# качество данных, которого нет. Колонка и её писатель оставлены: если площадка
|
||||
# начнёт отдавать кадастр, заполнение заработает само — тогда и вернём фильтр.
|
||||
|
||||
segment_clause = _SEGMENT_SQL[params.segment]
|
||||
if segment_clause is not None:
|
||||
|
|
|
|||
|
|
@ -20,12 +20,20 @@ snapshot_listing_sources / import_rosreestr_dkp.
|
|||
Окно расписания 06:00-07:00 UTC — ПОСЛЕ rosreestr_dkp_import (04:00-06:00 UTC), чтобы
|
||||
refresh потреблял свежие ДКП-сделки того же дня.
|
||||
|
||||
SQL derivation ниже — БАЙТ-В-БАЙТ та же логика, что seed в data/sql/080_asking_to_sold_ratios.sql
|
||||
(deal_side / ask_side / per_bucket + deal_global / ask_global / global_row: трейлинг-12мес
|
||||
окно, ppm²-полоса [_PPM2_MIN, settings.asking_ratio_ppm2_max] (default [30000,1200000]),
|
||||
бакет LEAST(GREATEST(rooms,0),4), порог n_deals>=30 AND n_listings>=30 для per_rooms,
|
||||
global -1 строка всегда). ON CONFLICT убран — DELETE идёт первым,
|
||||
SQL derivation ниже повторяет seed в data/sql/080_asking_to_sold_ratios.sql (deal_side /
|
||||
ask_side / per_bucket + deal_global / ask_global / global_row: трейлинг-12мес окно, ppm²-полоса
|
||||
[_PPM2_MIN, settings.asking_ratio_ppm2_max] (default [30000,1200000]), порог n_deals>=30 AND
|
||||
n_listings>=30 для per_rooms, global -1 строка всегда). ON CONFLICT убран — DELETE идёт первым,
|
||||
конфликтов нет (повторный прогон в одной 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
|
||||
|
|
@ -41,17 +49,56 @@ from app.services import scrape_runs as runs_mod
|
|||
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
|
||||
_PPM2_MIN: int = 30_000
|
||||
|
||||
# #C2 — asking-сторона (listings) покрыта скрейпом ТОЛЬКО по ЕКБ (per-city scrape B1/B2
|
||||
# ещё нет; в listings даже нет колонки city). Миграция 177 залила ДКП-сделки по всей
|
||||
# обл.66 (368 городов) → sold-медиана смешивала дешёвую область с ЕКБ-asking и обваливала
|
||||
# ratio (0.877→0.62, «выкупная» −29% системно). Скоупим SOLD-сторону (deal_side/deal_global)
|
||||
# на ЕКБ, чтобы sold и asking считались по ОДНОМУ рынку. Когда появятся oblast-листинги —
|
||||
# заменить на per-city ratio через зарезервированный столбец `district` (#647).
|
||||
# #C2 — исторически asking-сторона (listings) была покрыта скрейпом ТОЛЬКО по ЕКБ, а
|
||||
# миграция 177 залила ДКП-сделки по всей обл.66 (368 городов) → sold-медиана смешивала
|
||||
# дешёвую область с ЕКБ-asking и обваливала ratio (0.877→0.62, «выкупная» −29% системно).
|
||||
# Скоупили SOLD-сторону (deal_side/deal_global) на ЕКБ, чтобы sold и asking считались по
|
||||
# ОДНОМУ рынку.
|
||||
#
|
||||
# #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 = "%Екатеринбург%"
|
||||
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
|
||||
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
|
||||
# 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__)
|
||||
|
||||
# ── 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:
|
||||
# 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),
|
||||
# бакет 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
|
||||
# (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.
|
||||
# global -1 строка (basis='global_fallback') — всегда (если ask>0 AND sold>0). window_months=12.
|
||||
# Порог/окно — литералы; ppm²-полоса передаётся bind-параметрами :ppm2_min/:ppm2_max
|
||||
# (безопасно от SQL-инъекций; CAST не нужен — psycopg v3 передаёт int напрямую).
|
||||
_REDERIVE_SQL = text(
|
||||
"""
|
||||
f"""
|
||||
WITH
|
||||
-- SOLD медианы по бакетам комнат за трейлинг-12мес (ДКП Росреестра).
|
||||
deal_side AS (
|
||||
|
|
@ -93,19 +143,37 @@ _REDERIVE_SQL = text(
|
|||
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
||||
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 (
|
||||
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,
|
||||
COUNT(*) AS n_listings
|
||||
FROM listings
|
||||
WHERE is_active
|
||||
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
|
||||
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
||||
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.
|
||||
-- Тонкие бакеты (n<30) сюда НЕ попадают → estimator делает fallback на -1.
|
||||
|
|
@ -149,9 +217,15 @@ _REDERIVE_SQL = text(
|
|||
FROM listings
|
||||
WHERE is_active
|
||||
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
|
||||
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
||||
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_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.
|
||||
|
||||
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}.
|
||||
"""
|
||||
counters: dict[str, int] = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
session path as the detail-phase of `run_avito_city_sweep`
|
||||
(scraper_kit.orchestration.pipeline). Block handling mirrors that phase:
|
||||
rotate IP on every block, abort after max_consecutive_blocks (mark_done not
|
||||
mark_failed -- block is temporary, retry next night via NULL detail_enriched_at).
|
||||
rotate IP on every block, abort after max_consecutive_blocks. Статус оборванного
|
||||
блоками прогона — 'banned' (#2674, runs.mark_backfill_finished): работу он не
|
||||
доделал, остаток снапшота уедет в следующую ночь через NULL detail_enriched_at.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -30,6 +31,7 @@ from scraper_kit.avito_exceptions import (
|
|||
AvitoRateLimitedError,
|
||||
)
|
||||
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): раньше
|
||||
# _CHROME_HEADERS/_avito_proxies() импортировались из app.services.scrape_pipeline.
|
||||
|
|
@ -46,6 +48,7 @@ from scraper_kit.providers.avito.detail import (
|
|||
save_detail_enrichment,
|
||||
)
|
||||
from scraper_kit.providers.avito.serp import AvitoScraper
|
||||
from scraper_kit.snapshot_writer import upsert_listing_snapshot
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -70,6 +73,31 @@ __all__ = [
|
|||
"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
|
||||
class AvitoDetailBackfillResult:
|
||||
|
|
@ -102,15 +130,22 @@ async def run_avito_detail_backfill(
|
|||
"""Backfill detail_enriched_at for legacy avito listings via mobile proxy.
|
||||
|
||||
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.
|
||||
request_delay_sec: float -- delay between listings, default 6.0s.
|
||||
max_consecutive_blocks: int -- abort threshold, default 5.
|
||||
|
||||
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
|
||||
mark_done (incl. partial/block-abort) / mark_failed (exception only).
|
||||
mark_backfill_finished (done / banned при блоках / failed при нуле, #2674);
|
||||
mark_failed напрямую — только при исключении.
|
||||
"""
|
||||
batch_size = int(params.get("batch_size", 800))
|
||||
oblast_batch_size = int(params.get("oblast_batch_size", 100))
|
||||
budget_sec = float(params.get("budget_sec", 3600))
|
||||
request_delay_sec = float(params.get("request_delay_sec", 6.0))
|
||||
max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5))
|
||||
|
|
@ -141,7 +176,7 @@ async def run_avito_detail_backfill(
|
|||
|
||||
# kit AvitoScraper требует ScraperConfig позиционно (Strangler-инжекция #2133) —
|
||||
# RealScraperConfig проксирует settings.* так же, как читал legacy-конструктор без
|
||||
# аргументов (avito_proxy_rotate_url и т.д. для _rotate_ip()).
|
||||
# аргументов (scraper_proxy_url и т.д. для _build_cffi_session()/_rotate_ip()).
|
||||
scraper = AvitoScraper(RealScraperConfig())
|
||||
start = time.monotonic()
|
||||
|
||||
|
|
@ -189,30 +224,59 @@ async def run_avito_detail_backfill(
|
|||
runs_mod.update_heartbeat(db, run_id, current_counters)
|
||||
|
||||
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
|
||||
# Scope (#1814): только активные ЕКБ-листинги. region_code на insert
|
||||
# хардкодится в 66 (base.py) → НЕ дискриминирует legacy не-ЕКБ; реальный
|
||||
# признак региона у Avito — путь URL (/ekaterinburg/ для ЕКБ; legacy
|
||||
# Москва/СПб/Тюмень — /moskva//sankt-peterburg//tyumen/). browser-fetch
|
||||
# на legacy не-ЕКБ спотыкается → curl-fallback → 429-бан curl-фингерпринта.
|
||||
# Не тратим фетчи на мёртвые (is_active) и не-ЕКБ.
|
||||
# Scope (#1814, расширено #2576): активные листинги ЕКБ + известных oblast-
|
||||
# городов (region 66). region_code на insert хардкодится в 66 (base.py) →
|
||||
# НЕ дискриминирует город; реальный признак города у Avito — путь URL
|
||||
# (/ekaterinburg/ для ЕКБ; legacy Москва/СПб/Тюмень — /moskva//sankt-
|
||||
# peterburg//tyumen/ — те по-прежнему вне scope, НЕ входят ни в ekb, ни в
|
||||
# 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 = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, source_url
|
||||
WITH ekb AS (
|
||||
SELECT id, source_url, price_rub, 'ekb' 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 '%/ekaterinburg/%'
|
||||
-- сперва листинги без координат (#1967 — detail-страница даёт
|
||||
-- координаты здания), затем по свежести
|
||||
-- сперва листинги без координат (#1967 — detail-страница
|
||||
-- даёт координаты здания), затем по свежести
|
||||
ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST
|
||||
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()
|
||||
.all()
|
||||
|
|
@ -227,11 +291,16 @@ async def run_avito_detail_backfill(
|
|||
runs_mod.mark_done(db, run_id, current_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(
|
||||
"avito_detail_backfill: run_id=%d snapshot=%d (budget=%.0fs "
|
||||
"delay=%.1fs max_blocks=%d mode=%s)",
|
||||
"avito_detail_backfill: run_id=%d snapshot=%d (ekb=%d oblast=%d, "
|
||||
"budget=%.0fs delay=%.1fs max_blocks=%d mode=%s)",
|
||||
run_id,
|
||||
len(snapshot),
|
||||
len(snapshot) - oblast_count,
|
||||
oblast_count,
|
||||
budget_sec,
|
||||
request_delay_sec,
|
||||
max_consecutive_blocks,
|
||||
|
|
@ -239,6 +308,7 @@ async def run_avito_detail_backfill(
|
|||
)
|
||||
|
||||
consecutive_blocks = 0
|
||||
aborted_by_blocks = False
|
||||
do_sleep = False
|
||||
items_since_warm = 0
|
||||
|
||||
|
|
@ -376,6 +446,21 @@ async def run_avito_detail_backfill(
|
|||
text("UPDATE listings SET is_active = FALSE WHERE id = :id"),
|
||||
{"id": row["id"]},
|
||||
)
|
||||
# #2674: 404 с площадки — самый достоверный сигнал снятия,
|
||||
# фиксируем его в дневной истории (listings_snapshots.status
|
||||
# был константой 'active' у всех строк, 394 299). Тот же
|
||||
# SAVEPOINT, что и UPDATE флага: снимок без флага (или
|
||||
# наоборот) невозможен. price_rub из snapshot-SELECT —
|
||||
# .get() консервативен ради mock-снапшотов старых тестов.
|
||||
gone_price = row.get("price_rub")
|
||||
if gone_price is not None:
|
||||
upsert_listing_snapshot(
|
||||
db,
|
||||
listing_id=row["id"],
|
||||
price_rub=gone_price,
|
||||
run_id=run_id,
|
||||
status="closed",
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"avito_detail_backfill: run_id=%d failed to mark listing %s "
|
||||
|
|
@ -416,6 +501,7 @@ async def run_avito_detail_backfill(
|
|||
counters.enriched,
|
||||
counters.attempted,
|
||||
)
|
||||
aborted_by_blocks = True
|
||||
break
|
||||
# МГТС sticky-IP: один фикс. exit-IP, per-connection ротации нет (проверено:
|
||||
# 6/6 свежих сессий = тот же IP 109.252.125.80; ротация только вручную
|
||||
|
|
@ -488,9 +574,15 @@ async def run_avito_detail_backfill(
|
|||
|
||||
counters.duration_sec = time.monotonic() - start
|
||||
current_counters = counters.to_dict()
|
||||
runs_mod.mark_done(db, run_id, current_counters)
|
||||
runs_mod.mark_backfill_finished(
|
||||
db,
|
||||
run_id,
|
||||
current_counters,
|
||||
source="avito_detail_backfill",
|
||||
aborted_by_blocks=aborted_by_blocks,
|
||||
)
|
||||
logger.info(
|
||||
"avito_detail_backfill: run_id=%d DONE -- attempted=%d enriched=%d "
|
||||
"avito_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "
|
||||
"blocked=%d gone=%d failed=%d duration=%.1fs",
|
||||
run_id,
|
||||
counters.attempted,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,26 @@
|
|||
Парсинг адреса — _parse_street_house из app.services.geocoder (готовый парсер),
|
||||
работающий с формами «г. Екатеринбург, ул. Малышева, 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 --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,
|
||||
coarse city-centroid fallback): точный house-level матч должен получить шанс первым,
|
||||
иначе 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).
|
||||
Повторный прогон не затирает уже проставленные координаты.
|
||||
|
|
@ -37,7 +67,7 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from app.core.db import SessionLocal
|
||||
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__)
|
||||
|
||||
|
|
@ -55,6 +85,17 @@ class BackfillCoordsResult:
|
|||
updated: int = 0 # реально обновлено (UPDATE rowcount)
|
||||
no_address: int = 0 # listing.address IS NULL / не распарсился
|
||||
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 # исключения при обработке отдельной записи
|
||||
duration_sec: float = field(default=0.0)
|
||||
|
||||
|
|
@ -65,6 +106,8 @@ class BackfillCoordsResult:
|
|||
"updated": self.updated,
|
||||
"no_address": self.no_address,
|
||||
"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,
|
||||
"duration_sec": int(self.duration_sec),
|
||||
}
|
||||
|
|
@ -153,7 +196,7 @@ def backfill_coords_from_geoportal(
|
|||
rows = (
|
||||
db.execute(
|
||||
text(f"""
|
||||
SELECT id, address
|
||||
SELECT id, address, city
|
||||
FROM listings
|
||||
WHERE lat IS NULL
|
||||
AND geom IS NULL
|
||||
|
|
@ -184,6 +227,32 @@ def backfill_coords_from_geoportal(
|
|||
res.no_address += 1
|
||||
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'а
|
||||
parsed = _parse_street_house(address)
|
||||
if parsed is None:
|
||||
|
|
@ -259,12 +328,15 @@ def backfill_coords_from_geoportal(
|
|||
|
||||
logger.info(
|
||||
"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.matched,
|
||||
res.updated,
|
||||
res.no_address,
|
||||
res.no_match,
|
||||
res.skipped_non_ekb,
|
||||
res.skipped_non_ekb_by_column,
|
||||
res.errors,
|
||||
res.duration_sec,
|
||||
)
|
||||
|
|
@ -314,13 +386,16 @@ def run_geoportal_coords_backfill(
|
|||
runs_mod.mark_done(db, run_id, counters)
|
||||
logger.info(
|
||||
"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,
|
||||
res.candidates,
|
||||
res.matched,
|
||||
res.updated,
|
||||
res.no_address,
|
||||
res.no_match,
|
||||
res.skipped_non_ekb,
|
||||
res.skipped_non_ekb_by_column,
|
||||
res.errors,
|
||||
res.duration_sec,
|
||||
)
|
||||
|
|
@ -376,12 +451,13 @@ def main() -> None:
|
|||
|
||||
logger.info(
|
||||
"Готово: кандидатов=%d сматчено=%d обновлено=%d "
|
||||
"без_адреса=%d без_матча=%d ошибок=%d время=%.1fs",
|
||||
"без_адреса=%d без_матча=%d не_ЕКБ=%d ошибок=%d время=%.1fs",
|
||||
result.candidates,
|
||||
result.matched,
|
||||
result.updated,
|
||||
result.no_address,
|
||||
result.no_match,
|
||||
result.skipped_non_ekb,
|
||||
result.errors,
|
||||
result.duration_sec,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,9 +15,18 @@ APPROXIMATION (deliberate first increment):
|
|||
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.
|
||||
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
|
||||
treats building_cadastral_number as a hint, not ground truth, so an approximate fill is
|
||||
a net win over 0% coverage.
|
||||
deferred (cad_parcels FDW not exposed).
|
||||
|
||||
ЭТО 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'):
|
||||
1. refresh_cad_buildings_local(db) — TRUNCATE + bulk INSERT from FDW (one scan).
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@
|
|||
Requires migration 071_houses_cian_zhk_url.sql (cian_zhk_url column).
|
||||
|
||||
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
|
||||
|
|
@ -25,6 +33,7 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||
|
|
@ -68,6 +77,7 @@ async def backfill_cian_history(
|
|||
do_houses: bool = True,
|
||||
do_valuations: bool = False,
|
||||
dry_run: bool = False,
|
||||
on_progress: Callable[[CianBackfillResult], None] | None = None,
|
||||
) -> CianBackfillResult:
|
||||
"""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).
|
||||
Default False — opt-in because each call hits Cian auth-gated API.
|
||||
dry_run: skip all fetch+save; only count and log pending rows.
|
||||
on_progress: колбэк живости (#2725) — вызывается на каждой сущности ЛЮБОГО из
|
||||
трёх блоков, до её обработки, с текущим (мутируемым) result. Caller пишет
|
||||
heartbeat; исключения колбэка — на его совести (планировщик глушит их сам),
|
||||
здесь они прервали бы батч.
|
||||
|
||||
Returns:
|
||||
CianBackfillResult with per-domain counters + total wall-clock duration.
|
||||
|
|
@ -120,6 +134,8 @@ async def backfill_cian_history(
|
|||
listing_id: int = row["id"]
|
||||
source_url: str = row["source_url"]
|
||||
result.listings_processed += 1
|
||||
if on_progress is not None:
|
||||
on_progress(result)
|
||||
|
||||
enrichment = None
|
||||
try:
|
||||
|
|
@ -209,6 +225,8 @@ async def backfill_cian_history(
|
|||
house_id: int = hrow["id"]
|
||||
zhk_url: str = hrow["cian_zhk_url"]
|
||||
result.houses_processed += 1
|
||||
if on_progress is not None:
|
||||
on_progress(result)
|
||||
|
||||
enrichment = None
|
||||
try:
|
||||
|
|
@ -291,6 +309,8 @@ async def backfill_cian_history(
|
|||
else:
|
||||
for row in rows:
|
||||
result.valuations_processed += 1
|
||||
if on_progress is not None:
|
||||
on_progress(result)
|
||||
try:
|
||||
cval = await estimate_via_cian_valuation(
|
||||
db,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@
|
|||
TTL=30. novostroyki (9659 активных первичных строк) и NULL-сегмент не трогаем.
|
||||
- avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений.
|
||||
- Строки НЕ удаляются -- история нужна для бэктеста (#667).
|
||||
- #2674: деактивация в той же транзакции пишет снимок listings_snapshots со статусом
|
||||
'stale' за текущую дату -- «мы N суток не видели». Жёсткое 'closed' (площадка
|
||||
ответила 404) пишет только avito_detail_backfill: смешивать факт с догадкой дорого.
|
||||
|
||||
Задача синхронная (DB-only, никаких внешних HTTP-вызовов) -- запускается kit-scheduler'ом
|
||||
через 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"})
|
||||
|
||||
# ── Снимок «протухло» в дневной истории (#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:
|
||||
"""UPDATE без фильтра по сегменту: все сегменты для данного source.
|
||||
|
||||
staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings,
|
||||
поэтому 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(
|
||||
f"""
|
||||
WITH stale AS (
|
||||
UPDATE listings
|
||||
SET is_active = false
|
||||
WHERE source = :listing_source
|
||||
AND is_active = true
|
||||
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(
|
||||
f"""
|
||||
WITH stale AS (
|
||||
UPDATE listings
|
||||
SET is_active = false
|
||||
WHERE source = :listing_source
|
||||
AND is_active = true
|
||||
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
||||
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,
|
||||
segments: list[str] | None = None,
|
||||
staleness_column: str = "last_seen_at",
|
||||
min_confirmations: int = 0,
|
||||
health_window_days: int = _HEALTH_WINDOW_DAYS,
|
||||
) -> dict[str, int]:
|
||||
"""Пометить 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 всем строкам одним timestamp, поэтому честная
|
||||
свежесть = scraped_at (двигается только реальным скрейпом).
|
||||
min_confirmations: гейт по здоровью сбора (#2659). Сколько строк источник
|
||||
должен был подтвердить свежими за health_window_days суток, чтобы
|
||||
деактивации вообще разрешалось исполниться. 0 -> гейт выключен (так
|
||||
вызывают старые тесты и совместимая обёртка); реальные значения приходят
|
||||
из default_params расписания, см. миграцию 219 и комментарий выше.
|
||||
health_window_days: окно подтверждений для гейта, суток. Дефолт 3.
|
||||
|
||||
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:
|
||||
ValueError: если staleness_column не входит в whitelist (проверка ДО SQL,
|
||||
|
|
@ -131,6 +254,44 @@ def deactivate_stale_listings(
|
|||
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=[...] -> только
|
||||
# перечисленные сегменты. Используем `is not None` (НЕ truthy): пустой список []
|
||||
# означает "ни один сегмент" (= ANY(ARRAY[]) ничего не матчит, деактивирует 0),
|
||||
|
|
@ -140,10 +301,15 @@ def deactivate_stale_listings(
|
|||
"listing_source": listing_source,
|
||||
"ttl_days": ttl_days,
|
||||
"segments": segments,
|
||||
"run_id": run_id,
|
||||
}
|
||||
result = db.execute(_build_segments_sql(staleness_column), params)
|
||||
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)
|
||||
|
||||
counters["deactivated"] = result.rowcount or 0
|
||||
|
|
|
|||
161
tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py
Normal file
161
tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py
Normal 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
|
||||
|
|
@ -142,7 +142,9 @@ def check_deals_freshness(
|
|||
row = db.execute(_LATEST_DEAL_DATE_SQL).first()
|
||||
latest: date | None = row.latest if row is not None else None
|
||||
if latest is None:
|
||||
logger.warning(
|
||||
# ERROR (#2674): монитор не может выполнить работу — сбой, а не наблюдение.
|
||||
# Соседняя ветка (overdue) писала ERROR с самого начала; эта расходилась.
|
||||
logger.error(
|
||||
"deals freshness: таблица deals пуста/недоступна — оценить свежесть нельзя"
|
||||
)
|
||||
runs_mod.mark_failed(db, run_id, "deals empty or unavailable", counters)
|
||||
|
|
|
|||
|
|
@ -36,10 +36,11 @@ one BrowserFetcher is constructed per run.
|
|||
|
||||
Exception triad differs from Avito:
|
||||
- DomClickBlockedError (QRATOR challenge page OR any browser-fetch failure) --
|
||||
increments consecutive_blocks, abort via mark_done (NOT mark_failed) once
|
||||
max_consecutive_blocks is hit -- a block-abort is an expected operational
|
||||
outcome (QRATOR reputation burn), not a task failure. Mirrors Avito's
|
||||
AvitoBlockedError handling. No IP-rotation/cooldown recovery step exists here
|
||||
increments consecutive_blocks, abort once max_consecutive_blocks is hit.
|
||||
Статус такого прогона — 'banned' (#2674, см. runs.mark_backfill_finished):
|
||||
блок это external constraint, не наш баг, но и НЕ успех — раньше здесь стоял
|
||||
mark_done, и 24 из 30 прогонов с нулём обогащений назывались успешными.
|
||||
No IP-rotation/cooldown recovery step exists here
|
||||
(DomClick uses one dedicated residential proxy, not a rotating pool) -- an
|
||||
aborted run simply retries the remaining backlog next window.
|
||||
- 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.
|
||||
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
|
||||
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
|
||||
`POST /scrape/domclick/upload-cookies` (no auto-login -- documented MVP limitation,
|
||||
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
|
||||
|
|
@ -65,6 +72,7 @@ import logging
|
|||
import random
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||
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
|
||||
class DomClickDetailBackfillResult:
|
||||
"""Counters for one backfill run."""
|
||||
|
|
@ -120,7 +182,8 @@ async def run_domclick_detail_backfill(
|
|||
max_consecutive_blocks: int -- abort threshold, default 3.
|
||||
|
||||
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))
|
||||
budget_sec = float(params.get("budget_sec", 3600))
|
||||
|
|
@ -135,16 +198,13 @@ async def run_domclick_detail_backfill(
|
|||
|
||||
try:
|
||||
# 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)
|
||||
if cookies is None:
|
||||
logger.warning(
|
||||
"domclick_detail_backfill: run_id=%d -- no valid DomClick session cookies "
|
||||
"in DB; proceeding WITHOUT cookie-injection (QRATOR-defeat degraded to "
|
||||
"organic SERP-origin navigation only, PR #2430). Refresh test-account "
|
||||
"session via POST /scrape/domclick/upload-cookies.",
|
||||
run_id,
|
||||
)
|
||||
_alert_domclick_cookies(db, run_id)
|
||||
else:
|
||||
_warn_before_domclick_cookies_expire(db, run_id)
|
||||
|
||||
runs_mod.update_heartbeat(db, run_id, current_counters)
|
||||
|
||||
|
|
@ -193,6 +253,7 @@ async def run_domclick_detail_backfill(
|
|||
)
|
||||
|
||||
consecutive_blocks = 0
|
||||
aborted_by_blocks = False
|
||||
do_sleep = False
|
||||
|
||||
# Exactly ONE BrowserFetcher per run (no curl fallback for DomClick, see
|
||||
|
|
@ -275,6 +336,7 @@ async def run_domclick_detail_backfill(
|
|||
counters.enriched,
|
||||
counters.attempted,
|
||||
)
|
||||
aborted_by_blocks = True
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -296,9 +358,15 @@ async def run_domclick_detail_backfill(
|
|||
|
||||
counters.duration_sec = time.monotonic() - start
|
||||
current_counters = counters.to_dict()
|
||||
runs_mod.mark_done(db, run_id, current_counters)
|
||||
runs_mod.mark_backfill_finished(
|
||||
db,
|
||||
run_id,
|
||||
current_counters,
|
||||
source="domclick_detail_backfill",
|
||||
aborted_by_blocks=aborted_by_blocks,
|
||||
)
|
||||
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",
|
||||
run_id,
|
||||
counters.attempted,
|
||||
|
|
|
|||
|
|
@ -5,15 +5,29 @@
|
|||
- Scheduled: nightly via scrape_schedules (source='geocode_missing_listings', migration 110)
|
||||
— wired into in-app scheduler, window 06:00-09:00 UTC.
|
||||
|
||||
Pattern: dedup по address (1 unique address → 1 geocode call → UPDATE all listings).
|
||||
Rate limit: Nominatim 1 req/sec. Yandex 25K/day если YANDEX_GEOCODER_API_KEY set.
|
||||
Pattern: dedup по паре (address, city) — 1 уникальная пара → 1 geocode call → UPDATE
|
||||
всех 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):
|
||||
- Этот модуль группирует по address → меньше API calls (dedup).
|
||||
- Этот модуль группирует по (address, city) → меньше API calls (dedup), но не
|
||||
схлопывает разные города с одинаковым текстом адреса.
|
||||
- Поддерживает all sources включая Avito (после PR #487 убрали jitter).
|
||||
- Возвращает GeocodeBackfillResult с детальными counters.
|
||||
- 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
|
||||
|
|
@ -27,7 +41,7 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from app.services import scrape_runs as runs_mod
|
||||
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__)
|
||||
|
||||
|
|
@ -53,13 +67,24 @@ async def geocode_missing_listings(
|
|||
"""Geocode listings с NULL coords (любой source).
|
||||
|
||||
Steps:
|
||||
1. SELECT DISTINCT address FROM listings WHERE lat IS NULL AND address IS NOT NULL
|
||||
GROUP BY address ORDER BY COUNT(*) DESC LIMIT batch_size
|
||||
(приоритет адресам с большим числом listings — больший ROI per geocode call)
|
||||
1. SELECT address, city FROM listings WHERE lat IS NULL AND is_active
|
||||
AND address IS NOT NULL GROUP BY address, city ORDER BY COUNT(*) DESC
|
||||
LIMIT batch_size
|
||||
(приоритет парам address+city с большим числом listings — больший ROI per
|
||||
geocode call; группировка по паре, НЕ только по address — #2594 шаг 2/3:
|
||||
один и тот же текст адреса в разных городах — разные записи. `is_active` —
|
||||
#2604 п.1: не тратим Nominatim-бюджет на мёртвые объявления, которые никогда
|
||||
не попадут в выдачу пользователю)
|
||||
|
||||
2. Для каждого address:
|
||||
- geocode(address, db) — auto-cache (hit или miss)
|
||||
- Если есть результат: UPDATE listings SET lat, lon WHERE address = :addr AND lat IS NULL
|
||||
2. Для каждой пары (address, city):
|
||||
- geocode(address, db, city_hint=known_city_hint(city)) — auto-cache
|
||||
(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
|
||||
|
||||
3. Log progress каждые 50 addresses.
|
||||
|
|
@ -74,25 +99,39 @@ async def geocode_missing_listings(
|
|||
start = time.monotonic()
|
||||
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 дней (возможен
|
||||
# переезд адреса в кэше или смена провайдера), либо tried_at IS NULL (ещё не пробовали).
|
||||
# Это делает функцию 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 = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT address, COUNT(*) AS listings_count
|
||||
SELECT address, city, COUNT(*) AS listings_count
|
||||
FROM listings
|
||||
WHERE lat IS NULL
|
||||
AND is_active
|
||||
AND address IS NOT NULL
|
||||
AND length(trim(address)) >= 5
|
||||
AND (geocode_tried_at IS NULL
|
||||
OR geocode_tried_at < NOW() - INTERVAL '7 days')
|
||||
GROUP BY address
|
||||
ORDER BY listings_count DESC, address ASC
|
||||
GROUP BY address, city
|
||||
ORDER BY listings_count DESC, address ASC, city ASC NULLS FIRST
|
||||
LIMIT :limit
|
||||
"""
|
||||
),
|
||||
|
|
@ -117,23 +156,43 @@ async def geocode_missing_listings(
|
|||
|
||||
for idx, row in enumerate(rows):
|
||||
address: str = row["address"]
|
||||
city: str | None = row.get("city")
|
||||
listings_count: int = row["listings_count"]
|
||||
result.addresses_processed += 1
|
||||
|
||||
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:
|
||||
logger.warning("geocode_missing: geocode raised for '%s': %s", address[:60], exc)
|
||||
result.addresses_failed += 1
|
||||
if not dry_run:
|
||||
# Пометить tried_at чтобы адрес не переотбирался в следующих batch'ах
|
||||
# этого же прогона (loop-safe backoff 7 дней).
|
||||
# Пометить tried_at чтобы пара (address, city) не переотбиралась
|
||||
# в следующих 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(
|
||||
text(
|
||||
"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()
|
||||
continue
|
||||
|
|
@ -141,18 +200,25 @@ async def geocode_missing_listings(
|
|||
if geo is None:
|
||||
result.addresses_failed += 1
|
||||
logger.info(
|
||||
"geocode_missing: NOT FOUND '%s' (used in %d listings)",
|
||||
"geocode_missing: NOT FOUND '%s' city=%r (used in %d listings)",
|
||||
address[:60],
|
||||
city,
|
||||
listings_count,
|
||||
)
|
||||
if not dry_run:
|
||||
# Пометить tried_at — geocoder не нашёл адрес, backoff 7 дней.
|
||||
# Намеренно БЕЗ `AND is_active` (#2604 п.2) — то же обоснование, что
|
||||
# и в except-ветке выше: backoff привязан к тексту (address, city),
|
||||
# не к конкретному listing, is_active=false строка и так не выбирается
|
||||
# SELECT'ом заново; при реактивации backoff корректно защитит от
|
||||
# немедленного повтора заведомо неудачного запроса.
|
||||
db.execute(
|
||||
text(
|
||||
"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()
|
||||
continue
|
||||
|
|
@ -165,9 +231,13 @@ async def geocode_missing_listings(
|
|||
result.addresses_geocoded += 1
|
||||
|
||||
if dry_run:
|
||||
# city в логе (#2603) — с #2594 это часть ключа группы: без него две
|
||||
# строки dry-run с одинаковым текстом адреса неотличимы друг от друга.
|
||||
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],
|
||||
city,
|
||||
geo.lat,
|
||||
geo.lon,
|
||||
geo.provider,
|
||||
|
|
@ -183,16 +253,37 @@ async def geocode_missing_listings(
|
|||
|
||||
# UPDATE listings — PostGIS trigger (listings_set_geom_trg) обновит geom автоматически.
|
||||
# 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(
|
||||
text(
|
||||
"""
|
||||
UPDATE listings
|
||||
SET lat = :lat, lon = :lon, geo_precision = :precision,
|
||||
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()
|
||||
result.listings_updated += update_result.rowcount
|
||||
|
|
@ -293,6 +384,13 @@ async def run_geocode_missing_listings(
|
|||
)
|
||||
break
|
||||
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(
|
||||
"run_geocode_missing_listings: run_id=%d — дренаж "
|
||||
"(addresses_total=%d < batch_size=%d), завершаем",
|
||||
|
|
|
|||
|
|
@ -9,13 +9,34 @@ listing_source_events. Так история per-source цены копится
|
|||
через product_handlers._job_listing_source_snapshot,
|
||||
по образцу import_rosreestr_dkp (sync task в run_in_executor).
|
||||
|
||||
Вся работа — два set-based SQL statement'а (snapshot upsert + event-diff CTE),
|
||||
никакого row-by-row Python: 18 355 строк обслуживаются одним INSERT … SELECT каждый.
|
||||
Вся работа — два set-based SQL statement'а (snapshot upsert + event-diff), никакого
|
||||
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 на проде показал cost≈300k именно на этом шаге. Реально это никогда не завершалось
|
||||
за 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
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -27,6 +48,30 @@ logger = logging.getLogger(__name__)
|
|||
# Окно свежести: источник считается активным, если last_seen_at не старше N дней.
|
||||
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 ─────────────────────────────────────────────────────
|
||||
# Снимок на (listing_source_id, CURRENT_DATE). ON CONFLICT → last-write-wins за день
|
||||
# (повторный прогон в те же сутки перезаписывает снимок свежими значениями).
|
||||
|
|
@ -59,83 +104,209 @@ _SNAPSHOT_SQL = text(
|
|||
"""
|
||||
)
|
||||
|
||||
# ── Event diff: price_change ──────────────────────────────────────────────────
|
||||
# Для каждого источника сравниваем сегодняшнюю цену (snapshot_date = CURRENT_DATE) с
|
||||
# самым свежим ПРЕДЫДУЩИМ снимком (snapshot_date < CURRENT_DATE). Если цена изменилась
|
||||
# (обе NOT NULL, old <> 0) — пишем price_change.
|
||||
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL).
|
||||
# prior — последний снимок строго ДО сегодня (DISTINCT ON … ORDER BY date DESC).
|
||||
# Полностью set-based: один INSERT … SELECT по всем источникам, без Python-цикла.
|
||||
# change_time = now() детерминирует UNIQUE(listing_source_id, change_time, event_type)
|
||||
# в пределах прогона → ON CONFLICT DO NOTHING делает писатель идемпотентным.
|
||||
# ── Event diff: три выводимых типа событий из пяти в схеме ────────────────────
|
||||
# Для каждого источника сравниваем сегодняшний снимок (snapshot_date = CURRENT_DATE) с
|
||||
# самым свежим ПРЕДЫДУЩИМ (snapshot_date < CURRENT_DATE).
|
||||
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции).
|
||||
# p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607).
|
||||
#
|
||||
# #2674: схема (079) знает пять типов событий, писатель умел один — price_change,
|
||||
# 8288 строк. Дописаны два:
|
||||
# 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(
|
||||
"""
|
||||
WITH today AS (
|
||||
SELECT listing_source_id, price_rub
|
||||
SELECT listing_source_id, price_rub, payload_hash
|
||||
FROM listing_source_snapshots
|
||||
WHERE snapshot_date = CURRENT_DATE
|
||||
),
|
||||
prior 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
|
||||
)
|
||||
inserted AS (
|
||||
INSERT INTO listing_source_events (
|
||||
listing_source_id, change_time, event_type, price_rub, diff_percent
|
||||
)
|
||||
SELECT
|
||||
t.listing_source_id,
|
||||
now(),
|
||||
'price_change',
|
||||
date_trunc('day', now()),
|
||||
e.event_type,
|
||||
t.price_rub,
|
||||
round((t.price_rub - p.price_rub)::numeric / p.price_rub * 100, 4)
|
||||
e.diff_percent
|
||||
FROM today t
|
||||
JOIN prior p ON p.listing_source_id = t.listing_source_id
|
||||
WHERE t.price_rub IS NOT NULL
|
||||
LEFT JOIN LATERAL (
|
||||
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 <> 0
|
||||
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
|
||||
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]:
|
||||
"""Записать дневной снимок listing_sources + price_change-события.
|
||||
def snapshot_listing_sources(
|
||||
db: Session, run_id: int, params: dict[str, Any] | None = None
|
||||
) -> dict[str, int]:
|
||||
"""Записать дневной снимок listing_sources + события изменений.
|
||||
|
||||
Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp).
|
||||
Два set-based statement'а в одной транзакции:
|
||||
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.
|
||||
|
||||
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:
|
||||
# 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(
|
||||
_SNAPSHOT_SQL,
|
||||
{"freshness_days": FRESHNESS_WINDOW_DAYS, "run_id": run_id},
|
||||
)
|
||||
counters["snapshotted"] = snap_result.rowcount or 0
|
||||
|
||||
event_result = db.execute(_EVENT_DIFF_SQL)
|
||||
counters["price_change_events"] = event_result.rowcount or 0
|
||||
# Statement возвращает уже готовые пары (counter_key, n) по типам событий —
|
||||
# dict(...) без Python-агрегации, набор ключей задан инициализацией counters
|
||||
# выше, так что не сработавшие типы остаются нулями, а не исчезают.
|
||||
event_rows = db.execute(_EVENT_DIFF_SQL).fetchall()
|
||||
counters.update(dict(event_rows))
|
||||
|
||||
db.commit()
|
||||
runs_mod.mark_done(db, run_id, counters)
|
||||
logger.info(
|
||||
"snapshot_listing_sources run_id=%d done: snapshotted=%d price_change_events=%d",
|
||||
run_id,
|
||||
counters["snapshotted"],
|
||||
counters["price_change_events"],
|
||||
)
|
||||
logger.info("snapshot_listing_sources run_id=%d done: %s", run_id, counters)
|
||||
return counters
|
||||
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()
|
||||
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ import json
|
|||
import logging
|
||||
import random
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field, fields
|
||||
|
||||
from sqlalchemy import text
|
||||
|
|
@ -306,8 +307,7 @@ def _house_enrichment_counts(db: Session, house_id: int) -> tuple[int, int, int]
|
|||
rc = int(
|
||||
db.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM house_reliability_checks "
|
||||
"WHERE house_id = CAST(:h AS bigint)"
|
||||
"SELECT COUNT(*) FROM house_reliability_checks WHERE house_id = CAST(:h AS bigint)"
|
||||
),
|
||||
{"h": house_id},
|
||||
).scalar_one()
|
||||
|
|
@ -328,6 +328,7 @@ async def backfill_newbuilding_enrichment(
|
|||
force: bool = False,
|
||||
request_delay_sec: float | None = None,
|
||||
dry_run: bool = False,
|
||||
on_progress: Callable[[NewbuildingEnrichBackfillResult], None] | None = None,
|
||||
) -> NewbuildingEnrichBackfillResult:
|
||||
"""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
|
||||
a resolve incurs TWO delays (resolve fetch + enrich fetch).
|
||||
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:
|
||||
NewbuildingEnrichBackfillResult with population sizing, per-house outcome
|
||||
|
|
@ -415,6 +421,8 @@ async def backfill_newbuilding_enrichment(
|
|||
zhk_url: str | None = row["cian_zhk_url"]
|
||||
ext_id: str | None = row["ext_id"]
|
||||
result.processed += 1
|
||||
if on_progress is not None:
|
||||
on_progress(result)
|
||||
|
||||
# Idempotency fast-path: with force=False the SELECT already excludes enriched
|
||||
# houses (price_dynamics + reliability present), so this branch is a belt-and-
|
||||
|
|
@ -696,6 +704,18 @@ async def run_newbuilding_enrich(
|
|||
"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:
|
||||
runs_mod.update_heartbeat(db, run_id, counters)
|
||||
|
||||
|
|
@ -704,6 +724,7 @@ async def run_newbuilding_enrich(
|
|||
limit=limit,
|
||||
force=force,
|
||||
request_delay_sec=request_delay_sec,
|
||||
on_progress=_heartbeat,
|
||||
)
|
||||
|
||||
counters = result.to_dict()
|
||||
|
|
|
|||
|
|
@ -9,26 +9,47 @@
|
|||
видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок.
|
||||
|
||||
Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и
|
||||
поднимает per-day WARNING-алерт, когда данные устарели СВЕРХ допустимого лага
|
||||
поднимает per-day ERROR-алерт, когда данные устарели СВЕРХ допустимого лага
|
||||
публикации — так 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д).
|
||||
Монитор: age > sber_index_max_age_days + lag_allowance.
|
||||
lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) — запас на ИНХЕРЕНТНЫЙ лаг
|
||||
публикации СберИндекса: источник отстаёт на 1-2 месяца, period_month — лейбл
|
||||
ПЕРВОГО числа месяца, а месячный pull ещё не подтянул новейший период. Итог:
|
||||
35 + 25 = 60д. Ниже 60д latest считается «нормально отстающим» → алерта нет
|
||||
(иначе daily-шум на штатном лаге). Выше 60д данные застряли сверх ~2 месяцев
|
||||
→ алерт. Проверено на проде 2026-07-12: max=2026-05-01, age=72д > 60 → alert=1.
|
||||
публикации СберИндекса: источник отстаёт на 1-2 месяца, а period_month — лейбл
|
||||
ПЕРВОГО числа месяца, поэтому даже свежайшая загрузка даёт возраст ~46 суток.
|
||||
Итог: 35 + 25 = 60д. При недельном такте (миграция 212) рабочий диапазон возраста
|
||||
~46..53 — до порога остаётся ~7 суток запаса: один пропущенный недельный цикл
|
||||
поглощается, два подряд дают тревогу. Порог НЕ должен снова оказаться внутри
|
||||
рабочего диапазона — если такт загрузки будут менять, пересчитай потолок
|
||||
(пол + interval_days) и сверь с 60.
|
||||
|
||||
Задача синхронная (DB-only, один SELECT max(period_month)) — запускается
|
||||
kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в
|
||||
run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ
|
||||
функция evaluate_sber_freshness() (frozen-now тестируется без БД).
|
||||
|
||||
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — WARNING
|
||||
достаточен. mark_failed только если sber_price_index недоступна/пуста (нечего
|
||||
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — ERROR-записи
|
||||
достаточно. 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()
|
||||
latest: date | None = row.latest if row is not None else None
|
||||
if latest is None:
|
||||
logger.warning(
|
||||
# ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой,
|
||||
# а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд),
|
||||
# а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке.
|
||||
logger.error(
|
||||
"sber freshness: sber_price_index пуст/недоступен для region=%s "
|
||||
"(вторичка) — оценить свежесть нельзя",
|
||||
SBER_MONITOR_CITY,
|
||||
|
|
@ -156,7 +180,9 @@ def check_sber_freshness(
|
|||
}
|
||||
|
||||
if verdict.stale:
|
||||
logger.warning(
|
||||
# ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR) —
|
||||
# 9 срабатываний на проде дали ноль событий. См. докстринг модуля.
|
||||
logger.error(
|
||||
"sber freshness: max(period_month)=%s устарел на %d дней "
|
||||
"(> порога %d = sber_index_max_age_days %d + lag %d); "
|
||||
"СберИндекс time-adjustment ДКП-сделок мог отстать — "
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
Parse HTML via YandexDetailScraper.parse (pure, no network). Persist via
|
||||
save_detail_enrichment. Track consecutive parse→None results; abort after
|
||||
max_consecutive_blocks (mark_done, not mark_failed — retry next night via
|
||||
NULL detail_enriched_at).
|
||||
max_consecutive_blocks. Прогон с нулём обогащений теперь 'failed', не 'done'
|
||||
(#2674, runs.mark_backfill_finished): на проде 31 прогон из 52 упирался ровно в
|
||||
этот брейкер (attempted=5 failed=5) и все 31 назывались успешными. Остаток
|
||||
снапшота уедет в следующую ночь через NULL detail_enriched_at.
|
||||
|
||||
Why curl_cffi and not YandexDetailScraper.fetch_detail:
|
||||
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.
|
||||
|
||||
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))
|
||||
budget_sec = float(params.get("budget_sec", 3600))
|
||||
|
|
@ -278,9 +281,11 @@ async def run_yandex_detail_backfill(
|
|||
|
||||
counters.duration_sec = time.monotonic() - start
|
||||
current_counters = counters.to_dict()
|
||||
runs_mod.mark_done(db, run_id, current_counters)
|
||||
runs_mod.mark_backfill_finished(
|
||||
db, run_id, current_counters, source="yandex_detail_backfill"
|
||||
)
|
||||
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",
|
||||
run_id,
|
||||
counters.attempted,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,14 @@
|
|||
--
|
||||
-- СОЗДАЁТ:
|
||||
-- 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,
|
||||
-- поэтому NOT NULL DEFAULT '' — '' можно положить в PK, NULL нельзя).
|
||||
--
|
||||
|
|
@ -61,7 +68,8 @@ BEGIN;
|
|||
-- district NOT NULL DEFAULT '' — часть PK; #647 заполнит район, #648 всегда ''.
|
||||
-- sold_median/ask_median nullable — диагностика; ratio NOT NULL (строку без ratio не пишем).
|
||||
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)
|
||||
ratio numeric NOT NULL, -- sold_median_ppm2 / ask_median_ppm2
|
||||
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
|
||||
'Per-rooms asking→sold коэффициент (#648): ratio = median(SOLD ppm²)/median(ASKING ppm²). '
|
||||
'rooms_bucket 0=студия..4=4+; -1 = global fallback (basis=global_fallback, пишется всегда). '
|
||||
'Asking→sold коэффициент (#648): ratio = median(SOLD ppm²)/median(ASKING ppm²). '
|
||||
'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). '
|
||||
'district зарезервирован под #647 (geo), в #648 всегда ''''. '
|
||||
'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 ─────────────────────────────────────────────────────────
|
||||
-- Вынесено как один INSERT...SELECT с CTE-«сторонами» (deal_side / ask_side), чтобы
|
||||
-- Stage 4 (asking_to_sold_ratio_refresh) переиспользовал ровно эту логику. Окно сделок
|
||||
-- = трейлинг 12 мес; listings — текущие активные. ppm²-полоса [30000,600000] и бакет
|
||||
-- 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
|
||||
-- SOLD медианы по бакетам комнат за трейлинг-12мес (ДКП Росреестра).
|
||||
deal_side AS (
|
||||
|
|
|
|||
|
|
@ -13,7 +13,15 @@
|
|||
-- precision БЕЗ единого внешнего HTTP-запроса (в отличие от Nominatim) — но был ТОЛЬКО
|
||||
-- manual script (`python -m app.tasks.backfill_listings_coords_geoportal`), ни разу не
|
||||
-- запускавшийся на 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') по паттерну
|
||||
-- cadastral_geo_match (migration 125) — pure internal DB op, SAFE to enable=true.
|
||||
|
|
|
|||
88
tradein-mvp/backend/data/sql/192_tradein_users_auth.sql
Normal file
88
tradein-mvp/backend/data/sql/192_tradein_users_auth.sql
Normal 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;
|
||||
127
tradein-mvp/backend/data/sql/193_tradein_users_seed.sql
Normal file
127
tradein-mvp/backend/data/sql/193_tradein_users_seed.sql
Normal 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;
|
||||
150
tradein-mvp/backend/data/sql/194_deal_city_price_bands_tiers.sql
Normal file
150
tradein-mvp/backend/data/sql/194_deal_city_price_bands_tiers.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
44
tradein-mvp/backend/data/sql/196_listings_city.sql
Normal file
44
tradein-mvp/backend/data/sql/196_listings_city.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
49
tradein-mvp/backend/data/sql/198_scrape_proxy_rotations.sql
Normal file
49
tradein-mvp/backend/data/sql/198_scrape_proxy_rotations.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
100
tradein-mvp/backend/data/sql/200_region_code_foreign_cities.sql
Normal file
100
tradein-mvp/backend/data/sql/200_region_code_foreign_cities.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue