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:
|
outputs:
|
||||||
backend: ${{ steps.filter.outputs.backend }}
|
backend: ${{ steps.filter.outputs.backend }}
|
||||||
frontend: ${{ steps.filter.outputs.frontend }}
|
frontend: ${{ steps.filter.outputs.frontend }}
|
||||||
|
browser: ${{ steps.filter.outputs.browser }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dorny/paths-filter@v3
|
- uses: dorny/paths-filter@v3
|
||||||
|
|
@ -43,10 +44,23 @@ jobs:
|
||||||
# [tool.uv.workspace] меняют реальные зависимости → гейт обязан бежать.
|
# [tool.uv.workspace] меняют реальные зависимости → гейт обязан бежать.
|
||||||
- 'tradein-mvp/uv.lock'
|
- 'tradein-mvp/uv.lock'
|
||||||
- 'tradein-mvp/pyproject.toml'
|
- 'tradein-mvp/pyproject.toml'
|
||||||
|
# auth/roles.yaml — общий RBAC-конфиг обоих стеков, лежит В КОРНЕ
|
||||||
|
# репы и монтируется в tradein-backend (/app/auth/roles.yaml).
|
||||||
|
# tests/test_rbac.py читает именно его, поэтому правка ролей обязана
|
||||||
|
# гонять и этот гейт. Без строки правка roles.yaml не запускала НИ
|
||||||
|
# ОДИН сьют (та же дыра закрыта симметрично в ci.yml) — так на main
|
||||||
|
# уехал красный test_get_role_known_users (2026-07-30 → PR #2587).
|
||||||
|
- 'auth/**'
|
||||||
- '.forgejo/workflows/ci-tradein.yml'
|
- '.forgejo/workflows/ci-tradein.yml'
|
||||||
frontend:
|
frontend:
|
||||||
- 'tradein-mvp/frontend/**'
|
- 'tradein-mvp/frontend/**'
|
||||||
- '.forgejo/workflows/ci-tradein.yml'
|
- '.forgejo/workflows/ci-tradein.yml'
|
||||||
|
browser:
|
||||||
|
# Сайдкар — сервис ВНЕ uv-воркспейса (tradein-mvp/pyproject.toml
|
||||||
|
# members = backend + packages/*), со своим Dockerfile и без pyproject,
|
||||||
|
# поэтому и фильтр отдельный: backend-гейт его тестов не видел вовсе.
|
||||||
|
- 'tradein-mvp/browser/**'
|
||||||
|
- '.forgejo/workflows/ci-tradein.yml'
|
||||||
|
|
||||||
backend-tests:
|
backend-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -89,15 +103,73 @@ jobs:
|
||||||
run: uv sync --frozen
|
run: uv sync --frozen
|
||||||
|
|
||||||
- name: Run pytest (tradein-mvp/backend)
|
- name: Run pytest (tradein-mvp/backend)
|
||||||
# DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает
|
# БЕЗ deselect'ов — сьют гоняется целиком (#2722).
|
||||||
# ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state
|
#
|
||||||
# leak из другого test-модуля, pre-existing. Второй исторический deselect
|
# Здесь два года жил `--deselect tests/test_search_api.py::test_search_cache_hit`
|
||||||
# (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в
|
# с объяснением «падает ТОЛЬКО в whole-suite ordering, в изоляции проходит —
|
||||||
# полном прогоне (проверено локально: 2947 passed / 1 failed). Список обязан
|
# global-state leak из другого модуля». Объяснение было неверным в обеих
|
||||||
# совпадать с test-job в deploy-tradein.yml.
|
# половинах: тест падал и в изоляции тоже (401 vs 200), потому что он —
|
||||||
run: |
|
# единственный HTTP-тест в своём файле — ходил в /api/v1/search БЕЗ заголовка
|
||||||
uv run pytest -q \
|
# X-Authenticated-User, а RBAC-гард отвечает на такое 401 (ровно то, что
|
||||||
--deselect "tests/test_search_api.py::test_search_cache_hit"
|
# фиксирует tests/test_estimate_idor.py). Причина была в тесте, а не в порядке;
|
||||||
|
# заголовок добавлен, deselect снят, полный прогон зелёный.
|
||||||
|
#
|
||||||
|
# Не добавлять сюда новые deselect'ы: молча выключенный тест — это тот же
|
||||||
|
# класс дефекта, что каталог вне пайплайна (#2722). Тест либо чинится, либо
|
||||||
|
# помечается xfail с причиной В КОДЕ, где её видно рядом с самим тестом.
|
||||||
|
#
|
||||||
|
# NB: в deploy-tradein.yml (post-merge test-job) свой экземпляр этого
|
||||||
|
# deselect'а — он остаётся до #2680, который правит тот файл. Расхождение
|
||||||
|
# безвредно: pre-merge гейт тест гоняет, post-merge просто пропустит зелёный.
|
||||||
|
run: uv run pytest -q
|
||||||
|
|
||||||
|
# Тесты браузерного сайдкара (#2722). До этого job'а они не бежали НИГДЕ:
|
||||||
|
# ci-tradein гейтил только backend/frontend, deploy-tradein — тоже, а каталог
|
||||||
|
# вне uv-воркспейса, так что и `uv run pytest` из backend их не собирал. Итог:
|
||||||
|
# 4 теста лежали красными на main (с 2026-06-20 и 2026-07-02), файл при этом
|
||||||
|
# правился, и никто не узнал. Починка — PR #2724, этот job закрывает причину.
|
||||||
|
#
|
||||||
|
# Почему НЕ переиспользуем backend-job:
|
||||||
|
# 1. сайдкар не член воркспейса → `uv sync --frozen` его не ставит;
|
||||||
|
# 2. aiohttp (единственная не-stdlib зависимость сьюта) нет в tradein-mvp/uv.lock;
|
||||||
|
# 3. разный scope paths-filter: правка browser/ не должна гонять backend-сьют.
|
||||||
|
browser-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.browser == 'true'
|
||||||
|
# Сьют идёт ~15с. Лимит — страховка от зависшего теста: у сайдкара нет своего
|
||||||
|
# pyproject, а значит и pytest-timeout'а backend'а (timeout=120). Дешевле
|
||||||
|
# взять нативный job-таймаут, чем тащить плагин ради одного каталога.
|
||||||
|
timeout-minutes: 10
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./tradein-mvp/browser
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
# 3.12 — как в browser/Dockerfile (FROM python:3.12-slim).
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install test deps
|
||||||
|
# ВЕСЬ список: pytest + aiohttp. Ни playwright, ни camoufox, ни закачки
|
||||||
|
# Firefox — camoufox импортируется ЛЕНИВО внутри _launch_browser
|
||||||
|
# (server.py, `from camoufox.async_api import AsyncCamoufox`), а сами тесты
|
||||||
|
# мокают _ensure_browser/_do_fetch и грузят server.py по пути через importlib.
|
||||||
|
# pytest-asyncio тоже НЕ нужен: ни одного `async def test_` — каждый тест сам
|
||||||
|
# крутит asyncio.run(). Проверено локально на venv ровно из этих двух пакетов.
|
||||||
|
#
|
||||||
|
# aiohttp без пина — ровно как в browser/Dockerfile (`pip install ... aiohttp`),
|
||||||
|
# то есть гейт видит ту же версию, что уедет в образ. Пин здесь означал бы
|
||||||
|
# проверку версии, которой в проде нет.
|
||||||
|
run: pip install pytest aiohttp
|
||||||
|
|
||||||
|
- name: Run pytest (tradein-mvp/browser)
|
||||||
|
# Каталог без pyproject/pytest.ini → дефолтная конфигурация, ничего
|
||||||
|
# не deselect'ится. Ожидание: 108 passed, 0 failed, 0 skipped.
|
||||||
|
run: pytest -q
|
||||||
|
|
||||||
frontend-checks:
|
frontend-checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -133,3 +205,9 @@ jobs:
|
||||||
- name: Lint (next lint)
|
- name: Lint (next lint)
|
||||||
# Blocking: любая ESLint-ошибка → job RED.
|
# Blocking: любая ESLint-ошибка → job RED.
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Mera-public isolation guard (#2631)
|
||||||
|
# Blocking: статический import-graph публичного лэндинга не должен
|
||||||
|
# достигать закрытого контура (useMe/lib/api/sessionId/isPathAllowed/
|
||||||
|
# GuardedRoute вне next/dynamic). Инвариант этапа 1 #2545.
|
||||||
|
run: npm run check:mera-public-isolation
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,14 @@ jobs:
|
||||||
backend:
|
backend:
|
||||||
- 'backend/**'
|
- 'backend/**'
|
||||||
- 'data/sql/**'
|
- 'data/sql/**'
|
||||||
|
# auth/roles.yaml — общий RBAC-конфиг ОБОИХ стеков (bind-mount в
|
||||||
|
# backend и в tradein-backend). Правка ролей/пользователей меняет
|
||||||
|
# поведение backend/tests/test_rbac.py, но сам файл лежит вне
|
||||||
|
# 'backend/**' → без этой строки сьют no-op'ился, и правка уезжала
|
||||||
|
# в main без единого прогона. Так и случилось 2026-07-30: user2
|
||||||
|
# переведён в expired, test_get_role_known_users стал красным и
|
||||||
|
# доехал до main незамеченным (починен в PR #2587).
|
||||||
|
- 'auth/**'
|
||||||
- '.forgejo/workflows/ci.yml'
|
- '.forgejo/workflows/ci.yml'
|
||||||
frontend:
|
frontend:
|
||||||
- 'frontend/**'
|
- 'frontend/**'
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@ on:
|
||||||
- ".forgejo/workflows/deploy.yml"
|
- ".forgejo/workflows/deploy.yml"
|
||||||
- "data/sql/**"
|
- "data/sql/**"
|
||||||
- "ops/glitchtip-auth-forwarder/**"
|
- "ops/glitchtip-auth-forwarder/**"
|
||||||
|
# Bootstrap-SQL (создание БД auth, ALTER ROLE паролем из env) исполняется шагом
|
||||||
|
# деплоя ниже — без этого триггера правка bootstrap-файла молча не доезжала бы
|
||||||
|
# до прода до следующего чужого коммита в backend/.
|
||||||
|
- "ops/db-bootstrap/**"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
|
|
@ -320,6 +324,70 @@ jobs:
|
||||||
echo "⚠️ GENDESIGN_FDW_PASSWORD not set in backend/.env.runtime — skipping ALTER ROLE for tradein_fdw_reader"
|
echo "⚠️ GENDESIGN_FDW_PASSWORD not set in backend/.env.runtime — skipping ALTER ROLE for tradein_fdw_reader"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── БД `auth` — единое хранилище доступов «Меры» и «Птицы» ──────────────
|
||||||
|
# Расположение файлов: схема лежит в data/sql/auth/ (ПОДКАТАЛОГ, не плоский
|
||||||
|
# data/sql/) — цикл миграций выше использует `ls -1 data/sql/*.sql`, который в
|
||||||
|
# подкаталоги не рекурсирует. Значит эти файлы физически не могут примениться
|
||||||
|
# в БД gendesign, даже если кто-то забудет про разделение; при этом триггер
|
||||||
|
# `data/sql/**` (paths выше) подкаталог покрывает, деплой запускается сам.
|
||||||
|
# Свой _schema_migrations живёт ВНУТРИ БД auth: отдельная база — отдельный
|
||||||
|
# трекинг, имена файлов двух каталогов не конфликтуют между собой.
|
||||||
|
# Порядок: сразу после bootstrap'а FDW-пароля и ДО `compose up -d` — падение
|
||||||
|
# здесь останавливает деплой (exit 1) до подъёма нового кода.
|
||||||
|
# `source backend/.env.runtime` уже выполнен выше (строка с FDW-паролем), из него
|
||||||
|
# берётся AUTH_DB_PASSWORD.
|
||||||
|
echo "→ Bootstrapping auth database (idempotent)"
|
||||||
|
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||||
|
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=on \
|
||||||
|
< ops/db-bootstrap/create_auth_db.sql \
|
||||||
|
|| { echo "FAILED to create auth database"; exit 1; }
|
||||||
|
|
||||||
|
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||||
|
psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on -c "
|
||||||
|
CREATE TABLE IF NOT EXISTS _schema_migrations (
|
||||||
|
filename TEXT PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
"
|
||||||
|
|
||||||
|
for sql_file in $(ls -1 data/sql/auth/*.sql 2>/dev/null | sort); do
|
||||||
|
fname=$(basename "$sql_file")
|
||||||
|
# `| tr -d '[:space:]'` — как в deploy-tradein.yml: без него psql-вывод с
|
||||||
|
# лишним пробелом/CR ломает сравнение с "0" и миграция молча считается
|
||||||
|
# применённой.
|
||||||
|
applied=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||||
|
psql -U "$POSTGRES_USER" -d auth -tAc \
|
||||||
|
"SELECT COUNT(*) FROM _schema_migrations WHERE filename='$fname'" \
|
||||||
|
| tr -d '[:space:]')
|
||||||
|
if [ "$applied" = "0" ]; then
|
||||||
|
echo "→ Applying auth migration: $fname"
|
||||||
|
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||||
|
psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on \
|
||||||
|
< "$sql_file" \
|
||||||
|
|| { echo "FAILED on auth migration: $fname"; exit 1; }
|
||||||
|
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||||
|
psql -U "$POSTGRES_USER" -d auth -c \
|
||||||
|
"INSERT INTO _schema_migrations (filename) VALUES ('$fname') ON CONFLICT DO NOTHING;"
|
||||||
|
else
|
||||||
|
echo "✓ Already applied (auth): $fname"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "All auth migrations applied."
|
||||||
|
|
||||||
|
# Пароль роли auth_app из env (post-migration bootstrap): миграция
|
||||||
|
# data/sql/auth/002_auth_app_role.sql создаёт роль БЕЗ пароля, пароль живёт
|
||||||
|
# только в /opt/gendesign/backend/.env.runtime. Пустая переменная — не ошибка:
|
||||||
|
# PR-1 ещё никого не подключает к этой БД, роль просто остаётся без пароля.
|
||||||
|
if [ -n "${AUTH_DB_PASSWORD:-}" ]; then
|
||||||
|
echo "→ Applying auth_app password from env"
|
||||||
|
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||||
|
psql -U "$POSTGRES_USER" -d auth -v ON_ERROR_STOP=on \
|
||||||
|
-v "pw=$AUTH_DB_PASSWORD" \
|
||||||
|
< ops/db-bootstrap/set_auth_app_password.sql
|
||||||
|
else
|
||||||
|
echo "⚠️ AUTH_DB_PASSWORD not set in backend/.env.runtime — skipping ALTER ROLE for auth_app"
|
||||||
|
fi
|
||||||
|
|
||||||
# Build local-only sidecar images (glitchtip-auth-forwarder).
|
# Build local-only sidecar images (glitchtip-auth-forwarder).
|
||||||
# Эти services не в GHCR — сборка происходит на VPS на каждом deploy.
|
# Эти services не в GHCR — сборка происходит на VPS на каждом deploy.
|
||||||
# Cache-friendly: первый build ~30s, последующие 1-3s если файлы не менялись.
|
# Cache-friendly: первый build ~30s, последующие 1-3s если файлы не менялись.
|
||||||
|
|
|
||||||
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).
|
# Users managed via caddy/users.caddy.snippet (git history = audit trail).
|
||||||
# Public exclusions: /health (liveness probe), /preview/* (static mockups).
|
# Public exclusions: /health (liveness probe), /preview/* (static mockups).
|
||||||
#
|
#
|
||||||
|
# #2558: с 2026-07 basic_auth гейтит ТОЛЬКО Site Finder (`/`, `/api/*`,
|
||||||
|
# `/analytics` и т.д.). `/trade-in/*` (+ `/sale-share` redirect) вынесены ВЫШЕ
|
||||||
|
# import'а — у trade-in своя авторизация (форма входа + opaque session-cookie,
|
||||||
|
# см. #2552) поверх RBAC (`tradein-mvp/backend/app/core/rbac.py`). Site Finder
|
||||||
|
# всё ещё легаси-пилотный basic_auth (roles.yaml dual-mode остаётся живым для
|
||||||
|
# него — НЕ трогать caddy/users.caddy.snippet).
|
||||||
|
#
|
||||||
# IMPORTANT: route { } block is required to preserve directive order.
|
# IMPORTANT: route { } block is required to preserve directive order.
|
||||||
# Without route { }, Caddy executes directives in hard-coded default order
|
# Without route { }, Caddy executes directives in hard-coded default order
|
||||||
# (basic_auth runs before handle), making /health and /preview/* exclusions
|
# (basic_auth runs before handle), making /health and /preview/* exclusions
|
||||||
|
|
@ -70,26 +77,56 @@ gendsgn.ru {
|
||||||
# Оба ДО auth-import, иначе ассеты страницы уходят в @tradein (под auth) → 401 → без CSS.
|
# Оба ДО auth-import, иначе ассеты страницы уходят в @tradein (под auth) → 401 → без CSS.
|
||||||
@uipreview path /trade-in/ui-preview/* /trade-in/_next/static/*
|
@uipreview path /trade-in/ui-preview/* /trade-in/_next/static/*
|
||||||
handle @uipreview {
|
handle @uipreview {
|
||||||
reverse_proxy tradein-frontend:3000
|
reverse_proxy tradein-frontend:3000 {
|
||||||
|
# #2558 review: тот же периметр-scrub, что и у /trade-in/api/* и
|
||||||
|
# @tradein ниже — этот блок тоже теперь ДО basic_auth, клиент
|
||||||
|
# мог бы прислать свой X-Authenticated-User. Сейчас инертно
|
||||||
|
# (страница статична, у tradein-frontend нет секрета для
|
||||||
|
# X-Internal-Auth-Secret), но убираем ради единообразия периметра,
|
||||||
|
# а не полагаясь на то, что downstream ничего не делает с заголовком.
|
||||||
|
header_up -X-Authenticated-User
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Auth gate (applies to all routes below within this route block).
|
# #2558: Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker
|
||||||
import caddy/users.caddy.snippet
|
# stack, подключен через gendesign_shared network. Секция ЦЕЛИКОМ ДО
|
||||||
|
# `import caddy/users.caddy.snippet` ниже — /trade-in имеет собственную
|
||||||
# Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker stack,
|
# авторизацию (форма входа + opaque session-cookie, #2552; RBAC-проверка
|
||||||
# подключен через gendesign_shared network. Routes ДО универсального handle
|
# роли внутри tradein-backend, `app/core/rbac.py`), Site Finder basic_auth
|
||||||
# потому что Caddy матчит handle-блоки сверху вниз.
|
# ей больше не нужен и не должен применяться (short-circuit сверху вниз,
|
||||||
|
# как /health и /preview/* выше).
|
||||||
|
#
|
||||||
|
# X-Authenticated-User — ЯВНОЕ УДАЛЕНИЕ (`header_up -X-Authenticated-User`),
|
||||||
|
# НЕ `header_up X-Authenticated-User {http.auth.user.id}`. Причина: этот
|
||||||
|
# блок больше не идёт ПОСЛЕ basic_auth, поэтому `{http.auth.user.id}`
|
||||||
|
# никогда не резолвится авторизованным юзером на этом пути.
|
||||||
|
# Проверено эмпирически (echo-стенд на образе caddy:2, `caddy adapt`):
|
||||||
|
# старая Set-форма (`header_up X-Authenticated-User {http.auth.user.id}`)
|
||||||
|
# НЕ пропустила бы клиентский заголовок насквозь и НЕ оставила бы поле
|
||||||
|
# пустым — Caddy подставляет НЕРАЗРЕШЁННЫЙ плейсхолдер как ЛИТЕРАЛЬНУЮ
|
||||||
|
# строку (`ReplaceKnown`), т.е. upstream получил бы буквально
|
||||||
|
# `X-Authenticated-User: {http.auth.user.id}`. Для backend (auth_mode=
|
||||||
|
# "dual", `app/core/config.py`) это НЕ подмена личности — legacy path
|
||||||
|
# (`rbac.py:186`) сделал бы `get_role("{http.auth.user.id}")`, юзер не
|
||||||
|
# найден в roles.yaml → 403 для всех. Т.е. старая форма была бы не
|
||||||
|
# security-дырой, а fail-closed-but-сломанной (все trade-in запросы без
|
||||||
|
# session-cookie получали бы 403 вместо ожидаемого 401/редиректа на логин).
|
||||||
|
# `-Field` остаётся правильным выбором не потому что Set был бы дырой, а
|
||||||
|
# потому что это ЕДИНСТВЕННАЯ форма с явно задокументированной семантикой
|
||||||
|
# "удалить заголовок" (Caddyfile reverse_proxy directive: `-<field>` =
|
||||||
|
# delete) — корректное поведение не должно зависеть от того, как именно
|
||||||
|
# Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции.
|
||||||
|
# X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается
|
||||||
|
# из env (Set-операция с непустым значением, никак не связана с auth-гейтом
|
||||||
|
# basic_auth), это единственное, что теперь отсекает подделку заголовков
|
||||||
|
# изнутри gendesign_shared network для legacy dual-mode пути.
|
||||||
handle /trade-in/api/* {
|
handle /trade-in/api/* {
|
||||||
# `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
|
# `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
|
||||||
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
|
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
|
||||||
# префикса basePath /trade-in (Next.js basePath leak).
|
# префикса basePath /trade-in (Next.js basePath leak).
|
||||||
uri strip_prefix /trade-in
|
uri strip_prefix /trade-in
|
||||||
reverse_proxy tradein-backend:8000 {
|
reverse_proxy tradein-backend:8000 {
|
||||||
header_up X-Authenticated-User {http.auth.user.id}
|
header_up -X-Authenticated-User
|
||||||
# #2213 defense-in-depth: общий секрет Caddy↔tradein-backend. header_up
|
|
||||||
# с value ПЕРЕЗАПИСЫВАЕТ (стирает) любой клиентский X-Internal-Auth-Secret —
|
|
||||||
# тот же механизм, что защищает X-Authenticated-User выше. Пусто пока
|
|
||||||
# TRADEIN_INTERNAL_AUTH_SECRET не задан в .env (fail-open, backend не проверяет).
|
|
||||||
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
|
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -98,6 +135,23 @@ gendsgn.ru {
|
||||||
# Next basePath=/trade-in → редиректим на канонический /trade-in/sale-share
|
# Next basePath=/trade-in → редиректим на канонический /trade-in/sale-share
|
||||||
# (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL
|
# (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL
|
||||||
# в адресной строке требует отдельного Next-app с basePath=/sale-share.
|
# в адресной строке требует отдельного Next-app с basePath=/sale-share.
|
||||||
|
# #2558: перенесён ВЫШЕ auth-import вместе с trade-in — редирект ведёт на
|
||||||
|
# /trade-in/sale-share, для которого теперь нет Caddy basic_auth (как и
|
||||||
|
# для остального /trade-in). Это НЕ делает страницу публичной: она всё
|
||||||
|
# ещё за собственной авторизацией trade-in — `RouteGuard` во фронте
|
||||||
|
# (`app/layout.tsx`) и сессия для `/api/v1/buildings/sale-share*` на
|
||||||
|
# бэке; без валидной сессии юзер получит редирект на /login, а не
|
||||||
|
# контент. Смысл переноса — не открыть страницу всем, а убрать
|
||||||
|
# несогласованность: короткий URL не должен быть строже (Caddy
|
||||||
|
# basic_auth) целевого адреса, к которому и так уже нет
|
||||||
|
# basic_auth-барьера (только собственный login trade-in).
|
||||||
|
#
|
||||||
|
# ОБНОВЛЕНО 2026-07-31: доступ к разделу сузился с «pilot + admin» до
|
||||||
|
# ТОЛЬКО admin — «Поиск домов» признан тестовым продуктом, клиентам не
|
||||||
|
# показывается (deny в auth/roles.yaml для pilot и analyst + в
|
||||||
|
# DB_ROLE_PATHS для employee/manager). Сам редирект не трогаем: он ведёт
|
||||||
|
# на страницу, а гейт стоит на роли — для всех, кроме admin, короткий
|
||||||
|
# адрес приведёт на NoAccessScreen.
|
||||||
@saleshare path /sale-share /sale-share/
|
@saleshare path /sale-share /sale-share/
|
||||||
handle @saleshare {
|
handle @saleshare {
|
||||||
redir /trade-in/sale-share permanent
|
redir /trade-in/sale-share permanent
|
||||||
|
|
@ -110,13 +164,17 @@ gendsgn.ru {
|
||||||
handle @tradein {
|
handle @tradein {
|
||||||
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
|
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
|
||||||
reverse_proxy tradein-frontend:3000 {
|
reverse_proxy tradein-frontend:3000 {
|
||||||
header_up X-Authenticated-User {http.auth.user.id}
|
# См. комментарий над /trade-in/api/* выше — та же логика (явное
|
||||||
# #2213: симметрично с /trade-in/api/* — перезаписываем секрет из env
|
# удаление вместо Set с пустым {http.auth.user.id}).
|
||||||
# (стирает клиентский), на случай SSR-forwardʼa фронтом в backend.
|
header_up -X-Authenticated-User
|
||||||
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
|
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Auth gate — с #2558 применяется ТОЛЬКО к Site Finder (handle /api/* и
|
||||||
|
# handle {} ниже). Trade-In уже отработал и short-circuit'нул выше.
|
||||||
|
import caddy/users.caddy.snippet
|
||||||
|
|
||||||
handle /api/* {
|
handle /api/* {
|
||||||
reverse_proxy backend:8000 {
|
reverse_proxy backend:8000 {
|
||||||
header_up X-Authenticated-User {http.auth.user.id}
|
header_up X-Authenticated-User {http.auth.user.id}
|
||||||
|
|
@ -135,6 +193,129 @@ www.gendsgn.ru {
|
||||||
redir https://gendsgn.ru{uri} permanent
|
redir https://gendsgn.ru{uri} permanent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# МЕРА B2C — публичный периметр (ЭТАП 1 плана B2C-запуска, БЕЗ функционала).
|
||||||
|
#
|
||||||
|
# Архитектурное решение: отдельный домен, а НЕ дырка в блоке gendsgn.ru
|
||||||
|
# выше. На gendsgn.ru модель "запрещено всё, кроме дырок ВЫШЕ auth-import" —
|
||||||
|
# порядко-зависимая и общая для B2B (trade-in v2, admin, scrapers, /api/*).
|
||||||
|
# Здесь, наоборот, allowlist-by-default: basic_auth НЕТ ВООБЩЕ (не импортируем
|
||||||
|
# caddy/users.caddy.snippet), потому что на этом site-блоке B2B-маршрутов
|
||||||
|
# физически не объявлено — их нечего "открывать". Явно перечислены РОВНО два
|
||||||
|
# handle (корень "/" + статика Next _next/*), всё остальное — финальный
|
||||||
|
# catch-all `handle { respond 404 }`. Регресс-тест на эту модель:
|
||||||
|
# scripts/smoke-mera-perimeter.sh (проверяет, что B2B-путь здесь = 404, а не
|
||||||
|
# 200/401 — т.е. не был случайно проброшен).
|
||||||
|
#
|
||||||
|
# Next.js basePath=/trade-in запечён в prod-образ tradein-frontend (тот же
|
||||||
|
# контейнер, что обслуживает и gendsgn.ru/trade-in/*, см. build-args в
|
||||||
|
# .forgejo/workflows/deploy-tradein.yml) — поэтому корень домена rewrite'ится
|
||||||
|
# на internal-путь /trade-in/mera-public (страница-заглушка,
|
||||||
|
# tradein-mvp/frontend/src/app/mera-public/). Пользователь префикс /trade-in
|
||||||
|
# никогда не видит — rewrite меняет путь ТОЛЬКО для Caddy→backend запроса,
|
||||||
|
# это не HTTP-редирект браузера.
|
||||||
|
#
|
||||||
|
# DNS: A-record meraocenka.ru → IP VPS — ТРЕБУЕТСЯ ДО того, как сюда придёт
|
||||||
|
# реальный трафик. Если записи ещё нет на момент деплоя этого блока: `caddy
|
||||||
|
# reload`/`up -d --force-recreate caddy` в deploy.yml НЕ падает (конфиг
|
||||||
|
# синтаксически валиден, ошибка сертификата асинхронна и per-hostname) — Caddy
|
||||||
|
# просто залогирует неудачную попытку ACME-выпуска для meraocenka.ru (DNS не
|
||||||
|
# резолвится на этот сервер → HTTP-01/TLS-ALPN challenge недостижим) и продолжит
|
||||||
|
# ретраить с backoff, ПОКА запись не появится. Остальные site-блоки в этом же
|
||||||
|
# Caddyfile (gendsgn.ru, obsidian.gendsgn.ru и т.д.) не затрагиваются —
|
||||||
|
# автоматический HTTPS в Caddy изолирован per-hostname (тот же принцип, что
|
||||||
|
# уже описан для status.gendsgn.ru ниже). Повторные неудачные попытки ДО
|
||||||
|
# появления DNS могут исчерпать rate-limit Let's Encrypt (5 failed
|
||||||
|
# validations/hostname/hour) — не критично, просто подождать; `docker volume
|
||||||
|
# rm gendesign_caddy_data` для этого НЕ нужен (и вообще требует user-approval).
|
||||||
|
meraocenka.ru {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/meraocenka.ru.log
|
||||||
|
}
|
||||||
|
|
||||||
|
# Корень домена → лэндинг МЕРЫ (#2615 заменил заглушку этого этапа на
|
||||||
|
# полноценную страницу). rewrite добавляет basePath-префикс только для
|
||||||
|
# Caddy→backend хопа, пользователь /trade-in никогда не видит.
|
||||||
|
handle / {
|
||||||
|
rewrite * /trade-in/mera-public
|
||||||
|
reverse_proxy tradein-frontend:3000 {
|
||||||
|
# Тот же периметр-скраб, что у @uipreview (:87) и @tradein ниже.
|
||||||
|
# Этот блок вообще не под basic_auth, поэтому анонимный клиент
|
||||||
|
# тем более может прислать свой X-Authenticated-User. Сейчас
|
||||||
|
# инертно (лэндинг статичен, backend-вызовов нет), но снимаем
|
||||||
|
# ради единообразия периметра, а не полагаясь на то, что
|
||||||
|
# downstream ничего не делает с заголовком — иначе на этапе 5,
|
||||||
|
# когда откроется публичный /estimate, это станет дырой.
|
||||||
|
header_up -X-Authenticated-User
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Подстраницы САМОГО лэндинга. Нужны с момента мержа #2615: футер ссылается
|
||||||
|
# на политику обработки ПДн через next/link (`PRIVACY_PATH`), а Next с
|
||||||
|
# basePath эмитит её как /trade-in/mera-public/privacy. Без этого handle
|
||||||
|
# ссылка уходила бы в catch-all 404 ниже — то есть обязательный по 152-ФЗ
|
||||||
|
# документ был бы недоступен с публичной страницы.
|
||||||
|
#
|
||||||
|
# Matcher намеренно узкий — ровно поддерево лэндинга, НЕ /trade-in/*.
|
||||||
|
# B2B-дерево (/trade-in/v2, /trade-in/api/*, /trade-in/admin/*, /history)
|
||||||
|
# под него не подпадает и по-прежнему отдаёт 404. Регресс-тест на это —
|
||||||
|
# в scripts/smoke-mera-perimeter.sh.
|
||||||
|
handle /trade-in/mera-public/* {
|
||||||
|
reverse_proxy tradein-frontend:3000 {
|
||||||
|
header_up -X-Authenticated-User
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Next.js уже эмитит ссылки на статику с /trade-in-префиксом (тот же
|
||||||
|
# basePath) — passthrough без rewrite. Нужны для рендера страницы (JS/CSS
|
||||||
|
# чанки), сами по себе не содержат ни B2B-данных, ни секретов.
|
||||||
|
#
|
||||||
|
# Именно `static/*`, а не весь `_next/*` — тот же матчер, что у @uipreview
|
||||||
|
# (:78), который в проде доказал, что этого хватает для рендера. Широкий
|
||||||
|
# `_next/*` открыл бы анонимам ещё и `/_next/image` (оптимизация картинок,
|
||||||
|
# CPU-нагрузка по запросу), который на лэндинге не используется вообще:
|
||||||
|
# next/image в tradein-mvp/frontend/src/app/mera-public/ не импортируется.
|
||||||
|
handle /trade-in/_next/static/* {
|
||||||
|
reverse_proxy tradein-frontend:3000 {
|
||||||
|
header_up -X-Authenticated-User
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# #2631: favicon — единственный корневой статик, который браузер запрашивает
|
||||||
|
# сам; без явного handle падал в allowlist-404. app/favicon.ico отдаёт Next
|
||||||
|
# по корневому пути через basePath /trade-in.
|
||||||
|
handle /favicon.ico {
|
||||||
|
rewrite * /trade-in/favicon.ico
|
||||||
|
reverse_proxy tradein-frontend:3000 {
|
||||||
|
header_up -X-Authenticated-User
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin,
|
||||||
|
# /scrapers/*, /trade-in/api/*, /history, ...) — 404, НЕ проксируется.
|
||||||
|
handle {
|
||||||
|
respond 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Домены-спутники МЕРА → 301 на канонический meraocenka.ru.
|
||||||
|
# Решение 2026-07-31: канонический адрес ровно один, остальные две регистрации
|
||||||
|
# ловят (а) альтернативный транслит «оценка» — ocenka/otsenka, на слух
|
||||||
|
# неразличимы, (б) прежний рабочий вариант merahome. Отдельные site-блоки, а не
|
||||||
|
# matcher внутри основного: Caddy матчит по hostname и выпускает свой
|
||||||
|
# сертификат на каждый, поэтому DNS A-record нужен для КАЖДОГО из них — иначе
|
||||||
|
# ACME для этого хоста будет ретраиться (безвредно, см. комментарий выше, но
|
||||||
|
# лучше завести записи сразу).
|
||||||
|
# `{uri}` сохраняет путь и query — короткая ссылка с визитки не теряет ?id=.
|
||||||
|
merahome.ru {
|
||||||
|
redir https://meraocenka.ru{uri} permanent
|
||||||
|
}
|
||||||
|
|
||||||
|
meraotsenka.ru {
|
||||||
|
redir https://meraocenka.ru{uri} permanent
|
||||||
|
}
|
||||||
|
|
||||||
# Obsidian Self-hosted LiveSync (CouchDB backend).
|
# Obsidian Self-hosted LiveSync (CouchDB backend).
|
||||||
# Auto-TLS Let's Encrypt. CORS уже включён на стороне CouchDB через bootstrap
|
# Auto-TLS Let's Encrypt. CORS уже включён на стороне CouchDB через bootstrap
|
||||||
# (см. scripts/setup-couchdb.sh). Basic-auth — на стороне CouchDB (admin user).
|
# (см. scripts/setup-couchdb.sh). Basic-auth — на стороне CouchDB (admin user).
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,39 @@ roles:
|
||||||
- "/admin/**"
|
- "/admin/**"
|
||||||
- "/api/v1/admin/**"
|
- "/api/v1/admin/**"
|
||||||
- "/trade-in/api/v1/admin/**"
|
- "/trade-in/api/v1/admin/**"
|
||||||
|
# Внутренние разделы, закрытые от клиентских аккаунтов (решение владельца
|
||||||
|
# продукта 2026-07-31): «Доля в продаже» — аналитика рынка, «Кэш» —
|
||||||
|
# состояние кэшей/скраперов. Зеркало deny-списка DB-ролей employee/manager
|
||||||
|
# (tradein-mvp/backend/app/services/auth_session.py: DB_ROLE_PATHS).
|
||||||
|
#
|
||||||
|
# Зачем копия здесь, если клиенты ходят session-cookie'ой: снаружи легаси
|
||||||
|
# trusted-header ветка НЕДОСТИЖИМА — с #2558 Caddy срезает входящий
|
||||||
|
# X-Authenticated-User на всём /trade-in/* (`header_up
|
||||||
|
# -X-Authenticated-User` в handle /trade-in/api/* и в @tradein), так что
|
||||||
|
# ни один клиентский аккаунт по ней не ходит. Паттерны нужны для другого:
|
||||||
|
# 1) ВНУТРИСЕТЕВОЙ dual-mode трафик — запросы изнутри gendesign_shared с
|
||||||
|
# валидным X-Internal-Auth-Secret; ими ходят QA-смоуки вида
|
||||||
|
# `docker exec tradein-backend curl localhost:8000
|
||||||
|
# -H 'X-Authenticated-User: ...'` — они резолвятся именно через
|
||||||
|
# roles.yaml, и без этих строк смоук показал бы 200 там, где
|
||||||
|
# реальный клиент получает 403;
|
||||||
|
# 2) чтобы legacy-pilot не расходился с DB-employee, если dual-режим
|
||||||
|
# когда-нибудь снова окажется на периметре (откат #2558 / новый
|
||||||
|
# фронт-прокси) — тогда расхождение молча откроет разделы.
|
||||||
|
# НЕ удалять как «мёртвые»: они мёртвые только пока Caddy режет заголовок.
|
||||||
|
#
|
||||||
|
# Страницы + их API вместе: deny гейтит пункт меню (Topbar через /me),
|
||||||
|
# саму страницу (RouteGuard) и серверные ручки (rbac_guard).
|
||||||
|
#
|
||||||
|
# cache-stats закрыт ГЛОБОМ, а не точным путём, намеренно: точный паттерн
|
||||||
|
# обходится трейлинг-слэшем ('…/cache-stats/' не равен '…/cache-stats' →
|
||||||
|
# allowed), и защита повисала бы на Starlette redirect_slashes, а не на
|
||||||
|
# RBAC. '<prefix>/**' → '^<prefix>(?:/.*)?$': сам путь + слэш + подпути,
|
||||||
|
# но НЕ соседи по префиксу ('…/cache-statistics' не матчится).
|
||||||
|
- "/trade-in/sale-share/**"
|
||||||
|
- "/trade-in/cache/**"
|
||||||
|
- "/trade-in/api/v1/buildings/**"
|
||||||
|
- "/trade-in/api/v1/trade-in/cache-stats/**"
|
||||||
analyst:
|
analyst:
|
||||||
# #962 (EPIC18, ТЗ §19): analyst видит ВСЁ (deals, insights, exports,
|
# #962 (EPIC18, ТЗ §19): analyst видит ВСЁ (deals, insights, exports,
|
||||||
# site-finder, analytics, concept) КРОМЕ admin/data-management.
|
# site-finder, analytics, concept) КРОМЕ admin/data-management.
|
||||||
|
|
@ -48,12 +81,28 @@ roles:
|
||||||
# для любого role != "admin" → analyst авто-403 на admin-API без доп. кода.
|
# для любого role != "admin" → analyst авто-403 на admin-API без доп. кода.
|
||||||
# deny ниже драйвит фронтовый RouteGuard (deny_paths из /me) для UI-gating
|
# deny ниже драйвит фронтовый RouteGuard (deny_paths из /me) для UI-gating
|
||||||
# /admin/** страниц.
|
# /admin/** страниц.
|
||||||
|
# Клиентский deny 2026-07-31 (см. pilot выше) распространён на analyst
|
||||||
|
# ЧАСТИЧНО — асимметрия намеренная, не недосмотр:
|
||||||
|
# «Поиск домов» (/trade-in/sale-share + /api/v1/buildings/**) — ЗАКРЫТ.
|
||||||
|
# Решение владельца продукта 2026-07-31: это ТЕСТОВЫЙ продукт, доступ
|
||||||
|
# только у admin. «Только у админа» = включая внутренние роли, поэтому
|
||||||
|
# analyst тоже в deny.
|
||||||
|
# «Кэш» (/trade-in/cache + cache-stats) — ОСТАВЛЕН открытым: это не
|
||||||
|
# продукт, а диагностика состояния кэшей/скраперов, т.е. ровно тот
|
||||||
|
# рабочий инструмент, ради которого роль analyst и заведена
|
||||||
|
# («видит ВСЁ кроме admin-управления», см. выше).
|
||||||
|
# Обе стороны этой асимметрии запиннены тестом
|
||||||
|
# tradein-mvp/backend/tests/test_rbac.py::test_yaml_roles_deliberately_outside_client_deny
|
||||||
|
# — если решение поменяется, тест упадёт и заставит обновить и его, и этот
|
||||||
|
# комментарий, а не тихо разойтись с реальностью.
|
||||||
paths:
|
paths:
|
||||||
- "/**"
|
- "/**"
|
||||||
deny:
|
deny:
|
||||||
- "/admin/**"
|
- "/admin/**"
|
||||||
- "/api/v1/admin/**"
|
- "/api/v1/admin/**"
|
||||||
- "/trade-in/api/v1/admin/**"
|
- "/trade-in/api/v1/admin/**"
|
||||||
|
- "/trade-in/sale-share/**"
|
||||||
|
- "/trade-in/api/v1/buildings/**"
|
||||||
expired:
|
expired:
|
||||||
# Пробный доступ закончился — нет доступа ни к чему. Аккаунт остаётся в
|
# Пробный доступ закончился — нет доступа ни к чему. Аккаунт остаётся в
|
||||||
# caddy/users.caddy.snippet (basic_auth), чтобы дойти до фронта и увидеть
|
# caddy/users.caddy.snippet (basic_auth), чтобы дойти до фронта и увидеть
|
||||||
|
|
@ -70,7 +119,8 @@ users:
|
||||||
admin: admin
|
admin: admin
|
||||||
kopylov: pilot
|
kopylov: pilot
|
||||||
user1: pilot
|
user1: pilot
|
||||||
user2: pilot # «Брусника» — доступ восстановлен 2026-07-13 (снят trial-expire от 2026-07-09)
|
user2: expired # «Брусника» — доступ закрыт 2026-07-30 (решение владельца продукта;
|
||||||
|
# ранее: восстановлен 2026-07-13, trial-expire 2026-07-09)
|
||||||
user3: pilot
|
user3: pilot
|
||||||
user4: pilot
|
user4: pilot
|
||||||
user5: pilot
|
user5: pilot
|
||||||
|
|
|
||||||
269
backend/app/core/auth_db.py
Normal file
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 os
|
||||||
import warnings
|
import warnings
|
||||||
from typing import Annotated
|
from typing import Annotated, Literal
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from pydantic import field_validator, model_validator
|
from pydantic import SecretStr, field_validator, model_validator
|
||||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||||
|
|
||||||
|
# ── Дефолтные части DSN БД `auth` (общий реестр людей, эпик «единый вход») ─────
|
||||||
|
# Вынесены константами, потому что используются ДВАЖДЫ: как дефолт поля и как
|
||||||
|
# запасное значение, если переменная окружения задана ПУСТОЙ строкой
|
||||||
|
# (`AUTH_DB_HOST=` в .env.runtime не должен давать DSN вида `...@:5432/auth`).
|
||||||
|
#
|
||||||
|
# ⚠️ ХОСТ — главная ловушка, и для «Птицы» она ЗЕРКАЛЬНА ловушке «Меры».
|
||||||
|
# У «Меры» (tradein-mvp/backend/app/core/config.py:27) дефолт — `gendesign-postgres`,
|
||||||
|
# потому что внутри ЕЁ стека имя `postgres` резолвится в её собственный контейнер
|
||||||
|
# (tradein-mvp/docker-compose.prod.yml:143 собирает им продуктовый DATABASE_URL
|
||||||
|
# `...@postgres:5432/tradein`), и БД `auth` там нет.
|
||||||
|
#
|
||||||
|
# У «Птицы» ровно наоборот: её стек и есть главный. Сервис `postgres` в корневом
|
||||||
|
# docker-compose.prod.yml:22 (postgis/postgis:16-3.4) — это И ЕСТЬ тот сервер, где
|
||||||
|
# живёт БД `auth`: bootstrap и миграции data/sql/auth/*.sql применяет к нему шаг
|
||||||
|
# «Apply DB migrations» в .forgejo/workflows/deploy.yml:339-375. Соседи по тому же
|
||||||
|
# compose-проекту так к нему и обращаются — `@postgres:5432` (docker-compose.prod.yml:232
|
||||||
|
# и :265, DATABASE_URL сервисов glitchtip).
|
||||||
|
#
|
||||||
|
# Алиас `gendesign-postgres` (docker-compose.prod.yml:43-45) навешен ТОЛЬКО в внешней
|
||||||
|
# сети `shared` (gendesign_shared) и заведён ради ЧУЖИХ стеков — им и пользуется
|
||||||
|
# «Мера». Ставить его дефолтом здесь нельзя: в сети `shared` состоят лишь backend и
|
||||||
|
# worker (`networks: [default, shared]`, строки 152 и 199), а `beat` (строки 201-217)
|
||||||
|
# сетей не объявляет вовсе — он только в `default`, и `gendesign-postgres` из него
|
||||||
|
# просто не разрезолвится. `postgres` резолвится из всех трёх.
|
||||||
|
#
|
||||||
|
# Порт 5432 — ВНУТРИСЕТЕВОЙ порт контейнера. Публикация `127.0.0.1:5432:5432`
|
||||||
|
# (docker-compose.prod.yml:31-32) существует только ради SSH-туннеля с хоста и к
|
||||||
|
# этому пути отношения не имеет.
|
||||||
|
_AUTH_DB_DEFAULT_HOST = "postgres"
|
||||||
|
_AUTH_DB_DEFAULT_PORT = 5432
|
||||||
|
_AUTH_DB_DEFAULT_NAME = "auth"
|
||||||
|
# Роль приложения из data/sql/auth/002_auth_app_role.sql (least privilege: SELECT/
|
||||||
|
# INSERT/UPDATE/DELETE на sessions, SELECT + column-level UPDATE на users).
|
||||||
|
_AUTH_DB_DEFAULT_USER = "auth_app"
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
@ -371,5 +407,201 @@ class Settings(BaseSettings):
|
||||||
# на недоступном сервисе. ENV: DADATA_TIMEOUT_S.
|
# на недоступном сервисе. ENV: DADATA_TIMEOUT_S.
|
||||||
dadata_timeout_s: float = 8.0
|
dadata_timeout_s: float = 8.0
|
||||||
|
|
||||||
|
# ── Эпик «единый вход»: «Птица» ПРИНИМАЕТ сессию общего реестра ────────────
|
||||||
|
# Форма входа во всём продукте одна и живёт у «Меры» (/trade-in/login): она
|
||||||
|
# проверяет пароль и выдаёт сессию в auth.sessions. «Птица» сессии НЕ выдаёт и
|
||||||
|
# НЕ отзывает — только читает куку и резолвит её в человека. Кука host-only на
|
||||||
|
# gendsgn.ru с path="/" (tradein-mvp/backend/app/api/v1/auth.py:173-181),
|
||||||
|
# поэтому браузер шлёт её на оба продукта одного домена.
|
||||||
|
#
|
||||||
|
# Режим — ТРЁХЗНАЧНЫЙ, а не булев флаг, и это сделано ради последнего PR эпика:
|
||||||
|
# legacy (ДЕФОЛТ) — сегодняшнее поведение бит-в-бит: кука не читается вовсе,
|
||||||
|
# личность берётся из X-Authenticated-User (Caddy basic_auth);
|
||||||
|
# engine БД `auth` не создаётся, соединение не открывается,
|
||||||
|
# отсутствие AUTH_* в окружении не роняет старт;
|
||||||
|
# dual — сначала кука общего реестра, при её отсутствии/сбое реестра
|
||||||
|
# деградация на легаси-заголовок (переходный режим: popup
|
||||||
|
# Caddy ещё стоит и прикрывает заголовок от подделки);
|
||||||
|
# db_only — легаси-ветка НЕДОСТИЖИМА: нет валидной сессии → 401, даже
|
||||||
|
# если X-Authenticated-User присутствует.
|
||||||
|
#
|
||||||
|
# Почему именно так, а не `AUTH_SESSION_ENABLED=true/false`. В dual-режиме сбой
|
||||||
|
# реестра (или просто отсутствие куки) уводит запрос на trusted-header. Пока
|
||||||
|
# popup стоит, это безопасно: заголовок на `/api/*` перезаписывает Caddy из
|
||||||
|
# basic_auth (Caddyfile:178-182), клиент подставить его не может. Ровно в тот
|
||||||
|
# момент, когда последний PR эпика снимет `basic_auth` + `header_up`, заголовок
|
||||||
|
# станет полностью клиентским — и та же деградация превратится в ПОЛНЫЙ обход
|
||||||
|
# аутентификации (`curl -H 'X-Authenticated-User: admin'`). Булев флаг оставлял бы
|
||||||
|
# это на память мейнтейнера («не забыть выпилить фолбэк»); режим делает переход
|
||||||
|
# сменой ОДНОГО значения (`AUTH_MODE=db_only`), а недостижимость легаси-ветки в
|
||||||
|
# нём закреплена тестами (tests/test_auth_session_guard.py, секция db_only).
|
||||||
|
# Зеркало «Меры»: tradein-mvp/backend/app/core/config.py:91 (`auth_mode`); там
|
||||||
|
# значений два — легаси-режима у неё уже нет, она на реестре с #2552.
|
||||||
|
#
|
||||||
|
# ⚠️ ДЕФОЛТ `legacy` — ЧАСТЬ КОНТРАКТА PR, А НЕ ЗАГЛУШКА: после мержа прод обязан
|
||||||
|
# работать ровно как сегодня (popup Caddy снимается последним PR эпика).
|
||||||
|
# Читатели режима: `app.main.rbac_guard` (какой источник личности и есть ли
|
||||||
|
# фолбэк), `app.services.auth_session.resolve_session_token` и
|
||||||
|
# `app.core.auth_db.require_auth_db_configured` — через производное свойство
|
||||||
|
# `auth_session_enabled` ниже.
|
||||||
|
#
|
||||||
|
# Включение на проде = одна переменная: AUTH_DB_PASSWORD в backend/.env.runtime
|
||||||
|
# уже есть (её пишет ops и читает .forgejo/workflows/deploy.yml:381-386, чтобы
|
||||||
|
# сделать ALTER ROLE auth_app), остальные части DSN имеют прод-дефолты.
|
||||||
|
# ENV: AUTH_MODE.
|
||||||
|
auth_mode: Literal["legacy", "dual", "db_only"] = "legacy"
|
||||||
|
|
||||||
|
# DSN БД `auth` целиком. Пусто по умолчанию — задавать руками не обязательно:
|
||||||
|
# см. `resolved_auth_database_url` ниже, при пустом значении DSN собирается из
|
||||||
|
# AUTH_DB_PASSWORD + частей. Явное значение, если оно есть, выигрывает всегда
|
||||||
|
# (аварийный обход: другой хост, sslmode, байпас пула). ENV: AUTH_DATABASE_URL.
|
||||||
|
auth_database_url: str = ""
|
||||||
|
# Пароль роли auth_app. Живёт в ОДНОМ месте — этой переменной: требовать вдобавок
|
||||||
|
# целиковый AUTH_DATABASE_URL значило бы держать один секрет в двух местах
|
||||||
|
# (сменили пароль роли, забыли переписать DSN → вход ложится молча и целиком).
|
||||||
|
#
|
||||||
|
# SecretStr, а не str как у соседних секретов файла: `repr(settings)` и
|
||||||
|
# `settings.model_dump()` печатают обычные str-поля ДОСЛОВНО. Сегодня их никто не
|
||||||
|
# рендерит, но появиться такой рендер может тихо — с SecretStr он напечатает
|
||||||
|
# `SecretStr('**********')`. Значение достаётся ровно в одном месте —
|
||||||
|
# `.get_secret_value()` в резолвере ниже. Соседи (openai_api_key, dadata_api_secret,
|
||||||
|
# database_url) остались str — это предсуществующее положение, а не «там безопасно».
|
||||||
|
# ENV: AUTH_DB_PASSWORD.
|
||||||
|
auth_db_password: SecretStr = SecretStr("")
|
||||||
|
# Остальные части — с дефолтами, верными для ЭТОГО стека (см. константы выше и
|
||||||
|
# разбор ловушки хоста). Переопределяются через ENV для локального запуска (напр.
|
||||||
|
# AUTH_DB_HOST=localhost + AUTH_DB_PORT=15432 поверх SSH-туннеля).
|
||||||
|
# ENV: AUTH_DB_HOST, AUTH_DB_PORT, AUTH_DB_NAME, AUTH_DB_USER.
|
||||||
|
auth_db_host: str = _AUTH_DB_DEFAULT_HOST
|
||||||
|
auth_db_port: int = _AUTH_DB_DEFAULT_PORT
|
||||||
|
auth_db_name: str = _AUTH_DB_DEFAULT_NAME
|
||||||
|
auth_db_user: str = _AUTH_DB_DEFAULT_USER
|
||||||
|
|
||||||
|
# Имя cookie сессии. ОБЯЗАНО совпадать с тем, которым пользуется «Мера»
|
||||||
|
# (tradein-mvp/backend/app/core/config.py:84-86) — иначе браузер шлёт куку, а
|
||||||
|
# «Птица» её не узнаёт и молча остаётся без сессии.
|
||||||
|
#
|
||||||
|
# ⚠️ Имя ИСТОРИЧЕСКОЕ: оно родилось в trade-in до того, как реестр стал общим, и
|
||||||
|
# «tradein_» в нём теперь ни о чём не говорит. Переименование разлогинивает ВСЕХ
|
||||||
|
# и СРАЗУ в обоих продуктах (старую куку никто больше не читает), поэтому меняется
|
||||||
|
# только отдельным решением — синхронно в обоих стеках и с обдуманным моментом.
|
||||||
|
# ENV: SESSION_COOKIE_NAME.
|
||||||
|
session_cookie_name: str = "tradein_session"
|
||||||
|
# TTL сессии в часах (720 = 30 дней) — тот же дефолт, что у «Меры»
|
||||||
|
# (tradein-mvp/backend/app/core/config.py:88). «Птица» сессии не выдаёт, поэтому
|
||||||
|
# значение используется ЕДИНСТВЕННЫМ образом: на сколько sliding-refresh отодвигает
|
||||||
|
# expires_at (app/services/auth_session.py). Держать его РАВНЫМ значению «Меры»
|
||||||
|
# обязательно — иначе срок жизни сессии начнёт зависеть от того, в каком продукте
|
||||||
|
# человек кликнул последним. ENV: SESSION_TTL_HOURS.
|
||||||
|
session_ttl_hours: int = 720
|
||||||
|
|
||||||
|
@field_validator("auth_mode", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _blank_auth_mode_means_legacy(cls, value: object) -> object:
|
||||||
|
"""`AUTH_MODE=` (пустая строка) → `legacy`, а не ValidationError на импорте.
|
||||||
|
|
||||||
|
Та же ловушка, что у `AUTH_DB_PORT` ниже: `settings = Settings()` выполняется на
|
||||||
|
уровне модуля, поэтому невалидное значение роняет ИМПОРТ конфига и уводит
|
||||||
|
контейнер в restart-loop. Сценарий тот же — ops копирует блок AUTH_* в
|
||||||
|
.env.runtime и заполняет только пароль. Пустое значение обязано означать
|
||||||
|
«оставили как было», то есть сегодняшнее поведение.
|
||||||
|
|
||||||
|
Регистр и обрамляющие пробелы нормализуются: `AUTH_MODE=DB_ONLY ` — очевидная
|
||||||
|
опечатка со смыслом, а не запрос на падение. Непустой мусор (`AUTH_MODE=off`)
|
||||||
|
по-прежнему валится, и правильно: молча трактовать его как `legacy` значило бы
|
||||||
|
тихо оставить продукт на trusted-header после снятия popup'а.
|
||||||
|
"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
return normalized or "legacy"
|
||||||
|
return value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auth_session_enabled(self) -> bool:
|
||||||
|
"""Читает ли «Птица» сессионную куку общего реестра (то есть режим не `legacy`).
|
||||||
|
|
||||||
|
Производное от `auth_mode`, а не отдельное поле: два независимых переключателя
|
||||||
|
рано или поздно разъезжаются, и получилось бы состояние «куку читаем, но режим
|
||||||
|
легаси» (или наоборот), которого нет ни в одном настоящем сценарии.
|
||||||
|
|
||||||
|
Держит инвариант «`legacy` = ни одного коннекта к реестру»: по этому свойству
|
||||||
|
закорачиваются `app.services.auth_session.resolve_session_token` и
|
||||||
|
`app.core.auth_db.require_auth_db_configured`. Разница между `dual` и `db_only`
|
||||||
|
свойству не видна и не должна быть — она касается только фолбэка на
|
||||||
|
легаси-заголовок и живёт в `app.main.rbac_guard`.
|
||||||
|
"""
|
||||||
|
return self.auth_mode != "legacy"
|
||||||
|
|
||||||
|
@field_validator("auth_db_port", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _blank_auth_db_port_means_default(cls, value: object) -> object:
|
||||||
|
"""`AUTH_DB_PORT=` (пустая строка) → прод-дефолт, а не падение на импорте.
|
||||||
|
|
||||||
|
Симметрия с host/name/user, у которых пустое значение переменной падает
|
||||||
|
обратно на дефолт в резолвере. Для порта того же добиться нельзя: он
|
||||||
|
типизирован `int` и валидируется pydantic'ом ДО всякой нашей логики, а
|
||||||
|
`settings = Settings()` выполняется на уровне модуля — то есть `AUTH_DB_PORT=`
|
||||||
|
в .env.runtime роняло бы ValidationError на импорте конфига и уводило контейнер
|
||||||
|
в restart-loop. Причём В ЛЮБОМ режиме, включая дефолтный (флаг выключен), где к
|
||||||
|
БД `auth` не идёт ни одного обращения — ровно тот инвариант «дефолт не трогаем»,
|
||||||
|
который держит весь этот PR.
|
||||||
|
|
||||||
|
Сценарий не гипотетический: ops копирует блок AUTH_DB_* в .env.runtime и
|
||||||
|
заполняет только пароль — остальные строки остаются пустыми намеренно.
|
||||||
|
|
||||||
|
`mode="before"` — потому что вмешаться надо ДО приведения к int. Непустой мусор
|
||||||
|
(`AUTH_DB_PORT=abc`) по-прежнему валится, и правильно: это опечатка со смыслом,
|
||||||
|
а не «оставил пустым».
|
||||||
|
"""
|
||||||
|
if isinstance(value, str) and not value.strip():
|
||||||
|
return _AUTH_DB_DEFAULT_PORT
|
||||||
|
return value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def resolved_auth_database_url(self) -> str:
|
||||||
|
"""DSN БД `auth` — единственный источник правды для `app.core.auth_db`.
|
||||||
|
|
||||||
|
Приоритет:
|
||||||
|
1. `AUTH_DATABASE_URL`, если задан — выигрывает всегда.
|
||||||
|
2. Иначе, если задан `AUTH_DB_PASSWORD` — DSN собирается из частей.
|
||||||
|
3. Иначе — пустая строка, то есть «не сконфигурировано». Это НЕ ошибка сама
|
||||||
|
по себе: при `AUTH_MODE=legacy` (дефолт) сюда не заходит никто.
|
||||||
|
Ошибку — явную, а не тихий фолбэк — поднимает `app.core.auth_db`, и только
|
||||||
|
когда реестр реально понадобился.
|
||||||
|
|
||||||
|
⚠️ Возвращаемое значение СОДЕРЖИТ ПАРОЛЬ: не логировать, не класть в текст
|
||||||
|
исключений, не отдавать наружу (`/health`, `/docs`, метрики).
|
||||||
|
|
||||||
|
Пароль экранируется `quote(..., safe="")`: спецсимвол (`@`, `:`, `/`, `?`, `#`,
|
||||||
|
`%`) внутри пароля иначе порвал бы URL по своей грамматике — `@` сдвинул бы
|
||||||
|
границу host, `/` открыл бы path. Разбор дал бы либо ошибку, либо, что хуже,
|
||||||
|
МОЛЧА другой хост/базу. По той же причине экранируется имя пользователя.
|
||||||
|
|
||||||
|
А вот имя БД и хост — НЕ экранируются, и это не забывчивость: SQLAlchemy
|
||||||
|
раскодирует обратно только userinfo (user/password), а path отдаёт как есть.
|
||||||
|
Прогони мы имя БД через `quote`, в сервер уехало бы литеральное `c%2Fd` вместо
|
||||||
|
`c/d`. Хосту %-кодирование тоже только мешает — оно поломало бы IPv6-скобки.
|
||||||
|
"""
|
||||||
|
explicit = self.auth_database_url.strip()
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
|
||||||
|
# `.strip()` только для ПРОВЕРКИ «задан ли»: пробельная строка в .env — это
|
||||||
|
# опечатка, а не пароль. В сам DSN идёт значение КАК ЕСТЬ (не стриппится):
|
||||||
|
# ведущий/хвостовой пробел может быть частью настоящего пароля.
|
||||||
|
password = self.auth_db_password.get_secret_value()
|
||||||
|
if not password.strip():
|
||||||
|
return ""
|
||||||
|
|
||||||
|
user = quote(self.auth_db_user.strip() or _AUTH_DB_DEFAULT_USER, safe="")
|
||||||
|
secret = quote(password, safe="")
|
||||||
|
host = self.auth_db_host.strip() or _AUTH_DB_DEFAULT_HOST
|
||||||
|
port = self.auth_db_port
|
||||||
|
name = self.auth_db_name.strip() or _AUTH_DB_DEFAULT_NAME
|
||||||
|
# Схема — ровно та же, что у продуктового database_url (psycopg v3;
|
||||||
|
# `postgresql://` без суффикса увёл бы SQLAlchemy на psycopg2, которого в
|
||||||
|
# зависимостях нет).
|
||||||
|
return f"postgresql+psycopg://{user}:{secret}@{host}:{port}/{name}"
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,14 @@
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse, Response
|
||||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||||
|
|
@ -41,10 +44,12 @@ from app.api.v1 import (
|
||||||
trade_in,
|
trade_in,
|
||||||
users,
|
users,
|
||||||
)
|
)
|
||||||
|
from app.core import auth_db
|
||||||
from app.core.audit_middleware import audit_log_middleware
|
from app.core.audit_middleware import audit_log_middleware
|
||||||
from app.core.auth import get_role
|
from app.core.auth import get_role
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.observability.sentry_scrub import scrub_sensitive_query
|
from app.observability.sentry_scrub import scrub_sensitive_query
|
||||||
|
from app.services.auth_session import resolve_session_token
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -97,6 +102,18 @@ if settings.glitchtip_dsn:
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
# Эпик «единый вход», fail-fast: AUTH_MODE=dual|db_only обязан иметь РАБОЧИЙ
|
||||||
|
# реестр — проверяется не только разбор DSN, но и живое соединение (`SELECT 1`,
|
||||||
|
# app/core/auth_db.py). Не соединились → контейнер НЕ стартует. Режим `legacy`
|
||||||
|
# (ДЕФОЛТ) → no-op: ни проверки DSN, ни создания engine, ни коннекта.
|
||||||
|
#
|
||||||
|
# Почему именно на старте, а не «разберёмся в рантайме»: неверный пароль, опечатка
|
||||||
|
# в хосте, не созданная БД `auth` иначе ловились бы `except`'ом вокруг резолва
|
||||||
|
# сессии в rbac_guard, и сломанная конфигурация выглядела бы как «ни у кого нет
|
||||||
|
# сессии» — СУТКАМИ, потому что продуктовая БД жива, приложение отвечает 200, а
|
||||||
|
# сигнал остаётся только в логах. Дешевле не стартовать: деплой падает сразу и
|
||||||
|
# громко.
|
||||||
|
auth_db.require_auth_db_configured()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -122,10 +139,212 @@ app.middleware("http")(audit_log_middleware)
|
||||||
# 3) /api/v1/admin/* — только role=admin, иначе 403.
|
# 3) /api/v1/admin/* — только role=admin, иначе 403.
|
||||||
# Public paths без auth (/health, /docs, /openapi.json) пропускаем без проверки —
|
# Public paths без auth (/health, /docs, /openapi.json) пропускаем без проверки —
|
||||||
# X-Authenticated-User там просто не приходит из Caddy.
|
# X-Authenticated-User там просто не приходит из Caddy.
|
||||||
|
#
|
||||||
|
# Эпик «единый вход»: к правилу 1 добавляется ПЕРВЫЙ источник личности —
|
||||||
|
# сессионная кука общего реестра (БД `auth`). Выдаёт её единственная форма входа, у
|
||||||
|
# «Меры» (/trade-in/login); «Птица» сессии только читает. Кука host-only на
|
||||||
|
# gendsgn.ru с path="/" → браузер шлёт её и сюда. Порядок: кука → легаси-заголовок.
|
||||||
|
# Дальше — ВСЁ как раньше: роль из auth/roles.yaml, admin-гейт по _ADMIN_API_RE.
|
||||||
|
# Реестр отвечает на вопрос «кто ты», roles.yaml — «что тебе можно»; продуктовые
|
||||||
|
# роли реестра (auth.users.role) в «Птицу» намеренно не протаскиваются.
|
||||||
|
#
|
||||||
|
# ⚠️ AUTH_MODE=legacy ПО УМОЛЧАНИЮ — popup Caddy basic_auth ещё стоит и снимается
|
||||||
|
# ПОСЛЕДНИМ PR эпика. Пока режим legacy, этот файл ведёт себя бит-в-бит как до эпика:
|
||||||
|
# кука не читается, БД `auth` не открывается. `dual` — переходный режим (кука, при её
|
||||||
|
# отсутствии/сбое реестра фолбэк на заголовок), `db_only` — фолбэка нет вовсе.
|
||||||
|
#
|
||||||
|
# ⚠️ ДОЛГ, КОТОРЫЙ ОБЯЗАН БЫТЬ ЗАКРЫТ ДО СНЯТИЯ POPUP'А (не решается этим PR).
|
||||||
|
# Guard проверяет ровно две вещи: есть ли username в auth/roles.yaml (get_role) и
|
||||||
|
# admin-гейт по _ADMIN_API_RE. Списки `paths`/`deny` из roles.yaml на бэкенде НЕ
|
||||||
|
# применяются — это зафиксировано в самом auth/roles.yaml:33-35 («path-level
|
||||||
|
# enforcement делает frontend RouteGuard»). Следствие: в момент включения режима
|
||||||
|
# «Птицу» получает КАЖДЫЙ аккаунт реестра, чей username совпадает с записью в
|
||||||
|
# roles.yaml, — включая роль `expired` (user2: paths: [], deny: "/**"), которую
|
||||||
|
# сегодня останавливает только фронт. Это не регрессия (те же люди сегодня в
|
||||||
|
# caddy/users.caddy.snippet и добираются туда же через basic_auth), но эпик делает
|
||||||
|
# её несущей: (а) до снятия popup'а отзыв доступа имеет ДВА рубильника —
|
||||||
|
# caddy-snippet и access_state в реестре, их надо держать синхронными; (б) после
|
||||||
|
# снятия roles.yaml остаётся ЕДИНСТВЕННЫМ гейтом, и `expired` в нём станет чисто
|
||||||
|
# фронтовой фикцией. Перед включением: сверить `auth.users.username` на проде с
|
||||||
|
# `users:` в roles.yaml и решить — применять `paths`/`deny` на бэкенде или убрать
|
||||||
|
# `expired` как вводящий в заблуждение.
|
||||||
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
||||||
_PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"})
|
_PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"})
|
||||||
|
|
||||||
|
|
||||||
|
def _propagate_authenticated_user(request: Request, username: str) -> None:
|
||||||
|
"""Инжектит `X-Authenticated-User` в ASGI-scope — ПЕРЕЗАПИСЫВАЯ, а не дополняя.
|
||||||
|
|
||||||
|
🔴 Перезапись, а не «поставить, если отсутствует» — это требование безопасности,
|
||||||
|
а не стилистика. В бэкенде «Птицы» ОДИННАДЦАТЬ мест читают этот заголовок НАПРЯМУЮ,
|
||||||
|
мимо guard'а, и решают по нему, кто автор/кому принадлежат данные:
|
||||||
|
• app/core/audit_middleware.py:169 — атрибуция строки аудита;
|
||||||
|
• app/api/v1/me.py:30 — чей scope отдать (роль + фильтры);
|
||||||
|
• app/api/v1/insights.py:74/124/138 — created_by + _require_user (POST/PUT/DELETE);
|
||||||
|
• app/api/v1/own_projects.py:69/115/131 — created_by + _require_user (POST/PUT/DELETE);
|
||||||
|
• app/api/v1/parcels.py:1481 — GET /{cad_num}/forecast;
|
||||||
|
• app/api/v1/parcels.py:1902 — POST /{cad_num}/analyze (created_by рана,
|
||||||
|
parcels.py:4212, и 3-й аргумент forecast_site_finder_report.delay, :4226);
|
||||||
|
• сам rbac_guard ниже — легаси-ветка.
|
||||||
|
Ни одно из них не знает про сессию: для них истина — сырой заголовок. Оставь мы
|
||||||
|
skip-if-present — клиент с ВАЛИДНОЙ кукой прошёл бы guard как он сам, а во все эти
|
||||||
|
места уехал бы его собственный подставленный `X-Authenticated-User: <кто угодно>`
|
||||||
|
(Caddy шлёт этот заголовок на каждый прод-запрос, так что «просто добавить» его
|
||||||
|
было бы некуда). Ровно этот баг ловили у «Меры» — #2552 post-review, CRITICAL.
|
||||||
|
Резолвнутая сессия ОБЯЗАНА быть единственным источником личности.
|
||||||
|
|
||||||
|
Механизм: `request.scope` — один и тот же dict, прокинутый ПО ССЫЛКЕ через весь
|
||||||
|
ASGI-стек (Starlette не копирует scope между слоями). Мутация здесь видна:
|
||||||
|
• всей downstream-цепочке — мы мутируем ДО вызова call_next();
|
||||||
|
• audit-middleware — он ВНУТРЕННИЙ относительно rbac_guard (см. комментарий у
|
||||||
|
app.middleware("http")(audit_log_middleware) выше: LIFO-регистрация даёт
|
||||||
|
порядок rbac_guard → audit → router), т.е. его Request строится уже после
|
||||||
|
мутации. У «Меры» этот слой, наоборот, внешний, и там мутация до него
|
||||||
|
доезжает только потому, что читается ПОСЛЕ call_next.
|
||||||
|
|
||||||
|
Имена заголовков в ASGI — по спеке всегда lowercase bytes, и uvicorn/TestClient
|
||||||
|
её соблюдают. Фильтр всё равно нормализует ключ сам (`k.lower()`), а не полагается
|
||||||
|
на спеку: попади в scope запись `b"X-Authenticated-User"` (другой ASGI-сервер,
|
||||||
|
самодельный слой, тест-харнесс) — точное сравнение оставило бы её в списке рядом с
|
||||||
|
нашей. Читатели при этом видели бы правильное значение (`Headers.get` лоуэркейсит
|
||||||
|
искомый ключ, но не хранимый, так что смешанный регистр не матчится никогда), то
|
||||||
|
есть дыры нет — но состояние «две записи с одним именем» в scope не должно
|
||||||
|
существовать: оно ложное по построению и ломает любой обход списка глазами.
|
||||||
|
`errors="replace"` в encode: латиницей логины реестра не ограничены, а падать
|
||||||
|
UnicodeEncodeError в auth-пути нельзя.
|
||||||
|
|
||||||
|
NB: `request.headers` САМОГО этого Request уже закеширован (мы читали cookies) и
|
||||||
|
останется старым. Это не мешает: в session-ветке guard больше не читает заголовок,
|
||||||
|
а нижележащие слои строят свой Request поверх обновлённого scope.
|
||||||
|
"""
|
||||||
|
request.scope["headers"] = [
|
||||||
|
(k, v) for k, v in request.scope.get("headers", []) if k.lower() != b"x-authenticated-user"
|
||||||
|
] + [(b"x-authenticated-user", username.encode("latin-1", "replace"))]
|
||||||
|
|
||||||
|
|
||||||
|
# Троттлинг алерта «реестр не отвечает». Резолв сессии идёт на КАЖДОМ non-public
|
||||||
|
# запросе с кукой, а `logger.exception` уровня ERROR уезжает событием в GlitchTip
|
||||||
|
# (LoggingIntegration event_level=ERROR, см. sentry_sdk.init выше) — то есть лежащий
|
||||||
|
# реестр давал бы поток событий, пропорциональный трафику: квота/rate-limit выгорают
|
||||||
|
# за минуты, и настоящие ошибки этого же периода теряются. Полный traceback печатаем
|
||||||
|
# не чаще раза в минуту (с числом подавленных за окно), остальное — WARNING без
|
||||||
|
# exc_info, чтобы факт продолжающегося сбоя всё равно был виден в логах.
|
||||||
|
# Лок нужен по-настоящему: функция исполняется в threadpool'е, то есть параллельно.
|
||||||
|
_REGISTRY_FAILURE_ALERT_INTERVAL_S = 60.0
|
||||||
|
_REGISTRY_FAILURE_LOCK = threading.Lock()
|
||||||
|
_registry_failure_last_alert = 0.0
|
||||||
|
_registry_failure_suppressed = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_registry_failure_throttle() -> None:
|
||||||
|
"""Сбрасывает окно троттлинга. Для тестов: состояние модульное и живёт между ними."""
|
||||||
|
global _registry_failure_last_alert, _registry_failure_suppressed
|
||||||
|
with _REGISTRY_FAILURE_LOCK:
|
||||||
|
_registry_failure_last_alert = 0.0
|
||||||
|
_registry_failure_suppressed = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _log_registry_failure(path: str) -> None:
|
||||||
|
"""Логирует сбой резолва: раз в окно — ERROR с traceback, иначе WARNING.
|
||||||
|
|
||||||
|
Зовётся ТОЛЬКО из `except`-блока: `logger.exception` берёт traceback из текущего
|
||||||
|
sys.exc_info().
|
||||||
|
"""
|
||||||
|
global _registry_failure_last_alert, _registry_failure_suppressed
|
||||||
|
now = time.monotonic()
|
||||||
|
with _REGISTRY_FAILURE_LOCK:
|
||||||
|
alert = (now - _registry_failure_last_alert) >= _REGISTRY_FAILURE_ALERT_INTERVAL_S
|
||||||
|
if alert:
|
||||||
|
suppressed = _registry_failure_suppressed
|
||||||
|
_registry_failure_last_alert = now
|
||||||
|
_registry_failure_suppressed = 0
|
||||||
|
else:
|
||||||
|
suppressed = 0
|
||||||
|
_registry_failure_suppressed += 1
|
||||||
|
if alert:
|
||||||
|
logger.exception(
|
||||||
|
"RBAC: резолв сессии не удался на %s — эти запросы обслуживаются по "
|
||||||
|
"легаси-пути (Caddy basic_auth + X-Authenticated-User); подавлено таких же "
|
||||||
|
"за предыдущее окно: %d",
|
||||||
|
path,
|
||||||
|
suppressed,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"RBAC: резолв сессии не удался на %s (traceback подавлен троттлингом, "
|
||||||
|
"следующий — не раньше чем через %.0f с)",
|
||||||
|
path,
|
||||||
|
_REGISTRY_FAILURE_ALERT_INTERVAL_S,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_session_username(token: str | None, path: str) -> str | None:
|
||||||
|
"""Логин из сессионной куки, либо None, если личность по куке не установлена.
|
||||||
|
|
||||||
|
🔴 СИНХРОННАЯ и вызывается ТОЛЬКО через `run_in_threadpool` (см. rbac_guard):
|
||||||
|
внутри — psycopg-I/O (checkout из пула + SELECT, раз в 5 минут ещё UPDATE и
|
||||||
|
commit). Позови её напрямую из корутины guard'а — и весь API «Птицы»
|
||||||
|
сериализуется за один round-trip к БД `auth` на каждый запрос, а недоступный
|
||||||
|
реестр (или исчерпанный пул) заморозит event loop целиком, включая /health. Ровно
|
||||||
|
этот инцидент уже был на соседнем middleware — #1202, см. комментарий в
|
||||||
|
app/core/audit_middleware.py:175-181, там он и починен через `run_in_threadpool`.
|
||||||
|
Токен принимается ГОТОВЫМ (а не `Request`) именно поэтому: разбор Cookie-заголовка
|
||||||
|
дёшев и делается на loop'е, в поток уезжает только строка.
|
||||||
|
|
||||||
|
None означает ровно одно — «личность по куке не установлена», и вызывающий обязан
|
||||||
|
трактовать это одинаково во всех трёх случаях: куки нет, кука невалидна (нет
|
||||||
|
строки / истекла / access_state не active), резолв УПАЛ.
|
||||||
|
|
||||||
|
Поведение при сбое БД `auth` (осознанный выбор, а не «поймали и забыли»): логируем
|
||||||
|
ERROR с traceback — он уезжает событием в GlitchTip (LoggingIntegration
|
||||||
|
event_level=ERROR, см. sentry_sdk.init выше), т.е. это алерт, а не строчка, которую
|
||||||
|
никто не увидит (частота ограничена окном, `_log_registry_failure`), — и в режиме
|
||||||
|
`dual` деградируем к легаси-ветке, то есть к сегодняшнему поведению: Caddy
|
||||||
|
basic_auth + X-Authenticated-User. В режиме `db_only` деградации нет: guard
|
||||||
|
отвечает 401.
|
||||||
|
|
||||||
|
Почему НЕ 503/500. Пока идёт переходный период, popup basic_auth стоит перед
|
||||||
|
бэкендом, и легаси-ветка защищена ровно тем же, чем защищён весь продукт сегодня, —
|
||||||
|
множество людей, способных вообще достучаться, не расширяется. Отдавать же 503
|
||||||
|
значит класть «Птицу» целиком из-за проблемы, которую basic_auth уже покрывает
|
||||||
|
(отозванный пароль роли auth_app, пересозданная БД `auth`, исчерпанный пул её
|
||||||
|
engine — всё это не мешает продуктовой БД gendesign работать).
|
||||||
|
|
||||||
|
Почему это не «тихий фолбэк на легаси». Опасный сценарий — не «реестр упал», а
|
||||||
|
«реестр не сконфигурирован»: тогда права раздавались бы из roles.yaml в обход
|
||||||
|
реестра (включая аккаунты с access_state disabled/trial_expired) бессрочно и молча.
|
||||||
|
Этот сценарий сюда НЕ доходит: конфигурацию проверяет lifespan, причём НЕ на глазок —
|
||||||
|
`require_auth_db_configured` открывает соединение и делает `SELECT 1`, так что мимо
|
||||||
|
него не проходят ни пустой/битый DSN, ни неверный пароль, ни опечатка в хосте, ни
|
||||||
|
отозванная роль (app/core/auth_db.py). Здесь остаётся только второй рубеж — реестр,
|
||||||
|
отвалившийся ПОСЛЕ успешного старта.
|
||||||
|
|
||||||
|
⚠️ Отдельно про отзыв доступа: пароли Caddy basic_auth (caddy/users.caddy.snippet)
|
||||||
|
и `auth.users.access_state` — РАЗНЫЕ списки. Человек, которому в реестре поставили
|
||||||
|
disabled/trial_expired, свой basic_auth-пароль не теряет, поэтому на время
|
||||||
|
недоступности реестра деградация возвращает его в строй. То есть отзыв тут не
|
||||||
|
«строже сегодняшнего», а откатывается к состоянию ДО отзыва — при включении режима
|
||||||
|
caddy-snippet надо прополоть под список активных аккаунтов реестра.
|
||||||
|
|
||||||
|
⚠️ Когда последний PR эпика снимет popup, эта деградация обязана уйти вместе с ним:
|
||||||
|
без basic_auth впереди фолбэк на легаси-заголовок превращается в дыру — заголовок
|
||||||
|
станет полностью клиентским. Механика перехода уже готова: `AUTH_MODE=db_only`
|
||||||
|
(см. app/core/config.py), в нём легаси-ветка недостижима и этот возврат None
|
||||||
|
означает 401, а не «попробуем заголовок».
|
||||||
|
"""
|
||||||
|
if not token:
|
||||||
|
# Нет куки — ни одного обращения к БД `auth`. Это весь сегодняшний трафик.
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
session_user = resolve_session_token(token)
|
||||||
|
except Exception:
|
||||||
|
_log_registry_failure(path)
|
||||||
|
return None
|
||||||
|
if session_user is None:
|
||||||
|
return None
|
||||||
|
return session_user.username
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def rbac_guard(
|
async def rbac_guard(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -134,6 +353,17 @@ async def rbac_guard(
|
||||||
# Test-mode bypass: pytest бьёт по app мимо Caddy → нет X-Authenticated-User.
|
# Test-mode bypass: pytest бьёт по app мимо Caddy → нет X-Authenticated-User.
|
||||||
# СТРОГО gated на settings.testing (default False) — прод RBAC не затронут.
|
# СТРОГО gated на settings.testing (default False) — прод RBAC не затронут.
|
||||||
# RBAC-логика покрыта отдельно в tests/test_rbac.py (своя копия middleware).
|
# RBAC-логика покрыта отдельно в tests/test_rbac.py (своя копия middleware).
|
||||||
|
#
|
||||||
|
# ⚠️ Он ОТКЛЮЧАЕТ ВЕСЬ guard целиком, включая session-ветку ниже, — и это сказано
|
||||||
|
# здесь явно, чтобы не выглядело недосмотром. Следствие для тестов: сессионный путь
|
||||||
|
# НЕЛЬЗЯ проверять запросом к настоящему `app` через TestClient (conftest ставит
|
||||||
|
# settings.testing=True глобально, guard просто не отработает, тест «прошёл бы» ни о
|
||||||
|
# чём). Он и проверяется иначе: tests/test_auth_session_guard.py зовёт ЭТУ САМУЮ
|
||||||
|
# функцию напрямую, сняв settings.testing через monkeypatch, — то есть прод-код, а
|
||||||
|
# не копию. Копия guard'а в tests/test_rbac.py про куку намеренно НЕ знает и
|
||||||
|
# покрывает только режим legacy (там об этом написано). Сдвигать session-ветку ВЫШЕ
|
||||||
|
# bypass'а нельзя: получился бы полуработающий guard (личность резолвится, а 401/403
|
||||||
|
# не применяются) — состояние, которого нет ни в одном настоящем режиме.
|
||||||
if settings.testing:
|
if settings.testing:
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
@ -141,8 +371,42 @@ async def rbac_guard(
|
||||||
if path in _PUBLIC_PATHS:
|
if path in _PUBLIC_PATHS:
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
username = request.headers.get("X-Authenticated-User")
|
# Внешний `if` по режиму — не дубль проверки внутри resolve_session_token(), а
|
||||||
if not username:
|
# гарантия инварианта «legacy = поведение не меняется ни на байт»: в нём не
|
||||||
|
# трогается даже request.cookies (разбор Cookie-заголовка).
|
||||||
|
token = (
|
||||||
|
request.cookies.get(settings.session_cookie_name) if settings.auth_session_enabled else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# 🔴 Резолв — В THREADPOOL. Внутри синхронный psycopg-I/O, а мы в корутине: прямой
|
||||||
|
# вызов блокировал бы event loop на каждом запросе с кукой (инцидент #1202, тот же
|
||||||
|
# класс, что чинили в app/core/audit_middleware.py:175-183). `if token` перед
|
||||||
|
# хопом — не микрооптимизация: без куки резолвить нечего, и весь сегодняшний
|
||||||
|
# трафик не платит ни за поток, ни за коннект.
|
||||||
|
session_username = (
|
||||||
|
await run_in_threadpool(_resolve_session_username, token, path) if token else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if session_username is not None:
|
||||||
|
username = session_username
|
||||||
|
# 🔴 До call_next и до всего остального: личность из сессии обязана вытеснить
|
||||||
|
# клиентский заголовок для одиннадцати прямых читателей (см. функцию).
|
||||||
|
_propagate_authenticated_user(request, username)
|
||||||
|
elif settings.auth_mode == "db_only":
|
||||||
|
# Легаси-ветка ОТКЛЮЧЕНА: нет валидной сессии → отказ, даже если
|
||||||
|
# X-Authenticated-User присутствует. Это конечное состояние эпика — режим
|
||||||
|
# включается тем же PR, который снимает `basic_auth` + `header_up` из Caddy и
|
||||||
|
# тем самым делает заголовок полностью клиентским. Отдельный текст ответа:
|
||||||
|
# «no authenticated user» ниже говорит про basic_auth, которого в этот момент
|
||||||
|
# уже нет.
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "valid session required"},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# ---- легаси trusted-header путь — БИТ-В-БИТ как до эпика ----
|
||||||
|
header_user = request.headers.get("X-Authenticated-User")
|
||||||
|
if not header_user:
|
||||||
# Любой non-public path без auth-header → 401. Локальный curl мимо Caddy
|
# Любой non-public path без auth-header → 401. Локальный curl мимо Caddy
|
||||||
# или прокси-фронт без header_up. 401 точнее чем 403 — "сначала
|
# или прокси-фронт без header_up. 401 точнее чем 403 — "сначала
|
||||||
# аутентифицируйся".
|
# аутентифицируйся".
|
||||||
|
|
@ -150,12 +414,23 @@ async def rbac_guard(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
content={"detail": "no authenticated user (Caddy basic_auth required)"},
|
content={"detail": "no authenticated user (Caddy basic_auth required)"},
|
||||||
)
|
)
|
||||||
|
username = header_user
|
||||||
|
|
||||||
try:
|
try:
|
||||||
role = get_role(username)
|
role = get_role(username)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
# Юзер в Caddy basic_auth, но не в roles.yaml → 403 на ВСЁ.
|
# Юзер в Caddy basic_auth, но не в roles.yaml → 403 на ВСЁ.
|
||||||
# Decided 2026-05-25: «человек без ролей вообще ничего не видит».
|
# Decided 2026-05-25: «человек без ролей вообще ничего не видит».
|
||||||
|
if session_username is not None:
|
||||||
|
# Тот же отказ, но отдельным сообщением: «есть в реестре, нет в roles.yaml» —
|
||||||
|
# это рассинхрон двух списков (типовой при заведении нового аккаунта), а не
|
||||||
|
# подделка заголовка, и чинится он в другом месте.
|
||||||
|
logger.warning(
|
||||||
|
"RBAC: сессия резолвлена в %r, но юзера нет в auth/roles.yaml — отказ на %s",
|
||||||
|
username,
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
else:
|
||||||
logger.warning("RBAC: unknown user %r tried %s", username, path)
|
logger.warning("RBAC: unknown user %r tried %s", username, path)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
|
|
|
||||||
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, чтобы не подтягивать тяжёлые
|
# Test app — копия rbac_guard из app/main.py, чтобы не подтягивать тяжёлые
|
||||||
# импорты (weasyprint, celery worker, ...). Если поведение middleware меняется
|
# импорты (weasyprint, celery worker, ...). Если поведение middleware меняется
|
||||||
# в проде — синхронизируй здесь.
|
# в проде — синхронизируй здесь.
|
||||||
|
#
|
||||||
|
# NB: копия воспроизводит ЛЕГАСИ-ВЕТКУ принятия решения (trusted-header) и намеренно
|
||||||
|
# не знает про сессионную куку общего реестра, добавленную эпиком «единый вход»:
|
||||||
|
# при AUTH_MODE=legacy (дефолт) прод-guard принимает решение ровно так же, и тесты
|
||||||
|
# ниже проверяют именно тот режим. Режимы dual/db_only (кука → заголовок, приоритет
|
||||||
|
# куки над подделанным заголовком, 401/403, публичные пути) покрыты в
|
||||||
|
# tests/test_auth_session_guard.py — там вызывается НАСТОЯЩИЙ app.main.rbac_guard,
|
||||||
|
# без копии.
|
||||||
|
#
|
||||||
|
# «Ровно так же» — про ЛОГИКУ, не про списки: `_PUBLIC_PATHS` ниже держится
|
||||||
|
# синхронным с прод-версией руками (расхождение уже случалось — в копии не было
|
||||||
|
# /api/v1/ping), и никакой механики, которая бы это гарантировала, нет. Прод-список
|
||||||
|
# параметризован в test_auth_session_guard.py, поэтому его расширение хотя бы там
|
||||||
|
# проверяется автоматически.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
||||||
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
# Синхронно с app.main._PUBLIC_PATHS (там же и /api/v1/ping — он был потерян здесь).
|
||||||
|
_PUBLIC_PATHS = frozenset({"/health", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"})
|
||||||
|
|
||||||
|
|
||||||
def _build_test_app() -> FastAPI:
|
def _build_test_app() -> FastAPI:
|
||||||
|
|
@ -110,11 +125,24 @@ def client() -> TestClient:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# Пилотные логины user1..user10 в auth/roles.yaml. user2 — «Брусника»: доступ
|
||||||
|
# закрыт владельцем продукта 2026-07-30, роль переведена pilot → expired. Это
|
||||||
|
# ЕДИНСТВЕННОЕ отклонение от «все userN = pilot», и оно ожидаемое; хардкод
|
||||||
|
# именно здесь, отдельной константой, а не магическим `if` в цикле.
|
||||||
|
_EXPIRED_PILOT_LOGINS = {"user2": "«Брусника», доступ закрыт 2026-07-30"}
|
||||||
|
|
||||||
|
|
||||||
def test_get_role_known_users() -> None:
|
def test_get_role_known_users() -> None:
|
||||||
|
"""Ловит рассинхрон auth/roles.yaml с ожиданиями теста: roles.yaml лежит вне
|
||||||
|
`backend/**`, поэтому правка ролей не попадает в paths-filter CI и такой
|
||||||
|
рассинхрон CI молча пропускает (так и случилось с user2 → expired)."""
|
||||||
assert auth_mod.get_role("admin") == "admin"
|
assert auth_mod.get_role("admin") == "admin"
|
||||||
assert auth_mod.get_role("kopylov") == "pilot"
|
assert auth_mod.get_role("kopylov") == "pilot"
|
||||||
for n in range(1, 11):
|
for n in range(1, 11):
|
||||||
assert auth_mod.get_role(f"user{n}") == "pilot"
|
login = f"user{n}"
|
||||||
|
expected = "expired" if login in _EXPIRED_PILOT_LOGINS else "pilot"
|
||||||
|
why = _EXPIRED_PILOT_LOGINS.get(login, "обычный пилотный логин")
|
||||||
|
assert auth_mod.get_role(login) == expected, f"{login}: ожидали {expected} — {why}"
|
||||||
|
|
||||||
|
|
||||||
def test_get_role_unknown_user_raises() -> None:
|
def test_get_role_unknown_user_raises() -> None:
|
||||||
|
|
|
||||||
|
|
@ -220,4 +220,8 @@ COMMENT ON MATERIALIZED VIEW mv_quarter_price_index IS
|
||||||
'Consumer: estimator service (#647-3) reads O(1) by quarter_cad_number. '
|
'Consumer: estimator service (#647-3) reads O(1) by quarter_cad_number. '
|
||||||
'Issue: #760.';
|
'Issue: #760.';
|
||||||
|
|
||||||
|
-- C3 (#2583): DROP CASCADE выше уносит грант из 99b — без ре-гранта tradein FDW
|
||||||
|
-- (quarter_price_index, роль tradein_fdw_reader) ловит permission denied.
|
||||||
|
GRANT SELECT ON public.mv_quarter_price_index TO tradein_fdw_reader;
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|
|
||||||
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 |
|
| **Forgejo repo variables** (`vars.*`) | non-sensitive toggles (`LLM_ENABLED`, `OWN_DEVELOPER_IDS`) | ❌ нет | Forgejo Actions runner |
|
||||||
| **GitHub repo secrets** (зеркало для `.github/workflows/`) | deploy SSH key (obsidian-стек) | ❌ нет | GitHub Actions (только obsidian deploy) |
|
| **GitHub repo secrets** (зеркало для `.github/workflows/`) | deploy SSH key (obsidian-стек) | ❌ нет | GitHub Actions (только obsidian deploy) |
|
||||||
| **`/opt/gendesign/.env`** (VPS, root-only, chmod 600) | DB creds, GlitchTip infra-secrets, FDW/reader passwords, прокси, COMPOSE_PROFILES | ❌ `.gitignore` | docker compose (main + obsidian + tradein стеки) |
|
| **`/opt/gendesign/.env`** (VPS, root-only, chmod 600) | DB creds, GlitchTip infra-secrets, FDW/reader passwords, прокси, COMPOSE_PROFILES | ❌ `.gitignore` | docker compose (main + obsidian + tradein стеки) |
|
||||||
| **`/opt/gendesign/backend/.env.runtime`** (VPS, chmod 600) | runtime overlay: `SENTRY_RELEASE`, `GLITCHTIP_DSN`, `OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `OWN_DEVELOPER_IDS`, `GENDESIGN_FDW_PASSWORD`, `COUCHDB_*` | ❌ `.gitignore` | backend/worker/beat/couchdb |
|
| **`/opt/gendesign/backend/.env.runtime`** (VPS, chmod 600) | runtime overlay: `SENTRY_RELEASE`, `GLITCHTIP_DSN`, `OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `OWN_DEVELOPER_IDS`, `GENDESIGN_FDW_PASSWORD`, `AUTH_DB_PASSWORD`, `COUCHDB_*` | ❌ `.gitignore` | backend/worker/beat/couchdb |
|
||||||
| **`/opt/gendesign/tradein-mvp/backend/.env.runtime`** (VPS, chmod 600) | tradein DB creds, Yandex/DaData ключи, прокси-URL, Cian-логин, reader password | ❌ `.gitignore` | tradein стек |
|
| **`/opt/gendesign/tradein-mvp/backend/.env.runtime`** (VPS, chmod 600) | tradein DB creds, Yandex/DaData ключи, прокси-URL, Cian-логин, reader password | ❌ `.gitignore` | tradein стек |
|
||||||
| **`caddy/users.caddy.snippet`** (in git) | bcrypt-хеши basic_auth пилотных юзеров | ✅ да (хеши, не plaintext) | Caddy |
|
| **`caddy/users.caddy.snippet`** (in git) | bcrypt-хеши basic_auth пилотных юзеров | ✅ да (хеши, не plaintext) | Caddy |
|
||||||
| **Obsidian vault `meta/00_credentials.md`** | реестр **значений** всех секретов + audit-log ротаций | ❌ (вне репо) | Anton |
|
| **Obsidian vault `meta/00_credentials.md`** | реестр **значений** всех секретов + audit-log ротаций | ❌ (вне репо) | Anton |
|
||||||
|
|
@ -62,6 +62,7 @@
|
||||||
| `POSTGRES_PASSWORD` | `.env` | Пароль роли `gendesign` (PostGIS 16) | **E** (DB password) |
|
| `POSTGRES_PASSWORD` | `.env` | Пароль роли `gendesign` (PostGIS 16) | **E** (DB password) |
|
||||||
| `POSTGRES_USER` / `POSTGRES_DB` | `.env` | Имя роли / БД (не секрет, но в `.env`) | **E** |
|
| `POSTGRES_USER` / `POSTGRES_DB` | `.env` | Имя роли / БД (не секрет, но в `.env`) | **E** |
|
||||||
| `GENDESIGN_FDW_PASSWORD` | `backend/.env.runtime` | Пароль роли `tradein_fdw_reader` (FDW из main → tradein). Применяется через `ops/db-bootstrap/set_tradein_fdw_password.sql` | **E** |
|
| `GENDESIGN_FDW_PASSWORD` | `backend/.env.runtime` | Пароль роли `tradein_fdw_reader` (FDW из main → tradein). Применяется через `ops/db-bootstrap/set_tradein_fdw_password.sql` | **E** |
|
||||||
|
| `AUTH_DB_PASSWORD` | `backend/.env.runtime` | Пароль роли `auth_app` — БД `auth` на gendesign-postgres (единое хранилище доступов «Меры» и «Птицы»). Применяется через `ops/db-bootstrap/set_auth_app_password.sql` на деплое. Переменная задаётся на VPS вручную; пока не задана — шаг пропускается с warning'ом | **E** |
|
||||||
| `COUCHDB_PASSWORD` / `COUCHDB_USER` | `backend/.env.runtime` | CouchDB (Obsidian LiveSync, `obsidian.gendsgn.ru`) | **E** |
|
| `COUCHDB_PASSWORD` / `COUCHDB_USER` | `backend/.env.runtime` | CouchDB (Obsidian LiveSync, `obsidian.gendsgn.ru`) | **E** |
|
||||||
| `GLITCHTIP_DSN` | `backend/.env.runtime` | Backend GlitchTip DSN (перезаписывается deploy из `GLITCHTIP_BACKEND_DSN`) | **C** |
|
| `GLITCHTIP_DSN` | `backend/.env.runtime` | Backend GlitchTip DSN (перезаписывается deploy из `GLITCHTIP_BACKEND_DSN`) | **C** |
|
||||||
| `GLITCHTIP_DB_PASS` | `.env` | Пароль БД GlitchTip-стека | **E** |
|
| `GLITCHTIP_DB_PASS` | `.env` | Пароль БД GlitchTip-стека | **E** |
|
||||||
|
|
@ -76,7 +77,6 @@
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `TRADEIN_POSTGRES_PASSWORD` / `TRADEIN_POSTGRES_USER` | Пароль/юзер БД `tradein` | **E** |
|
| `TRADEIN_POSTGRES_PASSWORD` / `TRADEIN_POSTGRES_USER` | Пароль/юзер БД `tradein` | **E** |
|
||||||
| `TRADEIN_READER_PASSWORD` | Пароль роли `gendesign_reader` (ETL #976, `ops/db-bootstrap/set_gendesign_reader_password.sql`) | **E** |
|
| `TRADEIN_READER_PASSWORD` | Пароль роли `gendesign_reader` (ETL #976, `ops/db-bootstrap/set_gendesign_reader_password.sql`) | **E** |
|
||||||
| `YANDEX_GEOCODER_API_KEY` | Yandex Geocoder (25k req/day) | **D** |
|
|
||||||
| `DADATA_API_TOKEN` / `DADATA_API_SECRET` | DaData `/clean/address` enrichment | **D** |
|
| `DADATA_API_TOKEN` / `DADATA_API_SECRET` | DaData `/clean/address` enrichment | **D** |
|
||||||
| `SCRAPER_PROXY_URL` (+ legacy `AVITO_PROXY_URL`, `CIAN_PROXY_URL`, `YANDEX_PROXY_URL` и их `*_ROTATE_URL`) | Мобильный прокси для скраперов (содержит user:pass в URL) | **G** (proxy creds) |
|
| `SCRAPER_PROXY_URL` (+ legacy `AVITO_PROXY_URL`, `CIAN_PROXY_URL`, `YANDEX_PROXY_URL` и их `*_ROTATE_URL`) | Мобильный прокси для скраперов (содержит user:pass в URL) | **G** (proxy creds) |
|
||||||
| `CIAN_LOGIN_EMAIL` / `CIAN_LOGIN_PASSWORD` | Cian browser auto-login (#639, Variant B) | **D** |
|
| `CIAN_LOGIN_EMAIL` / `CIAN_LOGIN_PASSWORD` | Cian browser auto-login (#639, Variant B) | **D** |
|
||||||
|
|
@ -146,14 +146,14 @@ bcrypt-хеши — односторонние, не plaintext-секреты,
|
||||||
3. Frontend: обновить `GLITCHTIP_FRONTEND_DSN` (build-arg `NEXT_PUBLIC_GLITCHTIP_DSN`) → требует **rebuild frontend образа** (запекается на build-time) → `workflow_dispatch` или push в `frontend/**`.
|
3. Frontend: обновить `GLITCHTIP_FRONTEND_DSN` (build-arg `NEXT_PUBLIC_GLITCHTIP_DSN`) → требует **rebuild frontend образа** (запекается на build-time) → `workflow_dispatch` или push в `frontend/**`.
|
||||||
4. Vault entry.
|
4. Vault entry.
|
||||||
|
|
||||||
### Класс D — 3rd-party API keys (`OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `YANDEX_GEOCODER_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*`)
|
### Класс D — 3rd-party API keys (`OBJECTIVE_API_KEY`, `OPENAI_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*`)
|
||||||
|
|
||||||
**Downtime:** нет (фичи gracefully degrade при пустом ключе — см. config-комментарии).
|
**Downtime:** нет (фичи gracefully degrade при пустом ключе — см. config-комментарии).
|
||||||
|
|
||||||
1. Перевыпустить/ротировать ключ в кабинете провайдера (Объектив / OpenAI / Yandex Cloud / DaData / Cian-аккаунт).
|
1. Перевыпустить/ротировать ключ в кабинете провайдера (Объектив / OpenAI / DaData / Cian-аккаунт).
|
||||||
2. Где живёт:
|
2. Где живёт:
|
||||||
- `OBJECTIVE_API_KEY`, `OPENAI_API_KEY` — Forgejo secret → deploy пишет в main `.env.runtime`.
|
- `OBJECTIVE_API_KEY`, `OPENAI_API_KEY` — Forgejo secret → deploy пишет в main `.env.runtime`.
|
||||||
- `YANDEX_GEOCODER_API_KEY`, `DADATA_*`, `CIAN_LOGIN_*` — tradein `.env.runtime` (правится **на VPS вручную**, не из CI).
|
- `DADATA_*`, `CIAN_LOGIN_*` — tradein `.env.runtime` (правится **на VPS вручную**, не из CI).
|
||||||
3. Обновить значение `sed`-ом (НЕ перезапись файла) и `up -d --force-recreate --no-deps backend worker beat` (main) / `... backend scraper` (tradein).
|
3. Обновить значение `sed`-ом (НЕ перезапись файла) и `up -d --force-recreate --no-deps backend worker beat` (main) / `... backend scraper` (tradein).
|
||||||
4. Vault entry.
|
4. Vault entry.
|
||||||
|
|
||||||
|
|
|
||||||
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"]
|
CORS_ORIGINS=["http://localhost:8080","http://localhost:3000"]
|
||||||
ENVIRONMENT=dev
|
ENVIRONMENT=dev
|
||||||
|
|
||||||
# Yandex Geocoder API key (25k req/day free tier).
|
|
||||||
# Required for backfill scripts (scripts/backfill_house_coords.py + audit_address_mismatch.py).
|
|
||||||
# Empty = Nominatim fallback для backend геокодинга; backfill scripts требуют этот ключ
|
|
||||||
# и упадут с SystemExit без него.
|
|
||||||
YANDEX_GEOCODER_API_KEY=
|
|
||||||
|
|
||||||
# DaData /clean/address — обогащение target адреса в estimate flow (PR Q1).
|
# DaData /clean/address — обогащение target адреса в estimate flow (PR Q1).
|
||||||
# Возвращает canonical-форму, kadastr_num, ФИАС, координаты, ближайшее метро.
|
# Возвращает canonical-форму, kadastr_num, ФИАС, координаты, ближайшее метро.
|
||||||
# Demo tier: 100 req/день — хватит для тестов и low-traffic prod.
|
# Demo tier: 100 req/день — хватит для тестов и low-traffic prod.
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,7 @@ import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment
|
||||||
shell-скриптом deploy через `source .env.runtime` перед `compose up`.
|
shell-скриптом deploy через `source .env.runtime` перед `compose up`.
|
||||||
2. `/opt/gendesign/tradein-mvp/backend/.env.runtime` — переменные внутри
|
2. `/opt/gendesign/tradein-mvp/backend/.env.runtime` — переменные внутри
|
||||||
контейнера `tradein-backend` (читаются через `env_file:` в compose). Сюда
|
контейнера `tradein-backend` (читаются через `env_file:` в compose). Сюда
|
||||||
попадают `YANDEX_GEOCODER_API_KEY`, `COOKIE_ENCRYPTION_KEY` —
|
попадают `COOKIE_ENCRYPTION_KEY` и остальные application-секреты внутри
|
||||||
всё, что нужно scripts/backfill_house_coords.py и application code внутри
|
|
||||||
контейнера.
|
контейнера.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -66,7 +65,6 @@ import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment
|
||||||
TRADEIN_POSTGRES_USER=tradein
|
TRADEIN_POSTGRES_USER=tradein
|
||||||
TRADEIN_POSTGRES_PASSWORD=<сгенерировать openssl rand -hex 32>
|
TRADEIN_POSTGRES_PASSWORD=<сгенерировать openssl rand -hex 32>
|
||||||
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
|
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
|
||||||
YANDEX_GEOCODER_API_KEY= # пусто пока, Nominatim fallback работает
|
|
||||||
|
|
||||||
# Encryption key for Cian session cookies (pgp_sym_encrypt / Stage 9 Calculator).
|
# Encryption key for Cian session cookies (pgp_sym_encrypt / Stage 9 Calculator).
|
||||||
# Empty = Valuation Calculator scraper disabled + /api/v1/cookies/upload returns 503.
|
# Empty = Valuation Calculator scraper disabled + /api/v1/cookies/upload returns 503.
|
||||||
|
|
@ -77,10 +75,9 @@ COOKIE_ENCRYPTION_KEY=<64-char hex>
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# /opt/gendesign/tradein-mvp/backend/.env.runtime — те же ключи которые
|
# /opt/gendesign/tradein-mvp/backend/.env.runtime — те же ключи которые
|
||||||
# читаются ВНУТРИ container'а (scripts/backfill_house_coords.py, app/*).
|
# читаются ВНУТРИ container'а (app/*, scripts/*.py).
|
||||||
# Может быть симлинком на ../.env.runtime если переменные совпадают:
|
# Может быть симлинком на ../.env.runtime если переменные совпадают:
|
||||||
# ln -s ../.env.runtime /opt/gendesign/tradein-mvp/backend/.env.runtime
|
# ln -s ../.env.runtime /opt/gendesign/tradein-mvp/backend/.env.runtime
|
||||||
YANDEX_GEOCODER_API_KEY=<key или пусто>
|
|
||||||
COOKIE_ENCRYPTION_KEY=<64-char hex>
|
COOKIE_ENCRYPTION_KEY=<64-char hex>
|
||||||
GENDESIGN_FDW_PASSWORD=<password или пусто>
|
GENDESIGN_FDW_PASSWORD=<password или пусто>
|
||||||
GLITCHTIP_DSN=<dsn или пусто>
|
GLITCHTIP_DSN=<dsn или пусто>
|
||||||
|
|
@ -200,7 +197,6 @@ cat > tradein-mvp/.env.runtime <<EOF
|
||||||
TRADEIN_POSTGRES_USER=tradein
|
TRADEIN_POSTGRES_USER=tradein
|
||||||
TRADEIN_POSTGRES_PASSWORD=$(openssl rand -hex 32)
|
TRADEIN_POSTGRES_PASSWORD=$(openssl rand -hex 32)
|
||||||
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
|
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
|
||||||
YANDEX_GEOCODER_API_KEY=
|
|
||||||
EOF
|
EOF
|
||||||
chmod 600 tradein-mvp/.env.runtime
|
chmod 600 tradein-mvp/.env.runtime
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,8 +70,11 @@ from app.core.db import SessionLocal, get_db
|
||||||
from app.schemas.trade_in import ScheduleConfig, ScheduleConfigUpdate
|
from app.schemas.trade_in import ScheduleConfig, ScheduleConfigUpdate
|
||||||
from app.services import cian_session as cian_session_svc
|
from app.services import cian_session as cian_session_svc
|
||||||
from app.services import domclick_session as domclick_session_svc
|
from app.services import domclick_session as domclick_session_svc
|
||||||
|
from app.services import proxy_rotation as proxy_rotation_svc
|
||||||
from app.services import scrape_runs as runs_mod
|
from app.services import scrape_runs as runs_mod
|
||||||
from app.services.geocoder import geocode
|
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||||
|
from app.services.geocoder import geocode, known_city_hint
|
||||||
|
from app.services.proxy_pool import clear_source_bans
|
||||||
from app.services.scheduler import has_running_run
|
from app.services.scheduler import has_running_run
|
||||||
from app.services.scraper_adapters import (
|
from app.services.scraper_adapters import (
|
||||||
RealEnrichmentJobs,
|
RealEnrichmentJobs,
|
||||||
|
|
@ -197,7 +200,9 @@ async def scrape_around(
|
||||||
for source in payload.sources:
|
for source in payload.sources:
|
||||||
scraper_ctx: AvitoScraper | CianScraper | YandexRealtyScraper
|
scraper_ctx: AvitoScraper | CianScraper | YandexRealtyScraper
|
||||||
if source == "avito":
|
if source == "avito":
|
||||||
scraper_ctx = AvitoScraper(config, delay_provider=get_scraper_delay)
|
scraper_ctx = AvitoScraper(
|
||||||
|
config, delay_provider=get_scraper_delay, proxy_provider=proxy_provider
|
||||||
|
)
|
||||||
elif source == "cian":
|
elif source == "cian":
|
||||||
scraper_ctx = CianScraper(
|
scraper_ctx = CianScraper(
|
||||||
config, delay_provider=get_scraper_delay, proxy_provider=proxy_provider
|
config, delay_provider=get_scraper_delay, proxy_provider=proxy_provider
|
||||||
|
|
@ -227,6 +232,8 @@ async def scrape_around(
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
lots = await scraper.fetch_around(payload.lat, payload.lon, payload.radius_m)
|
lots = await scraper.fetch_around(payload.lat, payload.lon, payload.radius_m)
|
||||||
|
# run_id нет и не будет (#2701): ручной admin-скрейп строки в scrape_runs не
|
||||||
|
# заводит — снимок пишется вне прогона, поле честно остаётся NULL.
|
||||||
inserted, updated = save_listings(
|
inserted, updated = save_listings(
|
||||||
db, lots, matcher=matcher, region_code=DEFAULT_REGION_CODE
|
db, lots, matcher=matcher, region_code=DEFAULT_REGION_CODE
|
||||||
)
|
)
|
||||||
|
|
@ -246,7 +253,8 @@ def _clean_address_for_geocode(addr: str) -> str:
|
||||||
"""Чистим address для геокодера.
|
"""Чистим address для геокодера.
|
||||||
|
|
||||||
Cian отдаёт «улица Латвийская, 56/3 · р-н Чкаловский» — суффикс ' · ...'
|
Cian отдаёт «улица Латвийская, 56/3 · р-н Чкаловский» — суффикс ' · ...'
|
||||||
мешает Nominatim. Берём часть до ' · '. N1 отдаёт «Репина, 75/2 стр.» — ок.
|
мешает Nominatim. Берём часть до ' · '. Остальные источники такого суффикса
|
||||||
|
не используют — адрес остаётся без изменений.
|
||||||
"""
|
"""
|
||||||
main = addr.split(" · ")[0].strip()
|
main = addr.split(" · ")[0].strip()
|
||||||
return main or addr
|
return main or addr
|
||||||
|
|
@ -260,7 +268,7 @@ async def geocode_missing(
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
|
"""Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
|
||||||
|
|
||||||
target=listings (по умолч.) — объявления Cian/N1; target=deals — сделки Росреестра.
|
target=listings (по умолч.) — объявления; target=deals — сделки Росреестра.
|
||||||
Чанк-обработка с бюджетом по времени (~240с, заведомо меньше cron
|
Чанк-обработка с бюджетом по времени (~240с, заведомо меньше cron
|
||||||
`curl -m 320`): за вызов геокодим сколько успеваем, остаток уходит в
|
`curl -m 320`): за вызов геокодим сколько успеваем, остаток уходит в
|
||||||
`remaining`, cron вызывает в цикле пока `remaining` > 0.
|
`remaining`, cron вызывает в цикле пока `remaining` > 0.
|
||||||
|
|
@ -269,17 +277,13 @@ async def geocode_missing(
|
||||||
адреса не выбираются повторно 7 дней → cron-loop завершается, не зацикливается.
|
адреса не выбираются повторно 7 дней → cron-loop завершается, не зацикливается.
|
||||||
geom обновляется автоматически триггером.
|
geom обновляется автоматически триггером.
|
||||||
"""
|
"""
|
||||||
# Доп. фильтр для listings — у Avito/N1 встречаются плейсхолдер-адреса.
|
# Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса.
|
||||||
extra_filter = (
|
extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else ""
|
||||||
"AND address NOT LIKE '%(Avito)%' AND address NOT LIKE '%(N1)%'"
|
|
||||||
if target == "listings"
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
f"""
|
f"""
|
||||||
SELECT id, address
|
SELECT id, address, city
|
||||||
FROM {target}
|
FROM {target}
|
||||||
WHERE lat IS NULL
|
WHERE lat IS NULL
|
||||||
AND COALESCE(address, '') != ''
|
AND COALESCE(address, '') != ''
|
||||||
|
|
@ -313,7 +317,20 @@ async def geocode_missing(
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
clean = _clean_address_for_geocode(row["address"])
|
clean = _clean_address_for_geocode(row["address"])
|
||||||
result = await geocode(clean, db)
|
# city (#2594 шаг 2/3) — известен вызывающему коду через listings.city
|
||||||
|
# (миграция 196) / deals.city (миграция 177), проставляется из контекста
|
||||||
|
# развёртки/импорта. Прокидываем как city_hint, а не полагаемся на то, что
|
||||||
|
# геокодер угадает город по тексту address (голый "ул. Победы, 30" без
|
||||||
|
# города в тексте иначе уходит в Екатеринбург).
|
||||||
|
#
|
||||||
|
# known_city_hint (#2603) — гейт по словарю городов области: при
|
||||||
|
# target="deals" сюда приходит росреестровое поле, в хвосте которого
|
||||||
|
# лежат не-города («Бессонова», «Билейский рыбопитомник»), а мусорный
|
||||||
|
# хинт закрывает EKB-локальные тиры и уезжает префиксом в запрос
|
||||||
|
# провайдеру, т.е. вреднее отсутствия хинта. Общий хелпер, тот же, что у
|
||||||
|
# scripts/geocode_deals_nominatim.py и tasks/geocode_missing.py.
|
||||||
|
city = known_city_hint(row.get("city"))
|
||||||
|
result = await geocode(clean, db, city_hint=city)
|
||||||
if result is None:
|
if result is None:
|
||||||
# Помечаем что пробовали — иначе ретрай на каждом cron.
|
# Помечаем что пробовали — иначе ретрай на каждом cron.
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -1577,8 +1594,22 @@ def update_schedule(
|
||||||
"""UPDATE existing schedule (create если не существует, через INSERT ON CONFLICT)."""
|
"""UPDATE existing schedule (create если не существует, через INSERT ON CONFLICT)."""
|
||||||
from app.services.scheduler import compute_next_run_at
|
from app.services.scheduler import compute_next_run_at
|
||||||
|
|
||||||
# Compute new next_run_at если window изменился — recompute, иначе keep existing
|
# #2674: такт берётся из default_params — ровно как его читает планировщик
|
||||||
next_at = compute_next_run_at(payload.window_start_hour, payload.window_end_hour)
|
# (_claim_run/_defer_next_run_at). Без него compute_next_run_at падал на default=1 и
|
||||||
|
# ЛЮБОЕ сохранение сбивало источник на «завтра»: недельный avito_full_load после
|
||||||
|
# правки окна побежал бы через сутки. На суточных источниках баг был невидим —
|
||||||
|
# для них «завтра» и есть правильный ответ.
|
||||||
|
# None-safe так же, как в scheduler: `"interval_days": null` в jsonb → 1, не TypeError.
|
||||||
|
_interval_days = payload.default_params.get("interval_days")
|
||||||
|
interval_days = max(1, int(_interval_days)) if _interval_days is not None else 1
|
||||||
|
|
||||||
|
# Явно заданный оператором момент уважается как есть (в т.ч. в прошлом — «запустить
|
||||||
|
# сейчас»). Иначе считаем от такта.
|
||||||
|
next_at = payload.next_run_at or compute_next_run_at(
|
||||||
|
payload.window_start_hour,
|
||||||
|
payload.window_end_hour,
|
||||||
|
interval_days=interval_days,
|
||||||
|
)
|
||||||
|
|
||||||
row = (
|
row = (
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -1592,7 +1623,22 @@ def update_schedule(
|
||||||
window_start_hour = EXCLUDED.window_start_hour,
|
window_start_hour = EXCLUDED.window_start_hour,
|
||||||
window_end_hour = EXCLUDED.window_end_hour,
|
window_end_hour = EXCLUDED.window_end_hour,
|
||||||
default_params = EXCLUDED.default_params,
|
default_params = EXCLUDED.default_params,
|
||||||
next_run_at = EXCLUDED.next_run_at,
|
-- #2674: не двигаем уже назначенный запуск, если двигать не за чем.
|
||||||
|
-- Раньше next_run_at перезаписывался ВСЕГДА, поэтому правка соседнего
|
||||||
|
-- поля (enabled, request_delay_sec в params) заново разыгрывала момент
|
||||||
|
-- внутри окна и сдвигала прогон. Сохраняем существующий только когда он
|
||||||
|
-- ещё в будущем И ни окно, ни такт не менялись — тогда пересчёт дал бы
|
||||||
|
-- то же самое окно, только с другим random-смещением.
|
||||||
|
next_run_at = CASE
|
||||||
|
WHEN CAST(:explicit AS boolean) THEN EXCLUDED.next_run_at
|
||||||
|
WHEN scrape_schedules.next_run_at > NOW()
|
||||||
|
AND scrape_schedules.window_start_hour = EXCLUDED.window_start_hour
|
||||||
|
AND scrape_schedules.window_end_hour = EXCLUDED.window_end_hour
|
||||||
|
AND COALESCE(scrape_schedules.default_params ->> 'interval_days', '1')
|
||||||
|
= COALESCE(EXCLUDED.default_params ->> 'interval_days', '1')
|
||||||
|
THEN scrape_schedules.next_run_at
|
||||||
|
ELSE EXCLUDED.next_run_at
|
||||||
|
END,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING id, source, enabled, window_start_hour, window_end_hour,
|
RETURNING id, source, enabled, window_start_hour, window_end_hour,
|
||||||
default_params, last_run_id, last_run_at, next_run_at, updated_at
|
default_params, last_run_id, last_run_at, next_run_at, updated_at
|
||||||
|
|
@ -1605,6 +1651,7 @@ def update_schedule(
|
||||||
"we": payload.window_end_hour,
|
"we": payload.window_end_hour,
|
||||||
"params": json.dumps(payload.default_params, ensure_ascii=False),
|
"params": json.dumps(payload.default_params, ensure_ascii=False),
|
||||||
"next_at": next_at,
|
"next_at": next_at,
|
||||||
|
"explicit": payload.next_run_at is not None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
|
|
@ -2169,16 +2216,33 @@ async def scrape_house_imv_backfill(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Единая scrapers-страница: unified runs + health + rotate-ip (epic) ────────
|
# ── Единая scrapers-страница: unified runs + health (epic) ───────────────────
|
||||||
|
# rotate-ip (changeip mobileproxy) удалён #2616 шаг 3 — мёртвая подписка (#2613).
|
||||||
|
|
||||||
|
|
||||||
class UnifiedScrapeRunRow(BaseModel):
|
class UnifiedScrapeRunRow(BaseModel):
|
||||||
"""Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче)."""
|
"""Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче).
|
||||||
|
|
||||||
|
#2674: поля run_type больше нет. Вид прогона в БД всегда был дефолтом
|
||||||
|
'city_sweep' (3244 из 3244 строк, ни одно место кода его не задавало), и
|
||||||
|
таблица подписывала им прогоны, которые никаким sweep не были —
|
||||||
|
proxy_healthcheck, deactivate_stale_*, sber_index_pull. Что именно бежало,
|
||||||
|
называет `source`.
|
||||||
|
"""
|
||||||
|
|
||||||
run_id: int
|
run_id: int
|
||||||
source: str
|
source: str
|
||||||
run_type: str | None = None
|
|
||||||
status: str
|
status: str
|
||||||
|
# #2674: чинить фильтр без этого флага было бы регрессом. Пока таблица была
|
||||||
|
# пуста на всех вкладках, кнопка отмены не рендерилась ни разу; теперь оператор
|
||||||
|
# видит все 53 источника — и без флага мог бы «отменить» задачу, которая отмену
|
||||||
|
# не опрашивает (см. scrape_runs.honors_cancel): статус соврал бы, а
|
||||||
|
# has_running_run перестал бы держать single-run guard.
|
||||||
|
cancellable: bool = False
|
||||||
|
# #2686: диагноз для status='banned' — 'platform' (площадка заблокировала) или
|
||||||
|
# 'infra' (не отдал наш браузерный сайдкар). Без него оператор видит только
|
||||||
|
# «забанен» и делает вывод «площадка нас палит» на 80% наших же отказов.
|
||||||
|
ban_kind: str | None = None
|
||||||
params: dict | None = None
|
params: dict | None = None
|
||||||
counters: dict | None = None
|
counters: dict | None = None
|
||||||
total_seen: int | None = None
|
total_seen: int | None = None
|
||||||
|
|
@ -2194,6 +2258,12 @@ class UnifiedScrapeRunsResponse(BaseModel):
|
||||||
rows: list[UnifiedScrapeRunRow]
|
rows: list[UnifiedScrapeRunRow]
|
||||||
|
|
||||||
|
|
||||||
|
class ScrapeRunSourcesResponse(BaseModel):
|
||||||
|
"""Список source'ов для фильтра истории прогонов — из данных, не из литерала."""
|
||||||
|
|
||||||
|
sources: list[str]
|
||||||
|
|
||||||
|
|
||||||
class BrowserHealth(BaseModel):
|
class BrowserHealth(BaseModel):
|
||||||
reachable: bool
|
reachable: bool
|
||||||
browsers: dict[str, bool] = Field(default_factory=dict)
|
browsers: dict[str, bool] = Field(default_factory=dict)
|
||||||
|
|
@ -2213,33 +2283,23 @@ class ScraperHealthResponse(BaseModel):
|
||||||
providers: list[ProviderHealth]
|
providers: list[ProviderHealth]
|
||||||
|
|
||||||
|
|
||||||
class RotateIpResponse(BaseModel):
|
|
||||||
ok: bool
|
|
||||||
new_ip: str | None = None
|
|
||||||
reason: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
_ROTATABLE_SOURCES = ("avito", "cian", "yandex")
|
_ROTATABLE_SOURCES = ("avito", "cian", "yandex")
|
||||||
|
|
||||||
|
|
||||||
def _provider_proxy_url(source: str) -> str | None:
|
def _provider_proxy_url(source: str) -> str | None:
|
||||||
"""Effective proxy URL для source (учитывает property-fallback в settings)."""
|
"""Effective proxy URL для source (учитывает property-fallback в settings).
|
||||||
|
|
||||||
|
#2616 шаг 2: avito/cian/yandex все три сходятся на settings.scraper_proxy_url
|
||||||
|
(per-provider AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты — мёртвая
|
||||||
|
mobileproxy-подписка, #2613).
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
"avito": settings.avito_proxy_url,
|
"avito": settings.scraper_proxy_url,
|
||||||
"cian": settings.cian_proxy_url,
|
"cian": settings.cian_proxy_url,
|
||||||
"yandex": settings.yandex_proxy_url,
|
"yandex": settings.yandex_proxy_url,
|
||||||
}.get(source)
|
}.get(source)
|
||||||
|
|
||||||
|
|
||||||
def _provider_rotate_url(source: str) -> str | None:
|
|
||||||
"""changeip-URL для source (None → auto-rotate прокси без ручной ротации)."""
|
|
||||||
return {
|
|
||||||
"avito": settings.avito_proxy_rotate_url,
|
|
||||||
"cian": settings.cian_proxy_rotate_url,
|
|
||||||
"yandex": settings.yandex_proxy_rotate_url,
|
|
||||||
}.get(source)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | None]:
|
def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | None]:
|
||||||
"""Распарсить host/port из proxy URL (схема http(s)://user:pass@host:port)."""
|
"""Распарсить host/port из proxy URL (схема http(s)://user:pass@host:port)."""
|
||||||
if not proxy_url:
|
if not proxy_url:
|
||||||
|
|
@ -2257,7 +2317,11 @@ def list_scrape_runs_unified(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
source: Annotated[str | None, Query()] = None,
|
source: Annotated[str | None, Query()] = None,
|
||||||
status: Annotated[
|
status: Annotated[
|
||||||
Literal["done", "running", "banned", "zombie", "failed", "cancelled"] | None, Query()
|
# 'skipped' (#2658) — пропущенное расписание; без него оператор не может
|
||||||
|
# спросить «что сейчас пропускается» (фильтр отдавал 422 на единственной
|
||||||
|
# поверхности, построенной ровно для этого вопроса).
|
||||||
|
Literal["done", "running", "banned", "zombie", "failed", "cancelled", "skipped"] | None,
|
||||||
|
Query(),
|
||||||
] = None,
|
] = None,
|
||||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||||
offset: Annotated[int, Query(ge=0)] = 0,
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
|
|
@ -2269,7 +2333,7 @@ def list_scrape_runs_unified(
|
||||||
|
|
||||||
Query:
|
Query:
|
||||||
source — опц. фильтр по source (avito_city_sweep / cian_city_sweep / ...).
|
source — опц. фильтр по source (avito_city_sweep / cian_city_sweep / ...).
|
||||||
status — опц. фильтр (done/running/banned/zombie/failed/cancelled).
|
status — опц. фильтр (done/running/banned/zombie/failed/cancelled/skipped).
|
||||||
limit — default 50, max 200.
|
limit — default 50, max 200.
|
||||||
offset — default 0.
|
offset — default 0.
|
||||||
"""
|
"""
|
||||||
|
|
@ -2284,8 +2348,9 @@ def list_scrape_runs_unified(
|
||||||
UnifiedScrapeRunRow(
|
UnifiedScrapeRunRow(
|
||||||
run_id=r["run_id"],
|
run_id=r["run_id"],
|
||||||
source=r["source"],
|
source=r["source"],
|
||||||
run_type=r.get("run_type"),
|
|
||||||
status=r["status"],
|
status=r["status"],
|
||||||
|
cancellable=runs_mod.honors_cancel(str(r["source"])),
|
||||||
|
ban_kind=r.get("ban_kind"),
|
||||||
params=r.get("params"),
|
params=r.get("params"),
|
||||||
counters=r.get("counters"),
|
counters=r.get("counters"),
|
||||||
total_seen=r.get("total_seen"),
|
total_seen=r.get("total_seen"),
|
||||||
|
|
@ -2300,6 +2365,21 @@ def list_scrape_runs_unified(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scrape/runs/sources", response_model=ScrapeRunSourcesResponse)
|
||||||
|
def list_scrape_run_sources(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> ScrapeRunSourcesResponse:
|
||||||
|
"""Источники для фильтра истории прогонов — ровно те, что есть в scrape_runs.
|
||||||
|
|
||||||
|
#2674: фильтр в UI был захардкожен тремя значениями (avito/cian/yandex), а в
|
||||||
|
таблице 53 разных source и НИ ОДНОЙ строки с таким точным значением — каждый
|
||||||
|
пункт фильтра давал пустую выдачу, и 76% прогонов (вся площадка Домклик в том
|
||||||
|
числе) были недоступны для вопроса «что там происходит». Список берётся из
|
||||||
|
данных: новый source появляется в фильтре сам, без правки кода.
|
||||||
|
"""
|
||||||
|
return ScrapeRunSourcesResponse(sources=runs_mod.distinct_sources(db))
|
||||||
|
|
||||||
|
|
||||||
async def _probe_browser_health() -> BrowserHealth:
|
async def _probe_browser_health() -> BrowserHealth:
|
||||||
"""GET tradein-browser /health (timeout 5с). reachable=False при ошибке."""
|
"""GET tradein-browser /health (timeout 5с). reachable=False при ошибке."""
|
||||||
url = f"{settings.browser_http_endpoint.rstrip('/')}/health"
|
url = f"{settings.browser_http_endpoint.rstrip('/')}/health"
|
||||||
|
|
@ -2338,8 +2418,10 @@ async def scraper_health() -> ScraperHealthResponse:
|
||||||
|
|
||||||
- fetch_mode: settings.scraper_fetch_mode (curl_cffi / browser).
|
- fetch_mode: settings.scraper_fetch_mode (curl_cffi / browser).
|
||||||
- browser: GET tradein-browser /health (reachable + per-browser ready-флаги).
|
- browser: GET tradein-browser /health (reachable + per-browser ready-флаги).
|
||||||
- providers: для avito/cian/yandex — proxy host/port, rotate_supported,
|
- providers: для avito/cian/yandex — proxy host/port, rotate_supported
|
||||||
best-effort current_ip (параллельный пробинг через прокси на ipify).
|
(#2616 шаг 2: всегда False — changeip mobileproxy-ротация снята, мёртвый
|
||||||
|
аккаунт #2613; живая ASocks-ротация — POST /admin/proxies/{id}/rotate, #2611,
|
||||||
|
не per-provider-source), best-effort current_ip (пробинг через прокси на ipify).
|
||||||
|
|
||||||
Все пробинги параллельны (asyncio.gather) и time-boxed — суммарно ≤10с.
|
Все пробинги параллельны (asyncio.gather) и time-boxed — суммарно ≤10с.
|
||||||
"""
|
"""
|
||||||
|
|
@ -2359,7 +2441,7 @@ async def scraper_health() -> ScraperHealthResponse:
|
||||||
source=source,
|
source=source,
|
||||||
proxy_host=host,
|
proxy_host=host,
|
||||||
proxy_port=port,
|
proxy_port=port,
|
||||||
rotate_supported=bool(_provider_rotate_url(source)),
|
rotate_supported=False,
|
||||||
current_ip=ip_by_source[source],
|
current_ip=ip_by_source[source],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -2371,46 +2453,6 @@ async def scraper_health() -> ScraperHealthResponse:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/scraper/{source}/rotate-ip", response_model=RotateIpResponse)
|
|
||||||
async def rotate_proxy_ip(
|
|
||||||
source: Literal["avito", "cian", "yandex"],
|
|
||||||
) -> RotateIpResponse:
|
|
||||||
"""Сменить мобильный exit-IP провайдера через changeip-ссылку (mobileproxy).
|
|
||||||
|
|
||||||
Зеркалит логику AvitoScraper._rotate_ip (GET rotate_url + &format=json), но БЕЗ
|
|
||||||
settle-sleep — API сразу возвращает ответ changeip. Если rotate_url не задан —
|
|
||||||
прокси с авто-ротацией (свежий IP на новое соединение), ручная ротация не нужна.
|
|
||||||
"""
|
|
||||||
rotate_url = _provider_rotate_url(source)
|
|
||||||
if not rotate_url:
|
|
||||||
return RotateIpResponse(ok=False, reason="no rotate url (auto-rotate proxy)")
|
|
||||||
|
|
||||||
sep = "&" if "?" in rotate_url else "?"
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
||||||
resp = await client.get(f"{rotate_url}{sep}format=json")
|
|
||||||
resp.raise_for_status()
|
|
||||||
try:
|
|
||||||
data = resp.json()
|
|
||||||
except Exception:
|
|
||||||
data = {}
|
|
||||||
except Exception:
|
|
||||||
# НЕ отдавать str(exc) клиенту (аудит-фикс, #security-audit): httpx-исключения
|
|
||||||
# несут полный request URL, а rotate_url — mobileproxy changeip-ссылка с API-
|
|
||||||
# ключом провайдера в query-string (?...&proxy_key=...). str(exc) с этим URL в
|
|
||||||
# HTTP-ответе — прямая утечка секрета вызывающему клиенту. Причина сбоя остаётся
|
|
||||||
# в логах (exc_info=True) для диагностики; наружу — только нейтральный reason.
|
|
||||||
logger.warning("rotate-ip: changeip failed source=%s", source, exc_info=True)
|
|
||||||
return RotateIpResponse(ok=False, reason="changeip request failed")
|
|
||||||
|
|
||||||
# changeip отдаёт новый IP в одном из полей (формат провайдер-зависимый).
|
|
||||||
new_ip = None
|
|
||||||
if isinstance(data, dict):
|
|
||||||
new_ip = data.get("new_ip") or data.get("ip") or data.get("proxy_ip")
|
|
||||||
logger.info("rotate-ip: source=%s new_ip=%s", source, new_ip)
|
|
||||||
return RotateIpResponse(ok=True, new_ip=str(new_ip) if new_ip else None)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Pacing live-регулятор (GET/PUT /scraper/pacing) ──────────────────────────
|
# ── Pacing live-регулятор (GET/PUT /scraper/pacing) ──────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2506,6 +2548,12 @@ async def update_scraper_pacing(
|
||||||
class SourceCoverage(BaseModel):
|
class SourceCoverage(BaseModel):
|
||||||
source: str
|
source: str
|
||||||
active_count: int
|
active_count: int
|
||||||
|
# #2660: «активно» ≠ «живо». is_active снимается только деактиватором протухших,
|
||||||
|
# а он покрывает не все источники — на проде (2026-08-05) cian показывал 18 530
|
||||||
|
# активных при 12 683 не виденных 14+ дней. Из-за этого #2574 месяц читалась как
|
||||||
|
# «всё собирается». Не прячем протухшее из счётчика, а отдаём ВТОРЫМ числом
|
||||||
|
# рядом — тогда «активно» перестаёт читаться как «живо».
|
||||||
|
stale_count: int
|
||||||
fields: dict[str, float] # field_name -> fill% (0..100, round 1)
|
fields: dict[str, float] # field_name -> fill% (0..100, round 1)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2520,6 +2568,9 @@ class HousesCoverage(BaseModel):
|
||||||
class DataQualityResponse(BaseModel):
|
class DataQualityResponse(BaseModel):
|
||||||
sources: list[SourceCoverage]
|
sources: list[SourceCoverage]
|
||||||
houses: HousesCoverage
|
houses: HousesCoverage
|
||||||
|
# Порог «не виделись N дней» для stale_count — отдаём в ответе, чтобы UI
|
||||||
|
# подписывал число, а не хардкодил порог у себя вторым определением.
|
||||||
|
stale_days: int
|
||||||
|
|
||||||
|
|
||||||
# Поля listings для fill%-аудита. Каждый кортеж: (имя_поля, SQL-выражение IS NOT NULL).
|
# Поля listings для fill%-аудита. Каждый кортеж: (имя_поля, SQL-выражение IS NOT NULL).
|
||||||
|
|
@ -2549,6 +2600,10 @@ def get_data_quality(
|
||||||
living_area_m2, ceiling_height (cian), ceiling_height_m (avito), metro_stations.
|
living_area_m2, ceiling_height (cian), ceiling_height_m (avito), metro_stations.
|
||||||
houses: total, avito_validated_at%, rating_score%, house_type%.
|
houses: total, avito_validated_at%, rating_score%, house_type%.
|
||||||
house_reviews: общий count.
|
house_reviews: общий count.
|
||||||
|
|
||||||
|
#2660: рядом с active_count отдаётся stale_count — сколько из «активных» не
|
||||||
|
виделись LISTINGS_FRESH_DAYS дней (last_seen_at). Порог отдаётся в ответе
|
||||||
|
(stale_days), чтобы UI не заводил второе определение.
|
||||||
"""
|
"""
|
||||||
# Строим single-pass SELECT для listings полей через FILTER-агрегаты.
|
# Строим single-pass SELECT для listings полей через FILTER-агрегаты.
|
||||||
# Структура: COUNT(*) FILTER (WHERE <expr>) / NULLIF(COUNT(*), 0) * 100
|
# Структура: COUNT(*) FILTER (WHERE <expr>) / NULLIF(COUNT(*), 0) * 100
|
||||||
|
|
@ -2556,10 +2611,17 @@ def get_data_quality(
|
||||||
filter_exprs = ", ".join(
|
filter_exprs = ", ".join(
|
||||||
f"COUNT(*) FILTER (WHERE {expr}) AS f_{name}" for name, expr in _DQ_LISTING_FIELDS
|
f"COUNT(*) FILTER (WHERE {expr}) AS f_{name}" for name, expr in _DQ_LISTING_FIELDS
|
||||||
)
|
)
|
||||||
|
# last_seen_at, а не scraped_at: счётчик отвечает буквально на «сколько не
|
||||||
|
# виделись». На проде две колонки не расходятся (замер 2026-08-05: 0 активных
|
||||||
|
# строк с разницей ≥ суток), но семантика счётчика — про «видели», и колонка
|
||||||
|
# должна называть ровно её.
|
||||||
sql_listings = text(f"""
|
sql_listings = text(f"""
|
||||||
SELECT
|
SELECT
|
||||||
source,
|
source,
|
||||||
COUNT(*) AS active_count,
|
COUNT(*) AS active_count,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE last_seen_at <= NOW() - (:fresh_days || ' days')::interval
|
||||||
|
) AS stale_count,
|
||||||
{filter_exprs}
|
{filter_exprs}
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE is_active = true
|
WHERE is_active = true
|
||||||
|
|
@ -2567,7 +2629,7 @@ def get_data_quality(
|
||||||
ORDER BY source
|
ORDER BY source
|
||||||
""")
|
""")
|
||||||
|
|
||||||
rows = db.execute(sql_listings).mappings().all()
|
rows = db.execute(sql_listings, {"fresh_days": LISTINGS_FRESH_DAYS}).mappings().all()
|
||||||
|
|
||||||
sources: list[SourceCoverage] = []
|
sources: list[SourceCoverage] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
|
@ -2581,6 +2643,7 @@ def get_data_quality(
|
||||||
SourceCoverage(
|
SourceCoverage(
|
||||||
source=row["source"],
|
source=row["source"],
|
||||||
active_count=int(row["active_count"]),
|
active_count=int(row["active_count"]),
|
||||||
|
stale_count=int(row["stale_count"] or 0),
|
||||||
fields=fields,
|
fields=fields,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -2608,7 +2671,7 @@ def get_data_quality(
|
||||||
reviews_count=reviews_count,
|
reviews_count=reviews_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
return DataQualityResponse(sources=sources, houses=houses)
|
return DataQualityResponse(sources=sources, houses=houses, stale_days=LISTINGS_FRESH_DAYS)
|
||||||
|
|
||||||
|
|
||||||
# ── Proxy pool: хранилище + bulk-загрузка / список (#2161) ───────────────────
|
# ── Proxy pool: хранилище + bulk-загрузка / список (#2161) ───────────────────
|
||||||
|
|
@ -2679,6 +2742,52 @@ class ProxyBulkResponse(BaseModel):
|
||||||
updated: int
|
updated: int
|
||||||
|
|
||||||
|
|
||||||
|
class ProxySourceBan(BaseModel):
|
||||||
|
"""Активный бан узла КОНКРЕТНОЙ площадкой (#2600 п.2, scrape_proxy_source_bans)."""
|
||||||
|
|
||||||
|
source: str
|
||||||
|
banned_until: str
|
||||||
|
ban_count: int
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_source_bans(db: Session, proxy_ids: list[int]) -> dict[int, list[ProxySourceBan]]:
|
||||||
|
"""Активные (banned_until > now()) баны по источникам для указанных узлов.
|
||||||
|
|
||||||
|
Без этого оператор видит `enabled=true` и не понимает, почему узел не выдаётся
|
||||||
|
конкретному источнику (#2600 п.2 — бан теперь по паре «узел × источник», а не
|
||||||
|
глобальное выключение). Истёкшие строки не показываем: они ни на что не влияют,
|
||||||
|
живут ещё SOURCE_BAN_PURGE_DAYS только как память об эскалации.
|
||||||
|
"""
|
||||||
|
if not proxy_ids:
|
||||||
|
return {}
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT proxy_id, source, banned_until, ban_count
|
||||||
|
FROM scrape_proxy_source_bans
|
||||||
|
WHERE banned_until > now()
|
||||||
|
AND proxy_id = ANY(CAST(:ids AS bigint[]))
|
||||||
|
ORDER BY proxy_id, source
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"ids": proxy_ids},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
bans: dict[int, list[ProxySourceBan]] = {}
|
||||||
|
for r in rows:
|
||||||
|
bans.setdefault(int(r["proxy_id"]), []).append(
|
||||||
|
ProxySourceBan(
|
||||||
|
source=r["source"],
|
||||||
|
banned_until=r["banned_until"].isoformat(),
|
||||||
|
ban_count=r["ban_count"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return bans
|
||||||
|
|
||||||
|
|
||||||
class ProxyRow(BaseModel):
|
class ProxyRow(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
label: str | None
|
label: str | None
|
||||||
|
|
@ -2687,6 +2796,7 @@ class ProxyRow(BaseModel):
|
||||||
provider_affinity: str
|
provider_affinity: str
|
||||||
rotate_url: str | None # маскированный
|
rotate_url: str | None # маскированный
|
||||||
enabled: bool
|
enabled: bool
|
||||||
|
disabled_reason: str | None # #2610: NULL = не выключен вручную (авто-воскрешаем)
|
||||||
consecutive_fails: int
|
consecutive_fails: int
|
||||||
exit_ip: str | None
|
exit_ip: str | None
|
||||||
latency_ms: int | None
|
latency_ms: int | None
|
||||||
|
|
@ -2699,6 +2809,8 @@ class ProxyRow(BaseModel):
|
||||||
expires_at: str | None
|
expires_at: str | None
|
||||||
created_at: str | None
|
created_at: str | None
|
||||||
updated_at: str | None
|
updated_at: str | None
|
||||||
|
# #2600 п.2: активные баны площадками. Пустой список = узел выдаётся всем источникам.
|
||||||
|
source_bans: list[ProxySourceBan] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/proxies/bulk", response_model=ProxyBulkResponse)
|
@router.post("/proxies/bulk", response_model=ProxyBulkResponse)
|
||||||
|
|
@ -2711,8 +2823,18 @@ def bulk_upsert_proxies(
|
||||||
Тело: {"proxies": [{"url", "provider_affinity", "kind"?, "rotate_url"?,
|
Тело: {"proxies": [{"url", "provider_affinity", "kind"?, "rotate_url"?,
|
||||||
"label"?, "geo"?, "operator"?}, ...]}.
|
"label"?, "geo"?, "operator"?}, ...]}.
|
||||||
|
|
||||||
Существующий url → DO UPDATE (affinity/kind/rotate_url + enabled=true,
|
Существующий url → DO UPDATE (affinity/kind/rotate_url + enabled,
|
||||||
label/geo/operator обновляются если переданы). Новый → INSERT.
|
label/geo/operator обновляются если переданы). Новый → INSERT (enabled=true,
|
||||||
|
disabled_reason=NULL — новый прокси не может быть "выключен вручную").
|
||||||
|
|
||||||
|
enabled на UPDATE-ветке НЕ безусловный (#2610): если у существующей строки
|
||||||
|
disabled_reason НЕ NULL (оператор снял узел с ротации вручную), bulk-upsert
|
||||||
|
(например повторный прогон загрузчика с тем же url) не должен тихо вернуть
|
||||||
|
его в строй — тот же класс бага, что чинили в mark_health. enabled=true
|
||||||
|
ставится, только если disabled_reason IS NULL; сам disabled_reason bulk
|
||||||
|
не трогает (эта ручка не умеет ни ставить, ни снимать ручной флаг — это
|
||||||
|
PATCH /proxies/{id}, см. patch_proxy).
|
||||||
|
|
||||||
Валидация provider_affinity/kind по whitelist на уровне Pydantic → 422.
|
Валидация provider_affinity/kind по whitelist на уровне Pydantic → 422.
|
||||||
|
|
||||||
Возвращает {inserted, updated}. Дубли по url ВНУТРИ одного запроса
|
Возвращает {inserted, updated}. Дубли по url ВНУТРИ одного запроса
|
||||||
|
|
@ -2738,7 +2860,10 @@ def bulk_upsert_proxies(
|
||||||
label = COALESCE(EXCLUDED.label, scrape_proxies.label),
|
label = COALESCE(EXCLUDED.label, scrape_proxies.label),
|
||||||
geo = COALESCE(EXCLUDED.geo, scrape_proxies.geo),
|
geo = COALESCE(EXCLUDED.geo, scrape_proxies.geo),
|
||||||
operator = COALESCE(EXCLUDED.operator, scrape_proxies.operator),
|
operator = COALESCE(EXCLUDED.operator, scrape_proxies.operator),
|
||||||
enabled = true,
|
enabled = CASE
|
||||||
|
WHEN scrape_proxies.disabled_reason IS NULL THEN true
|
||||||
|
ELSE scrape_proxies.enabled
|
||||||
|
END,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
RETURNING (xmax = 0) AS was_inserted
|
RETURNING (xmax = 0) AS was_inserted
|
||||||
"""
|
"""
|
||||||
|
|
@ -2771,6 +2896,9 @@ def list_proxies(
|
||||||
"""Список прокси со статусами. Пароли в url/rotate_url маскируются.
|
"""Список прокси со статусами. Пароли в url/rotate_url маскируются.
|
||||||
|
|
||||||
Фильтры: provider (=provider_affinity), enabled. Без фильтров — все.
|
Фильтры: provider (=provider_affinity), enabled. Без фильтров — все.
|
||||||
|
|
||||||
|
source_bans — активные баны узла площадками (#2600 п.2): узел может быть
|
||||||
|
enabled=true и при этом не выдаваться конкретному источнику.
|
||||||
"""
|
"""
|
||||||
clauses: list[str] = []
|
clauses: list[str] = []
|
||||||
params: dict[str, Any] = {}
|
params: dict[str, Any] = {}
|
||||||
|
|
@ -2787,8 +2915,9 @@ def list_proxies(
|
||||||
text(
|
text(
|
||||||
f"""
|
f"""
|
||||||
SELECT id, label, url, kind, provider_affinity, rotate_url, enabled,
|
SELECT id, label, url, kind, provider_affinity, rotate_url, enabled,
|
||||||
consecutive_fails, exit_ip, latency_ms, last_check_at, last_ok_at,
|
disabled_reason, consecutive_fails, exit_ip, latency_ms,
|
||||||
leased_by, leased_at, geo, operator, expires_at, created_at, updated_at
|
last_check_at, last_ok_at, leased_by, leased_at, geo, operator,
|
||||||
|
expires_at, created_at, updated_at
|
||||||
FROM scrape_proxies
|
FROM scrape_proxies
|
||||||
{where}
|
{where}
|
||||||
ORDER BY provider_affinity, id
|
ORDER BY provider_affinity, id
|
||||||
|
|
@ -2804,6 +2933,8 @@ def list_proxies(
|
||||||
def _iso(v: Any) -> str | None:
|
def _iso(v: Any) -> str | None:
|
||||||
return v.isoformat() if v is not None else None
|
return v.isoformat() if v is not None else None
|
||||||
|
|
||||||
|
bans = _fetch_source_bans(db, [int(r["id"]) for r in rows])
|
||||||
|
|
||||||
return [
|
return [
|
||||||
ProxyRow(
|
ProxyRow(
|
||||||
id=r["id"],
|
id=r["id"],
|
||||||
|
|
@ -2813,6 +2944,7 @@ def list_proxies(
|
||||||
provider_affinity=r["provider_affinity"],
|
provider_affinity=r["provider_affinity"],
|
||||||
rotate_url=_mask_proxy_url(r["rotate_url"]),
|
rotate_url=_mask_proxy_url(r["rotate_url"]),
|
||||||
enabled=r["enabled"],
|
enabled=r["enabled"],
|
||||||
|
disabled_reason=r["disabled_reason"],
|
||||||
consecutive_fails=r["consecutive_fails"],
|
consecutive_fails=r["consecutive_fails"],
|
||||||
exit_ip=r["exit_ip"],
|
exit_ip=r["exit_ip"],
|
||||||
latency_ms=r["latency_ms"],
|
latency_ms=r["latency_ms"],
|
||||||
|
|
@ -2825,6 +2957,7 @@ def list_proxies(
|
||||||
expires_at=_iso(r["expires_at"]),
|
expires_at=_iso(r["expires_at"]),
|
||||||
created_at=_iso(r["created_at"]),
|
created_at=_iso(r["created_at"]),
|
||||||
updated_at=_iso(r["updated_at"]),
|
updated_at=_iso(r["updated_at"]),
|
||||||
|
source_bans=bans.get(int(r["id"]), []),
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|
@ -2832,6 +2965,18 @@ def list_proxies(
|
||||||
|
|
||||||
class ProxyPatch(BaseModel):
|
class ProxyPatch(BaseModel):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
|
reason: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
max_length=500,
|
||||||
|
description=(
|
||||||
|
"Причина ручного выключения (#2610). Используется только когда enabled=false; "
|
||||||
|
"при отсутствии подставляется дефолтный текст. Игнорируется при enabled=true — "
|
||||||
|
"включение всегда сбрасывает disabled_reason в NULL."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_MANUAL_DISABLE_REASON = "manually disabled via admin API"
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/proxies/{proxy_id}", response_model=ProxyRow)
|
@router.patch("/proxies/{proxy_id}", response_model=ProxyRow)
|
||||||
|
|
@ -2840,20 +2985,40 @@ def patch_proxy(
|
||||||
payload: ProxyPatch,
|
payload: ProxyPatch,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
) -> ProxyRow:
|
) -> ProxyRow:
|
||||||
"""Enable/disable одного прокси по id. 404 если не найден."""
|
"""Enable/disable одного прокси по id. 404 если не найден.
|
||||||
|
|
||||||
|
#2610: разводит "ручное выключение оператором" от "авто-выключение пулом".
|
||||||
|
enabled=false → disabled_reason ставится (payload.reason либо дефолтный текст) —
|
||||||
|
mark_health(ok=True) больше не воскресит узел молча первой успешной ipify-пробой.
|
||||||
|
enabled=true → disabled_reason ОБЯЗАТЕЛЬНО сбрасывается в NULL — иначе узел,
|
||||||
|
однажды выключенный руками, никогда больше не участвовал бы в авто-восстановлении
|
||||||
|
(см. proxy_pool.mark_health).
|
||||||
|
"""
|
||||||
row = (
|
row = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
UPDATE scrape_proxies
|
UPDATE scrape_proxies
|
||||||
SET enabled = :enabled, updated_at = now()
|
SET enabled = :enabled,
|
||||||
|
disabled_reason = CASE
|
||||||
|
WHEN CAST(:enabled AS boolean) THEN NULL
|
||||||
|
ELSE COALESCE(CAST(:reason AS text), disabled_reason,
|
||||||
|
CAST(:default_reason AS text))
|
||||||
|
END,
|
||||||
|
updated_at = now()
|
||||||
WHERE id = :id
|
WHERE id = :id
|
||||||
RETURNING id, label, url, kind, provider_affinity, rotate_url, enabled,
|
RETURNING id, label, url, kind, provider_affinity, rotate_url, enabled,
|
||||||
consecutive_fails, exit_ip, latency_ms, last_check_at, last_ok_at,
|
disabled_reason, consecutive_fails, exit_ip, latency_ms,
|
||||||
leased_by, leased_at, geo, operator, expires_at, created_at, updated_at
|
last_check_at, last_ok_at, leased_by, leased_at, geo, operator,
|
||||||
|
expires_at, created_at, updated_at
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"enabled": payload.enabled, "id": proxy_id},
|
{
|
||||||
|
"enabled": payload.enabled,
|
||||||
|
"reason": payload.reason,
|
||||||
|
"default_reason": _DEFAULT_MANUAL_DISABLE_REASON,
|
||||||
|
"id": proxy_id,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.fetchone()
|
.fetchone()
|
||||||
|
|
@ -2861,6 +3026,18 @@ def patch_proxy(
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail=f"proxy id={proxy_id} not found")
|
raise HTTPException(status_code=404, detail=f"proxy id={proxy_id} not found")
|
||||||
db.commit()
|
db.commit()
|
||||||
|
if payload.enabled:
|
||||||
|
# Ручное включение = чистый лист, как и обнуление disabled_reason выше (#2610).
|
||||||
|
# Иначе узел вернулся бы enabled=true, но по-прежнему невыдаваемым источникам с
|
||||||
|
# активным баном — и оператор не имел бы способа снять ложный бан (#2600 п.2).
|
||||||
|
clear_source_bans(db, proxy_id, reason="manual enable via admin API")
|
||||||
|
if not payload.enabled:
|
||||||
|
logger.info(
|
||||||
|
"proxy_pool: proxy id=%d manually disabled via admin API (reason=%r) — "
|
||||||
|
"auto-revive suspended until re-enabled (#2610)",
|
||||||
|
proxy_id,
|
||||||
|
row["disabled_reason"],
|
||||||
|
)
|
||||||
|
|
||||||
def _iso(v: Any) -> str | None:
|
def _iso(v: Any) -> str | None:
|
||||||
return v.isoformat() if v is not None else None
|
return v.isoformat() if v is not None else None
|
||||||
|
|
@ -2873,6 +3050,7 @@ def patch_proxy(
|
||||||
provider_affinity=row["provider_affinity"],
|
provider_affinity=row["provider_affinity"],
|
||||||
rotate_url=_mask_proxy_url(row["rotate_url"]),
|
rotate_url=_mask_proxy_url(row["rotate_url"]),
|
||||||
enabled=row["enabled"],
|
enabled=row["enabled"],
|
||||||
|
disabled_reason=row["disabled_reason"],
|
||||||
consecutive_fails=row["consecutive_fails"],
|
consecutive_fails=row["consecutive_fails"],
|
||||||
exit_ip=row["exit_ip"],
|
exit_ip=row["exit_ip"],
|
||||||
latency_ms=row["latency_ms"],
|
latency_ms=row["latency_ms"],
|
||||||
|
|
@ -2885,4 +3063,46 @@ def patch_proxy(
|
||||||
expires_at=_iso(row["expires_at"]),
|
expires_at=_iso(row["expires_at"]),
|
||||||
created_at=_iso(row["created_at"]),
|
created_at=_iso(row["created_at"]),
|
||||||
updated_at=_iso(row["updated_at"]),
|
updated_at=_iso(row["updated_at"]),
|
||||||
|
source_bans=_fetch_source_bans(db, [int(row["id"])]).get(int(row["id"]), []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Proxy pool: ручная ротация exit-IP по proxy_id (#2600 п.5) ───────────────
|
||||||
|
#
|
||||||
|
# Раньше отдельно от /scraper/{source}/rotate-ip (env-прокси mobileproxy,
|
||||||
|
# changeip-ссылка) — тот эндпоинт удалён вместе с мёртвой подпиской (#2616 шаг 3).
|
||||||
|
# Этот эндпоинт — единственная живая ручная ротация, по proxy_id из пула
|
||||||
|
# scrape_proxies (сейчас это ASocks-порты с суточным лимитом 3/сутки), см.
|
||||||
|
# app.services.proxy_rotation.rotate_proxy.
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyRotateResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
reason: str | None = None
|
||||||
|
new_ip: str | None = None
|
||||||
|
rotations_remaining_today: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/proxies/{proxy_id}/rotate", response_model=ProxyRotateResponse)
|
||||||
|
async def rotate_pool_proxy(
|
||||||
|
proxy_id: int,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> ProxyRotateResponse:
|
||||||
|
"""Ручная ротация exit-IP одного прокси пула (#2600 п.5).
|
||||||
|
|
||||||
|
Делегирует в app.services.proxy_rotation.rotate_proxy — читает rotate_url
|
||||||
|
прокси из scrape_proxies, требует ASOCKS_API_TOKEN (settings.asocks_api_token),
|
||||||
|
проверяет суточный лимит (3/сутки, scrape_proxy_rotations) ДО обращения к API.
|
||||||
|
ok=False — ожидаемая бизнес-ситуация (нет rotate_url / нет токена / лимит /
|
||||||
|
провайдер отказал), НЕ HTTPException; reason ВСЕГДА нейтральный, без токена.
|
||||||
|
|
||||||
|
ПОКА без автотриггера по бану (issue #2600 п.2: сигнал бана до пула не
|
||||||
|
доходит — страница-заглушка отдаёт 200) — только этот ручной вызов.
|
||||||
|
"""
|
||||||
|
result = await proxy_rotation_svc.rotate_proxy(db, proxy_id)
|
||||||
|
return ProxyRotateResponse(
|
||||||
|
ok=result.ok,
|
||||||
|
reason=result.reason,
|
||||||
|
new_ip=result.new_ip,
|
||||||
|
rotations_remaining_today=result.rotations_remaining_today,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
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(
|
async def lookup(
|
||||||
address: Annotated[str, Query(min_length=3, max_length=500)],
|
address: Annotated[str, Query(min_length=3, max_length=500)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
city_hint: Annotated[
|
||||||
|
str | None,
|
||||||
|
Query(
|
||||||
|
max_length=100,
|
||||||
|
description=(
|
||||||
|
"Город, если известен вызывающему (например выбран пользователем "
|
||||||
|
"на предыдущем шаге UI). #2576: без него геокодер БОЛЬШЕ НЕ "
|
||||||
|
"подставляет 'Екатеринбург' молча — ответ может помечаться "
|
||||||
|
"city_ambiguous=true."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
) -> GeocodeResult:
|
) -> GeocodeResult:
|
||||||
"""Геокодинг адреса → lat/lon.
|
"""Геокодинг адреса → lat/lon.
|
||||||
|
|
||||||
Примеры:
|
Примеры:
|
||||||
/api/v1/geocode/lookup?address=ул.+Малышева+30+Екатеринбург
|
/api/v1/geocode/lookup?address=ул.+Малышева+30+Екатеринбург
|
||||||
/api/v1/geocode/lookup?address=Куйбышева+50+Екатеринбург
|
/api/v1/geocode/lookup?address=Куйбышева+50+Екатеринбург
|
||||||
|
/api/v1/geocode/lookup?address=Ленина+1&city_hint=Нижний+Тагил
|
||||||
"""
|
"""
|
||||||
result = await geocode(address, db)
|
result = await geocode(address, db, city_hint=city_hint)
|
||||||
if result is None:
|
if result is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Address not found: {address}")
|
raise HTTPException(status_code=404, detail=f"Address not found: {address}")
|
||||||
return result
|
return result
|
||||||
|
|
@ -55,6 +68,16 @@ async def suggest_addresses(
|
||||||
q: Annotated[str, Query(min_length=2, max_length=200, description="Запрос для автокомплита")],
|
q: Annotated[str, Query(min_length=2, max_length=200, description="Запрос для автокомплита")],
|
||||||
limit: Annotated[int, Query(ge=1, le=15)] = 8,
|
limit: Annotated[int, Query(ge=1, le=15)] = 8,
|
||||||
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
||||||
|
city_hint: Annotated[
|
||||||
|
str | None,
|
||||||
|
Query(
|
||||||
|
max_length=100,
|
||||||
|
description=(
|
||||||
|
"Город, если известен вызывающему (#2576) — без него подсказки "
|
||||||
|
"БОЛЬШЕ НЕ ограничиваются молчаливо Екатеринбургом."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
) -> SuggestResponse:
|
) -> SuggestResponse:
|
||||||
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
|
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
|
||||||
остаётся быстрым fast-path).
|
остаётся быстрым fast-path).
|
||||||
|
|
@ -66,8 +89,9 @@ async def suggest_addresses(
|
||||||
Пример:
|
Пример:
|
||||||
/api/v1/geocode/suggest?q=Малышева
|
/api/v1/geocode/suggest?q=Малышева
|
||||||
/api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет
|
/api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет
|
||||||
|
/api/v1/geocode/suggest?q=Ленина+1&city_hint=Нижний+Тагил
|
||||||
"""
|
"""
|
||||||
items = await suggest(q, db=db, limit=limit)
|
items = await suggest(q, db=db, limit=limit, city_hint=city_hint)
|
||||||
return SuggestResponse(
|
return SuggestResponse(
|
||||||
items=[
|
items=[
|
||||||
SuggestItem(
|
SuggestItem(
|
||||||
|
|
@ -100,11 +124,11 @@ class ReverseResponse(BaseModel):
|
||||||
precision: str = Field(
|
precision: str = Field(
|
||||||
...,
|
...,
|
||||||
description=(
|
description=(
|
||||||
"Yandex-style: exact/number/street/range/near/locality/other/cadastral. "
|
"exact/number/street/range/near/locality/other/cadastral. "
|
||||||
"Фронт двигает marker только если exact/number/cadastral."
|
"Фронт двигает marker только если exact/number/cadastral."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
provider: str = Field(..., description="cadastral | yandex | nominatim")
|
provider: str = Field(..., description="cadastral | nominatim")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/reverse", response_model=ReverseResponse)
|
@router.get("/reverse", response_model=ReverseResponse)
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,31 @@ Mounted at /api/v1/me; через Caddy `uri strip_prefix /trade-in` это ст
|
||||||
Caddy basic_auth пропускает `X-Authenticated-User: <username>` через
|
Caddy basic_auth пропускает `X-Authenticated-User: <username>` через
|
||||||
`header_up` в каждом reverse_proxy. Frontend дёргает /me чтобы понять
|
`header_up` в каждом reverse_proxy. Frontend дёргает /me чтобы понять
|
||||||
кому что показывать.
|
кому что показывать.
|
||||||
|
|
||||||
|
#2552: session-first. Валидная DB-session cookie (см. app.services.auth_session)
|
||||||
|
отдаёт scope из реестра людей (role/display_name/org/email) БЕЗ похода в
|
||||||
|
roles.yaml. Без cookie (или невалидная/истёкшая) — legacy X-Authenticated-User
|
||||||
|
путь, БЕЗ ИЗМЕНЕНИЙ (regression недопустим — существующие тесты держат его
|
||||||
|
бит-в-бит).
|
||||||
|
|
||||||
|
Сессия БД берётся у `identity_store.get_identity_db` (реестр), а не у
|
||||||
|
`app.core.db.get_db` (продуктовая БД): при `IDENTITY_STORE=auth` люди и сессии
|
||||||
|
живут в другой БД. В дефолтном режиме это ТОТ ЖЕ объект `Session`, что отдал бы
|
||||||
|
`get_db`, — поведение прода не меняется.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Header, HTTPException
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.auth import UserScope, get_user_scope
|
from app.core.auth import UserScope, get_user_scope
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.auth_session import get_db_role_scope, get_session_user
|
||||||
|
from app.services.identity_store import get_identity_db
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -25,13 +40,45 @@ router = APIRouter()
|
||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
async def me(
|
async def me(
|
||||||
|
request: Request,
|
||||||
|
db: Annotated[Session, Depends(get_identity_db)],
|
||||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||||
) -> UserScope:
|
) -> UserScope | dict[str, Any]:
|
||||||
"""Return the current user's RBAC scope (role + allowed/deny paths)."""
|
"""Return the current user's RBAC scope (role + allowed/deny paths).
|
||||||
|
|
||||||
|
Return type is a union (не только `UserScope`) — `UserScope.role` — это
|
||||||
|
`Literal["admin","pilot","analyst","expired"]` (legacy roles.yaml names),
|
||||||
|
а DB-роли (реестр: tradein_users.role / auth.users.role) —
|
||||||
|
`"admin"/"manager"/"employee"`. FastAPI
|
||||||
|
строит response-схему из return-аннотации; жёсткий `UserScope` завернул бы
|
||||||
|
"employee"/"manager" в ResponseValidationError. Итоговая JSON-форма
|
||||||
|
ОДИНАКОВАЯ (те же 8 ключей) для обеих веток.
|
||||||
|
"""
|
||||||
|
token = request.cookies.get(settings.session_cookie_name)
|
||||||
|
if token:
|
||||||
|
try:
|
||||||
|
session_user = get_session_user(db, token)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("me: session lookup failed")
|
||||||
|
session_user = None
|
||||||
|
if session_user is not None:
|
||||||
|
role = session_user["role"]
|
||||||
|
allowed_paths, deny_paths = get_db_role_scope(role)
|
||||||
|
return {
|
||||||
|
"username": session_user["username"],
|
||||||
|
"role": role,
|
||||||
|
"allowed_paths": allowed_paths,
|
||||||
|
"deny_paths": deny_paths,
|
||||||
|
"brand": None,
|
||||||
|
"display_name": session_user["display_name"],
|
||||||
|
"org": session_user["org_name"],
|
||||||
|
"email": session_user["email"],
|
||||||
|
}
|
||||||
|
|
||||||
if not x_authenticated_user:
|
if not x_authenticated_user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail="no authenticated user (Caddy basic_auth required)",
|
detail="no authenticated user (valid session required)",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
return get_user_scope(x_authenticated_user)
|
return get_user_scope(x_authenticated_user)
|
||||||
|
|
|
||||||
|
|
@ -29,20 +29,48 @@ support-моста (`app.services.tgbot.bridge`, data/sql/186_tg_support.sql).
|
||||||
`username` — thread_id для отправки не нужен вообще, поэтому эту БД-операцию
|
`username` — thread_id для отправки не нужен вообще, поэтому эту БД-операцию
|
||||||
можно безопасно отложить до после успешного sendMessage. Бонус: неудачная
|
можно безопасно отложить до после успешного sendMessage. Бонус: неудачная
|
||||||
отправка больше не создаёт тред.
|
отправка больше не создаёт тред.
|
||||||
|
|
||||||
|
Анонимная ветка (`/support/anon/*`, инцидент 2026-07-31)
|
||||||
|
-------------------------------------------------------
|
||||||
|
Ровно те же 4 действия, но БЕЗ авторизации — доступны с экрана входа. Причина:
|
||||||
|
после cutover'а на свою авторизацию (#2558) единственным каналом в поддержку был
|
||||||
|
чат ЗА логином, а самая частая причина писать в поддержку — как раз «не могу
|
||||||
|
войти». 2026-07-31 «Практика» весь день билась в форму (5 login_failed, 0
|
||||||
|
успешных) и достучаться из продукта не могла ничем.
|
||||||
|
|
||||||
|
Идентичность анонима — opaque-токен в httpOnly-куке (`_ANON_COOKIE_NAME`),
|
||||||
|
тред живёт в тех же `web_support_threads` под ключом `anon:<token>`. Двоеточие
|
||||||
|
делает коллизию с реальным логином структурно невозможной: `tradein_users`
|
||||||
|
допускает только `^[A-Za-z0-9._-]{3,64}$` (CHECK из миграции 193 + Pydantic),
|
||||||
|
двоеточия там быть не может — аноним НИКОГДА не попадёт в чужой тред и не
|
||||||
|
«станет» существующим юзером.
|
||||||
|
|
||||||
|
Изоляция тредов та же, что у авторизованной ветки, и по той же причине:
|
||||||
|
thread_id не принимается снаружи ни в каком виде, тред резолвится
|
||||||
|
ИСКЛЮЧИТЕЛЬНО из куки. Кука здесь — bearer-токен своего треда, поэтому
|
||||||
|
httpOnly+Secure+SameSite=Lax (как session-cookie) и `token_urlsafe(18)`
|
||||||
|
(144 бита) вместо чего-то угадываемого.
|
||||||
|
|
||||||
|
В Telegram-топик уходит НЕ сам токен, а `anon-<6 hex от sha256(токен)>`
|
||||||
|
(`_anon_display_id`): оператору нужен стабильный ярлык треда, а не bearer —
|
||||||
|
зеркало топика читают люди и пересылают дальше.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.ratelimit import SlidingWindowLimiter
|
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
||||||
from app.services.tgbot import web_support_storage as storage
|
from app.services.tgbot import web_support_storage as storage
|
||||||
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
|
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
|
||||||
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||||
|
|
@ -262,3 +290,204 @@ def mark_support_read(
|
||||||
storage.mark_read(db, thread_id=thread_id)
|
storage.mark_read(db, thread_id=thread_id)
|
||||||
db.commit()
|
db.commit()
|
||||||
return StatusOut()
|
return StatusOut()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Анонимная ветка — поддержка без входа (см. блок в докстринге модуля)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_ANON_COOKIE_NAME = "tradein_support_anon"
|
||||||
|
# 30 дней: тред должен пережить «напишу вечером — отвечут утром», но не жить вечно.
|
||||||
|
_ANON_COOKIE_MAX_AGE_S = 30 * 24 * 3600
|
||||||
|
# Двоеточие → структурная невозможность коллизии с реальным логином (докстринг).
|
||||||
|
_ANON_THREAD_PREFIX = "anon:"
|
||||||
|
# Форма того, что МЫ выдаём (`token_urlsafe(18)` → 24 символа из [A-Za-z0-9_-]).
|
||||||
|
# Кука клиент-контролируема: без этой проверки в ключ треда (а значит в SQL-параметр
|
||||||
|
# и в лог) уехала бы произвольная строка из браузера. Не матчится — считаем куку
|
||||||
|
# отсутствующей и выдаём новую, а не пытаемся «починить» присланное.
|
||||||
|
_ANON_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}\Z")
|
||||||
|
|
||||||
|
# Публичная ручка записи в общий Telegram-топик — поверхность для спама, которой у
|
||||||
|
# авторизованной ветки нет. Два независимых бюджета:
|
||||||
|
# 1) per-token (`_send_limiter`, 12/мин — тот же объект, ключи не пересекаются:
|
||||||
|
# анонимные начинаются с "anon:", что невозможно для username);
|
||||||
|
# 2) per-IP — именно он ловит обход ротацией куки (сбросил куку → новый токен →
|
||||||
|
# бюджет (1) снова пуст). Окно широкое и щедрое для живого диалога: реальный
|
||||||
|
# сценарий — «не могу войти, помогите», несколько сообщений подряд.
|
||||||
|
_ANON_IP_RATE_LIMIT = 10
|
||||||
|
_ANON_IP_RATE_WINDOW_S = 600.0
|
||||||
|
_anon_ip_limiter = SlidingWindowLimiter(limit=_ANON_IP_RATE_LIMIT, window_s=_ANON_IP_RATE_WINDOW_S)
|
||||||
|
|
||||||
|
|
||||||
|
def _anon_display_id(token: str) -> str:
|
||||||
|
"""Стабильный НЕсекретный ярлык треда для оператора — см. докстринг модуля.
|
||||||
|
|
||||||
|
sha256, а не префикс токена: префикс — это часть bearer'а, а зеркало уходит
|
||||||
|
в Telegram-топик, который читают люди и пересылают дальше.
|
||||||
|
"""
|
||||||
|
return f"anon-{hashlib.sha256(token.encode('utf-8')).hexdigest()[:6]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_anon_token(request: Request) -> str | None:
|
||||||
|
"""Токен из куки, если он валидной формы; иначе None (кука считается отсутствующей)."""
|
||||||
|
raw = request.cookies.get(_ANON_COOKIE_NAME)
|
||||||
|
if raw is None or not _ANON_TOKEN_RE.match(raw):
|
||||||
|
return None
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _anon_thread_key(token: str) -> str:
|
||||||
|
return f"{_ANON_THREAD_PREFIX}{token}"
|
||||||
|
|
||||||
|
|
||||||
|
def _set_anon_cookie(response: Response, token: str) -> None:
|
||||||
|
response.set_cookie(
|
||||||
|
key=_ANON_COOKIE_NAME,
|
||||||
|
value=token,
|
||||||
|
max_age=_ANON_COOKIE_MAX_AGE_S,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="lax",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/support/anon/messages", response_model=SupportMessageOut)
|
||||||
|
async def send_anon_support_message(
|
||||||
|
payload: SupportMessageInput,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> SupportMessageOut:
|
||||||
|
"""Сообщение в поддержку БЕЗ входа. Порядок операций — как в авторизованной
|
||||||
|
ветке (H1 в докстринге модуля): БД трогаем только после успешного sendMessage.
|
||||||
|
|
||||||
|
Кука выставляется тоже только на успехе — иначе первая же неудачная попытка
|
||||||
|
(бот не настроен / Telegram лёг) закрепляла бы за посетителем пустой тред.
|
||||||
|
"""
|
||||||
|
if not _bot_configured():
|
||||||
|
raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_TEXT)
|
||||||
|
|
||||||
|
token = _read_anon_token(request)
|
||||||
|
is_new_token = token is None
|
||||||
|
if token is None:
|
||||||
|
token = secrets.token_urlsafe(18)
|
||||||
|
thread_key = _anon_thread_key(token)
|
||||||
|
ip = _client_ip(request)
|
||||||
|
|
||||||
|
# Оба бюджета — non-destructive peek (review L3): неудачная отправка не
|
||||||
|
# должна стоить посетителю попытки. `.record()` только на успех, ниже.
|
||||||
|
for retry_after in (_send_limiter.retry_after(thread_key), _anon_ip_limiter.retry_after(ip)):
|
||||||
|
if retry_after is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Слишком много сообщений. Попробуйте позже.",
|
||||||
|
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||||
|
)
|
||||||
|
|
||||||
|
display_id = _anon_display_id(token)
|
||||||
|
client = TelegramClient(settings.telegram_bot_token)
|
||||||
|
try:
|
||||||
|
mirrored = await client.send_message(
|
||||||
|
chat_id=settings.telegram_support_chat_id,
|
||||||
|
text=_format_anon_mirror_text(display_id, payload.text),
|
||||||
|
message_thread_id=settings.telegram_support_topic_id or None,
|
||||||
|
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
||||||
|
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||||
|
)
|
||||||
|
except TelegramApiError:
|
||||||
|
# Ни текст сообщения (ПДн), ни токен (bearer треда) в лог не попадают.
|
||||||
|
logger.exception(
|
||||||
|
"web support (anon): не удалось отправить зеркало в топик (%s)", display_id
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
|
||||||
|
|
||||||
|
_send_limiter.record(thread_key)
|
||||||
|
_anon_ip_limiter.record(ip)
|
||||||
|
|
||||||
|
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
|
||||||
|
if topic_message_id is None:
|
||||||
|
logger.warning(
|
||||||
|
"web support (anon): Telegram sendMessage не вернул message_id (%s) — "
|
||||||
|
"ответ оператора на это сообщение не будет смаршрутизирован",
|
||||||
|
display_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
thread_id = storage.get_or_create_thread(db, thread_key)
|
||||||
|
row = storage.record_inbound(
|
||||||
|
db,
|
||||||
|
thread_id=thread_id,
|
||||||
|
text_body=payload.text,
|
||||||
|
topic_message_id=topic_message_id,
|
||||||
|
support_chat_id=settings.telegram_support_chat_id,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if is_new_token:
|
||||||
|
_set_anon_cookie(response, token)
|
||||||
|
logger.info("web support (anon): message sent %s thread_id=%d", display_id, thread_id)
|
||||||
|
return SupportMessageOut(**row)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_anon_mirror_text(display_id: str, message_text: str) -> str:
|
||||||
|
"""Помечает зеркало как пришедшее с сайта ОТ НЕЗАЛОГИНЕННОГО посетителя.
|
||||||
|
|
||||||
|
Оператору это ключевой контекст: у такого обращения нет аккаунта, по которому
|
||||||
|
можно посмотреть историю, и самая вероятная причина написать — как раз
|
||||||
|
невозможность войти (инцидент 2026-07-31).
|
||||||
|
"""
|
||||||
|
return f"[С САЙТА · БЕЗ ВХОДА] {display_id}:\n{message_text}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/support/anon/messages", response_model=list[SupportMessageOut])
|
||||||
|
def list_anon_support_messages(
|
||||||
|
request: Request,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
since: Annotated[int, Query(ge=0)] = 0,
|
||||||
|
) -> list[SupportMessageOut]:
|
||||||
|
"""Свой тред по куке. Нет куки / нет треда → пустой список, НЕ 401: виджет
|
||||||
|
поллит эту ручку и до первого сообщения, 401 там был бы ложной ошибкой.
|
||||||
|
|
||||||
|
Sync `def` (review M3) — см. `list_support_messages`.
|
||||||
|
"""
|
||||||
|
token = _read_anon_token(request)
|
||||||
|
if token is None:
|
||||||
|
return []
|
||||||
|
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
|
||||||
|
if thread_id is None:
|
||||||
|
return []
|
||||||
|
rows = storage.list_messages(
|
||||||
|
db, thread_id=thread_id, since_id=since, limit=_LIST_MESSAGES_LIMIT
|
||||||
|
)
|
||||||
|
return [SupportMessageOut(**r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/support/anon/unread", response_model=UnreadOut)
|
||||||
|
def get_anon_support_unread(
|
||||||
|
request: Request,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> UnreadOut:
|
||||||
|
"""Sync `def` (review M3) — см. `list_support_messages`."""
|
||||||
|
token = _read_anon_token(request)
|
||||||
|
if token is None:
|
||||||
|
return UnreadOut(unread=0)
|
||||||
|
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
|
||||||
|
if thread_id is None:
|
||||||
|
return UnreadOut(unread=0)
|
||||||
|
return UnreadOut(unread=storage.count_unread(db, thread_id=thread_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/support/anon/read", response_model=StatusOut)
|
||||||
|
def mark_anon_support_read(
|
||||||
|
request: Request,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> StatusOut:
|
||||||
|
"""Sync `def` (review M3) — см. `list_support_messages`."""
|
||||||
|
token = _read_anon_token(request)
|
||||||
|
if token is None:
|
||||||
|
return StatusOut()
|
||||||
|
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
|
||||||
|
if thread_id is not None:
|
||||||
|
storage.mark_read(db, thread_id=thread_id)
|
||||||
|
db.commit()
|
||||||
|
return StatusOut()
|
||||||
|
|
|
||||||
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:
|
if not x_authenticated_user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail="no authenticated user (Caddy basic_auth required)",
|
detail="no authenticated user (valid session required)",
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.core.auth import get_role
|
from app.core.auth import get_role
|
||||||
|
|
@ -702,7 +702,7 @@ def estimate_history(
|
||||||
if not x_authenticated_user:
|
if not x_authenticated_user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail="no authenticated user (Caddy basic_auth required)",
|
detail="no authenticated user (valid session required)",
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.core.auth import get_role
|
from app.core.auth import get_role
|
||||||
|
|
@ -764,7 +764,15 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
|
||||||
trade_in_estimates с непустым address; NULL при отсутствии адресов.
|
trade_in_estimates с непустым address; NULL при отсутствии адресов.
|
||||||
NB: это честный best-effort по persisted оценкам, а не hit-rate реального
|
NB: это честный best-effort по persisted оценкам, а не hit-rate реального
|
||||||
кэша (отдельного счётчика попаданий не ведём).
|
кэша (отдельного счётчика попаданий не ведём).
|
||||||
|
|
||||||
|
#2660: listings_active сам по себе врал — «активно» на проде не означает
|
||||||
|
«живо» (деактиватор протухших покрывает не все источники). Рядом отдаём
|
||||||
|
listings_active_stale — сколько из них не виделись listings_stale_days
|
||||||
|
(= LISTINGS_FRESH_DAYS эстиматора; прод 2026-08-05: 37 900 активных при
|
||||||
|
20 935 не виденных 14+ дней). Счётчик не прячем, а разделяем.
|
||||||
"""
|
"""
|
||||||
|
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||||
|
|
||||||
row = (
|
row = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
@ -774,6 +782,10 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
|
||||||
(SELECT count(*) FROM geocode_cache WHERE expires_at > NOW())
|
(SELECT count(*) FROM geocode_cache WHERE expires_at > NOW())
|
||||||
AS geocode_cache_fresh,
|
AS geocode_cache_fresh,
|
||||||
(SELECT count(*) FROM listings WHERE is_active) AS listings_active,
|
(SELECT count(*) FROM listings WHERE is_active) AS listings_active,
|
||||||
|
(SELECT count(*) FROM listings
|
||||||
|
WHERE is_active
|
||||||
|
AND last_seen_at <= NOW() - (:fresh_days || ' days')::interval)
|
||||||
|
AS listings_active_stale,
|
||||||
(SELECT max(scraped_at) FROM listings) AS listings_last_scraped,
|
(SELECT max(scraped_at) FROM listings) AS listings_last_scraped,
|
||||||
(SELECT count(*) FROM deals) AS deals,
|
(SELECT count(*) FROM deals) AS deals,
|
||||||
(SELECT count(*) FROM gendesign_cad_buildings) AS cad_buildings,
|
(SELECT count(*) FROM gendesign_cad_buildings) AS cad_buildings,
|
||||||
|
|
@ -790,12 +802,15 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
|
||||||
WHERE address IS NOT NULL AND address <> ''
|
WHERE address IS NOT NULL AND address <> ''
|
||||||
) t) AS repeat_address_pct
|
) t) AS repeat_address_pct
|
||||||
"""
|
"""
|
||||||
)
|
),
|
||||||
|
{"fresh_days": LISTINGS_FRESH_DAYS},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.fetchone()
|
.fetchone()
|
||||||
)
|
)
|
||||||
return dict(row) if row else {}
|
# Порог отдаём рядом с числом — чтобы UI подписывал «не виделись N дней»,
|
||||||
|
# а не заводил второе определение свежести у себя.
|
||||||
|
return (dict(row) | {"listings_stale_days": LISTINGS_FRESH_DAYS}) if row else {}
|
||||||
|
|
||||||
|
|
||||||
# ── Stage 4a: house info + IMV benchmark для UI ───────────────────────────────
|
# ── Stage 4a: house info + IMV benchmark для UI ───────────────────────────────
|
||||||
|
|
@ -1820,6 +1835,108 @@ def get_street_deals(
|
||||||
|
|
||||||
# ── Sales vs Listings (PR K — Foundation Phase 1 of issue #564) ──────────────
|
# ── Sales vs Listings (PR K — Foundation Phase 1 of issue #564) ──────────────
|
||||||
|
|
||||||
|
# #2666 гейт правдоподобия на «медианный торг». Пейринг ДКП↔объявление идёт по
|
||||||
|
# УЛИЦЕ без номера дома (data_quality="street_only", ADR #721): на длинной улице
|
||||||
|
# сделка и объявление могут стоять в разных домах и разных ценовых классах, и
|
||||||
|
# тогда discount_pct — не торг, а разница между двумя чужими друг другу лотами.
|
||||||
|
# Гард #2660 (миграция 211) убрал предвзятые пары «вторичка ↔ новостройка» и тем
|
||||||
|
# самым сделал остаток артефактов ВИДНЫМ: по `%Космонавтов%` 2-комн. медиана
|
||||||
|
# уехала с −11.9% на +36.4%, т.е. пользователю написали бы «продали на 36%
|
||||||
|
# дороже, чем просили». Здесь не чиним пейринг (это ADR-уровень), а перестаём
|
||||||
|
# показывать число, которому нельзя верить.
|
||||||
|
#
|
||||||
|
# Пороги подобраны по проду 2026-08-05 (симуляция эндпоинта на 238 РЕАЛЬНЫХ
|
||||||
|
# пользовательских запросах из trade_in_estimates — тот же address/area/rooms,
|
||||||
|
# что уходил в виджет; 128 из них дали хотя бы одну пару):
|
||||||
|
#
|
||||||
|
# MIN_PAIRS = 10 — бутстрап по 12 «плотным» группам (n ≥ 60 пар): из полной
|
||||||
|
# выборки берём подвыборку размера k и смотрим, насколько медиана подвыборки
|
||||||
|
# отклоняется от полной. p90 |отклонения|: k=5 → 18.8 п.п., k=10 → 12.0,
|
||||||
|
# k=15 → 9.9, k=20 → 8.2. Кривая ломается ровно на 10 (5→10 даёт −6.8 п.п.
|
||||||
|
# шума, 10→15 уже только −2.1, а каждые +5 к порогу стоят ещё ~8-10% улиц).
|
||||||
|
# Совпадает с уже принятым в продукте порогом малой выборки
|
||||||
|
# settings.sell_time_sensitivity_min_n_lots = 10.
|
||||||
|
#
|
||||||
|
# SANE_MIN/MAX = [−60%, +20%] — асимметричны намеренно, у сторон разная природа:
|
||||||
|
# ВЕРХ. В наблюдаемом распределении 128 групп положительный хвост РАЗОРВАН:
|
||||||
|
# +11.1, +10.8, +16.9 — и дальше пусто до +33.7, +34.2, +34.6, +39.0, +52.5,
|
||||||
|
# +70.2, +81.5, +103.1. Отсечка +20% попадает в пустой промежуток, т.е. режет
|
||||||
|
# отдельный кластер, а не край континуума. Сверху её подпирает рынок: ни один
|
||||||
|
# городской бакет asking_to_sold_ratios не даёт плюса вообще (max ratio 0.9132
|
||||||
|
# = −8.7% торга), так что «продали на +20% дороже ask» уже вдвое дальше любого
|
||||||
|
# рыночно объяснимого плюса.
|
||||||
|
# НИЗ. Разрыва нет — минус идёт сплошняком от −5% до −87%, и это ожидаемо:
|
||||||
|
# у большого отрицательного торга есть механизм (занижение цены в ДКП), в
|
||||||
|
# отличие от большого плюса. Поэтому граница грубая, «заведомо не рынок»:
|
||||||
|
# худший городской бакет (студии, ratio 0.7623) = −23.8%, −60% в 2.5 раза
|
||||||
|
# глубже. Режет 6 групп из 128 (−87 … −64).
|
||||||
|
#
|
||||||
|
# Цена гейта на проде: из 128 групп с парами число сохраняют 64 (50%), 59 (46%)
|
||||||
|
# теряют его по «мало пар» и ещё 5 (4%) — по диапазону. Виджет при этом остаётся:
|
||||||
|
# сделки, медиана ₽/м², диапазон и сами пары считаются мимо гейта, гаснет ровно
|
||||||
|
# строка «медианный торг», и вместо неё уходит median_discount_explanation.
|
||||||
|
#
|
||||||
|
# MIN_DISTINCT_LISTINGS = 2 (#2672) — ПАРЫ НЕ ЯВЛЯЮТСЯ НЕЗАВИСИМЫМИ НАБЛЮДЕНИЯМИ,
|
||||||
|
# и MIN_PAIRS этого не видит. DISTINCT ON подбирает по объявлению на сделку, но
|
||||||
|
# ОДНО объявление переиспользуется на многих сделках улицы: у показываемых групп
|
||||||
|
# медиана — 18 сделок на одно различное объявление. До этого порога из 64
|
||||||
|
# показываемых чисел 22 (34%) стояли на ОДНОМ объявлении (худший живой кейс —
|
||||||
|
# `Белинского` 1-комн.: 50 пар, 1 объявление, −50.6%), 50 (78%) — меньше чем на
|
||||||
|
# трёх. «50 пар» там означало не 50 наблюдений рынка, а 50 сделок, поделённых на
|
||||||
|
# ОДНУ цену предложения: число говорило о том, чем эта конкретная квартира
|
||||||
|
# отличалась от типичной сделки, а не о торге на улице.
|
||||||
|
#
|
||||||
|
# Почему именно 2, и почему порог здесь обоснован ИНАЧЕ, чем MIN_PAIRS. Разброс
|
||||||
|
# со стороны объявлений мерили джекнайфом (выкинуть одно объявление, 45 групп,
|
||||||
|
# 118 повторов): p50 3.6, p90 18.8, max 80.4 п.п. — тот же порядок, что и шум
|
||||||
|
# при 5 парах, который при выборе MIN_PAIRS сочли неприемлемым. Но на группах с
|
||||||
|
# ОДНИМ объявлением ни джекнайф, ни кластерный бутстрап не дают числа вообще:
|
||||||
|
# выкидывать нечего, пересэмплировать нечего, отклонение тождественно 0.
|
||||||
|
# Их «нулевая ошибка» — не малая ошибка, а отсутствие измерения, и агрегат по
|
||||||
|
# всем 64 группам от их добавления УЛУЧШАЛСЯ (кластер-бутстрап p90 16.0 → 11.5),
|
||||||
|
# т.е. метрика становилась тем зеленее, чем больше в ней неизмеримого. Поэтому
|
||||||
|
# 2 — не статистический выбор, а граница выразимости: ниже неё нет выборки, о
|
||||||
|
# разбросе которой можно спрашивать, и показывать число = фабриковать точность.
|
||||||
|
# Выше 2 порог уже статистический, и данные (прод 2026-08-06, те же 128 групп)
|
||||||
|
# говорят, что он должен быть выше — но ценой почти всей витрины:
|
||||||
|
# объявлений ≥ 2 → 42 группы (33%), джекнайф p90 17.4;
|
||||||
|
# объявлений ≥ 3 → 14 групп (11%), p90 10.9 (планка MIN_PAIRS — 12.0);
|
||||||
|
# объявлений ≥ 4 → 7 групп ( 5%), p90 5.5.
|
||||||
|
# Порог 3 попадал бы в принятую планку шума, но оставляет 11% витрины и всё
|
||||||
|
# равно не делает число защищаемым (ошибка со стороны СДЕЛОК никуда не делась и
|
||||||
|
# складывается с ней). Выбирать между «9% покрытия» и «выключить строку» —
|
||||||
|
# решение владельца, не гейта; здесь снимается ровно то, что не является
|
||||||
|
# наблюдением рынка в принципе. Понижать MIN_PAIRS в компенсацию нельзя:
|
||||||
|
# вернувшиеся группы стоят на тех же одном-двух объявлениях (ложная точность).
|
||||||
|
#
|
||||||
|
# SANE_MIN ужесточён −60% → −35% (#2672). Исходное подозрение «−60% режет живой
|
||||||
|
# рынок» проверено и ОПРОВЕРГНУТО: до −60% проходило всё, законный механизм
|
||||||
|
# большого минуса (занижение цены в ДКП) сохранён целиком. Ошибка была в другую
|
||||||
|
# сторону — граница пропускала неправдоподобный отрицательный хвост: 26 из 64
|
||||||
|
# показываемых чисел (41%) лежали ниже −23.7%, худшего объяснимого рынком
|
||||||
|
# бакета (asking_to_sold_ratios: студии, ratio 0.7634, 1 519 сделок; ни один
|
||||||
|
# бакет не глубже), самое глубокое показываемое — −58.5%. Мы гасили «+34%» и
|
||||||
|
# показывали «−58.5%», полученный из ТОГО ЖЕ артефакта пейринга. Асимметрия
|
||||||
|
# работала против пользователя: абсурдный плюс сам себя опровергает («продали
|
||||||
|
# дороже, чем просили» — виджету просто не поверят), абсурдный минус выглядит
|
||||||
|
# правдоподобно и подталкивает продавца к выводу, что его улица торгуется за
|
||||||
|
# полцены. −35% ≈ в 1.5 раза глубже худшего рыночного бакета (запас на занижение
|
||||||
|
# в ДКП сохранён) и попадает в разрыв наблюдаемого распределения −37.6 → −33.9.
|
||||||
|
# Живой кейс из ревью: Серов, Ленина 163, 2-комн., 21 пара → −46.5% показывался.
|
||||||
|
#
|
||||||
|
# ПОТОЛОК ГЕЙТА (знать до следующей правки — здесь НЕ чинится):
|
||||||
|
# 1. Пейринг по УЛИЦЕ, а не по дому — корень всего перечисленного (ADR #721).
|
||||||
|
# Гейт по различным объявлениям честный промежуточный шаг, а не решение:
|
||||||
|
# он убирает числа, которые не являются наблюдением, но оставшиеся всё ещё
|
||||||
|
# сравнивают сделку в одном доме с объявлением в другом.
|
||||||
|
# 2. Поштучный discount_pct в таблице пар НЕ гасится, когда сводное число
|
||||||
|
# погашено (#2672 п.3, фронт): под погашенной медианой видны строки +76%,
|
||||||
|
# +73% против той же одной цены предложения. Отдельная задача.
|
||||||
|
SALES_VS_LISTINGS_MIN_PAIRS = 10
|
||||||
|
SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS = 2
|
||||||
|
SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT = -35.0
|
||||||
|
SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT = 20.0
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sales-vs-listings", response_model=SalesVsListingsResponse)
|
@router.get("/sales-vs-listings", response_model=SalesVsListingsResponse)
|
||||||
def get_sales_vs_listings(
|
def get_sales_vs_listings(
|
||||||
|
|
@ -1845,7 +1962,7 @@ def get_sales_vs_listings(
|
||||||
|
|
||||||
Per-street view: Росреестр open dataset агрегирует адреса до улицы.
|
Per-street view: Росреестр open dataset агрегирует адреса до улицы.
|
||||||
"""
|
"""
|
||||||
from app.services.estimator import _percentile, extract_street_name
|
from app.services.estimator import _percentile, _resolve_target_city, extract_street_name
|
||||||
|
|
||||||
def _empty(reason_street: str | None = None) -> SalesVsListingsResponse:
|
def _empty(reason_street: str | None = None) -> SalesVsListingsResponse:
|
||||||
return SalesVsListingsResponse(
|
return SalesVsListingsResponse(
|
||||||
|
|
@ -1866,6 +1983,15 @@ def get_sales_vs_listings(
|
||||||
logger.warning("sales-vs-listings: cannot extract street from %r", address)
|
logger.warning("sales-vs-listings: cannot extract street from %r", address)
|
||||||
return _empty()
|
return _empty()
|
||||||
|
|
||||||
|
# #2583 H4 city-scope (зеркало /street-deals #C1, trade_in.py:1717): без него
|
||||||
|
# street_pattern матчит одноимённые улицы ЛЮБОГО города обл.66 на ОБЕИХ сторонах
|
||||||
|
# JOIN (deals.address / listings.address хранят "<Город>, <Улица>") — прод-аудит
|
||||||
|
# показал 49% явно чужого города + 50% NULL-city listings для проверенных стритов,
|
||||||
|
# медианный discount_pct уезжал в -60%+ на смеси рынков. target_city резолвится тем
|
||||||
|
# же словарём (~30 городов обл.66), что и street-deals; None (адрес вне словаря,
|
||||||
|
# известная H1) → фильтр не применяется на TVF-стороне (см. миграцию 205).
|
||||||
|
target_city = _resolve_target_city(address)
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
@ -1882,7 +2008,8 @@ def get_sales_vs_listings(
|
||||||
CAST(:rooms AS integer),
|
CAST(:rooms AS integer),
|
||||||
CAST(:window_days AS integer),
|
CAST(:window_days AS integer),
|
||||||
CAST(:area_tolerance AS numeric),
|
CAST(:area_tolerance AS numeric),
|
||||||
CAST(:period_months AS integer)
|
CAST(:period_months AS integer),
|
||||||
|
CAST(:target_city AS text)
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -1893,6 +2020,7 @@ def get_sales_vs_listings(
|
||||||
"window_days": window_days,
|
"window_days": window_days,
|
||||||
"area_tolerance": area_tolerance,
|
"area_tolerance": area_tolerance,
|
||||||
"period_months": period_months,
|
"period_months": period_months,
|
||||||
|
"target_city": target_city,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
|
|
@ -1948,12 +2076,69 @@ def get_sales_vs_listings(
|
||||||
|
|
||||||
discounts = sorted(p.discount_pct for p in pairs if p.discount_pct is not None)
|
discounts = sorted(p.discount_pct for p in pairs if p.discount_pct is not None)
|
||||||
median_discount = round(_percentile(discounts, 0.5), 2) if discounts else None
|
median_discount = round(_percentile(discounts, 0.5), 2) if discounts else None
|
||||||
|
# #2672: сколько РАЗЛИЧНЫХ объявлений стоит за этими парами. len(discounts)
|
||||||
|
# считает сделки, а не наблюдения рынка — одно объявление попадает в пару
|
||||||
|
# к десяткам сделок улицы (см. шапку секции).
|
||||||
|
n_distinct_listings = len(
|
||||||
|
{p.listing_id for p in pairs if p.discount_pct is not None and p.listing_id is not None}
|
||||||
|
)
|
||||||
|
|
||||||
|
# #2666 гейт правдоподобия (обоснование порогов — в шапке секции). Число либо
|
||||||
|
# отдаётся, либо гасится с объяснением ПОЧЕМУ — молча пустое поле пользователь
|
||||||
|
# прочитает как поломку, а не как честность.
|
||||||
|
median_discount_explanation: str | None = None
|
||||||
|
if median_discount is not None:
|
||||||
|
if len(discounts) < SALES_VS_LISTINGS_MIN_PAIRS:
|
||||||
|
# Формулировка — ФАКТ про выборку, а не обещание надёжности выше
|
||||||
|
# порога: 10 пар тоже не гарантия (см. «ПОТОЛОК ГЕЙТА» выше —
|
||||||
|
# пары псевдореплики), обещать «от 10 надёжно» мы не вправе.
|
||||||
|
median_discount_explanation = (
|
||||||
|
f"Медианный торг не показываем: пар «сделка ↔ объявление» всего "
|
||||||
|
f"{len(discounts)} — на такой выборке медиана гуляет на десятки "
|
||||||
|
f"процентных пунктов."
|
||||||
|
)
|
||||||
|
elif n_distinct_listings < SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS:
|
||||||
|
# Числа стоят В КОНЦЕ клауз намеренно: «различных объявлений всего 1»
|
||||||
|
# грамматично при любом значении, «на 1 различных объявлений» — нет.
|
||||||
|
median_discount_explanation = (
|
||||||
|
f"Медианный торг не показываем: сделок {len(discounts)}, а разных "
|
||||||
|
f"объявлений для сравнения всего {n_distinct_listings} — такой процент "
|
||||||
|
f"говорит о цене одной конкретной квартиры, а не о торге на улице."
|
||||||
|
)
|
||||||
|
elif not (
|
||||||
|
SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT
|
||||||
|
<= median_discount
|
||||||
|
<= SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT
|
||||||
|
):
|
||||||
|
# Типографский минус (U+2212) — как в fmtDiscount на фронте.
|
||||||
|
shown = f"{median_discount:+.1f}".replace("-", "−")
|
||||||
|
# Про «пары строятся по улице, а не по дому» здесь НЕ пишем: ровно
|
||||||
|
# следующим блоком это говорит street_only-дисклеймер (карточка) /
|
||||||
|
# хвост note (v2-mappers). Проверено скриншотом — две формулировки
|
||||||
|
# подряд читались как стена текста.
|
||||||
|
median_discount_explanation = (
|
||||||
|
f"Медианный торг не показываем: расчёт дал неправдоподобное значение "
|
||||||
|
f"({shown}%) — такого торга на рынке не бывает."
|
||||||
|
)
|
||||||
|
if median_discount_explanation is not None:
|
||||||
|
logger.info(
|
||||||
|
"sales-vs-listings: median_discount gated street=%r rooms=%d "
|
||||||
|
"n_pairs=%d distinct_listings=%d value=%+.2f%%",
|
||||||
|
street_name,
|
||||||
|
rooms,
|
||||||
|
len(discounts),
|
||||||
|
n_distinct_listings,
|
||||||
|
median_discount,
|
||||||
|
)
|
||||||
|
median_discount = None
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"sales-vs-listings: street=%r deals=%d with_listings=%d linkage=%.1f%% median_disc=%s",
|
"sales-vs-listings: street=%r deals=%d with_listings=%d distinct_listings=%d "
|
||||||
|
"linkage=%.1f%% median_disc=%s",
|
||||||
street_name,
|
street_name,
|
||||||
total_deals,
|
total_deals,
|
||||||
deals_with_listings,
|
deals_with_listings,
|
||||||
|
n_distinct_listings,
|
||||||
linkage_rate_pct,
|
linkage_rate_pct,
|
||||||
f"{median_discount:+.2f}%" if median_discount is not None else "n/a",
|
f"{median_discount:+.2f}%" if median_discount is not None else "n/a",
|
||||||
)
|
)
|
||||||
|
|
@ -1967,6 +2152,7 @@ def get_sales_vs_listings(
|
||||||
deals_with_listings=deals_with_listings,
|
deals_with_listings=deals_with_listings,
|
||||||
linkage_rate_pct=linkage_rate_pct,
|
linkage_rate_pct=linkage_rate_pct,
|
||||||
median_discount_pct=median_discount,
|
median_discount_pct=median_discount,
|
||||||
|
median_discount_explanation=median_discount_explanation,
|
||||||
# street_sales_vs_listings матчит по УЛИЦЕ (не по дому, #721 ADR) →
|
# street_sales_vs_listings матчит по УЛИЦЕ (не по дому, #721 ADR) →
|
||||||
# даже при deals_with_listings>0 это street-level, не house. house_linked НЕ emit'им.
|
# даже при deals_with_listings>0 это street-level, не house. house_linked НЕ emit'им.
|
||||||
data_quality="street_only" if total_deals > 0 else "no_data",
|
data_quality="street_only" if total_deals > 0 else "no_data",
|
||||||
|
|
|
||||||
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."""
|
"""Минимальный settings для standalone trade-in MVP."""
|
||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field, SecretStr, field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
# ── Дефолтные части DSN БД `auth` (общий реестр людей, эпик «единый вход») ──────
|
||||||
|
# Вынесены константами, потому что используются ДВАЖДЫ: как `Field(default=...)`
|
||||||
|
# и как запасное значение, если переменная окружения задана пустой строкой
|
||||||
|
# (`AUTH_DB_HOST=` в .env.runtime не должен давать DSN вида `...@:5432/auth`).
|
||||||
|
#
|
||||||
|
# ⚠️ ХОСТ — главная ловушка. Внутри стека «Меры» имя `postgres` резолвится в ЕЁ
|
||||||
|
# СОБСТВЕННЫЙ контейнер: tradein-mvp/docker-compose.prod.yml объявляет сервис
|
||||||
|
# `postgres` (container_name `tradein-postgres`, сети `tradein-net` +
|
||||||
|
# `gendesign_shared`) и собирает им продуктовый DATABASE_URL —
|
||||||
|
# `postgresql+psycopg://...@postgres:5432/tradein`. БД `auth` живёт НЕ там, а на
|
||||||
|
# постгресе главного стека: корневой docker-compose.prod.yml вешает своему
|
||||||
|
# сервису `postgres` в сети `shared` (external, name `gendesign_shared`) алиас
|
||||||
|
# `gendesign-postgres`. tradein-backend к `gendesign_shared` подписан, поэтому
|
||||||
|
# `gendesign-postgres:5432` из него резолвится, а `postgres:5432` увело бы в
|
||||||
|
# чужую (свою же продуктовую) БД — там ни роли auth_app, ни таблиц реестра.
|
||||||
|
# Порт 5432 — ВНУТРИСЕТЕВОЙ порт контейнера; публикация `127.0.0.1:5432:5432` в
|
||||||
|
# корневом compose существует только ради SSH-туннеля с хоста и к этому пути
|
||||||
|
# отношения не имеет.
|
||||||
|
_AUTH_DB_DEFAULT_HOST = "gendesign-postgres"
|
||||||
|
_AUTH_DB_DEFAULT_PORT = 5432
|
||||||
|
_AUTH_DB_DEFAULT_NAME = "auth"
|
||||||
|
# Роль приложения из data/sql/auth/002_auth_app_role.sql (least privilege).
|
||||||
|
_AUTH_DB_DEFAULT_USER = "auth_app"
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
@ -49,11 +74,226 @@ class Settings(BaseSettings):
|
||||||
default="", validation_alias="TRADEIN_INTERNAL_AUTH_SECRET"
|
default="", validation_alias="TRADEIN_INTERNAL_AUTH_SECRET"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/
|
# ── #2550: DB-auth foundation (bcrypt password hashing + session cookie) ────
|
||||||
# backfill_house_coords.py + audit_address_mismatch.py + main backend
|
# Подготовительные поля для #2549 (эпик). Enforcement непустого session_secret
|
||||||
# OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F).
|
# (fail-fast при пустом значении в prod) добавится в #2552 — здесь дефолт
|
||||||
yandex_geocoder_api_key: str | None = None # 25K req/day free после регистрации
|
# намеренно пустой, чтобы прод-контейнер не падал на старте до того как
|
||||||
yandex_suggest_key: str | None = None # для frontend autocomplete (proxy через backend)
|
# секрет проставлен в .env.runtime. ENV: SESSION_SECRET.
|
||||||
|
session_secret: str = Field(default="", validation_alias="SESSION_SECRET")
|
||||||
|
# Имя cookie для DB-based сессии (отдельно от Caddy basic_auth / trusted-header).
|
||||||
|
session_cookie_name: str = Field(
|
||||||
|
default="tradein_session", validation_alias="SESSION_COOKIE_NAME"
|
||||||
|
)
|
||||||
|
# TTL сессии в часах. Дефолт 720ч (30 дней).
|
||||||
|
session_ttl_hours: int = Field(default=720, validation_alias="SESSION_TTL_HOURS")
|
||||||
|
# "dual" — переходный режим (Caddy trusted-header ИЛИ DB-сессия оба валидны);
|
||||||
|
# "db_only" — только DB-сессия (Caddy basic_auth убран). Переключение — #2552+.
|
||||||
|
auth_mode: Literal["dual", "db_only"] = Field(default="dual", validation_alias="AUTH_MODE")
|
||||||
|
# Rate-limit на /login: не более login_rate_limit попыток за
|
||||||
|
# login_rate_limit_window_s секунд на ключ (обычно IP или username).
|
||||||
|
login_rate_limit: int = Field(default=5, validation_alias="LOGIN_RATE_LIMIT")
|
||||||
|
login_rate_limit_window_s: int = Field(
|
||||||
|
default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S"
|
||||||
|
)
|
||||||
|
# Глобальный (независимый от IP) счётчик неудачных входов НА ИМЯ (#2571).
|
||||||
|
# Лимит выше по паре (username, IP) распределённый перебор обходит: с каждого
|
||||||
|
# нового адреса ему дают свежие login_rate_limit попыток. Здесь ключ — ТОЛЬКО
|
||||||
|
# имя, поэтому попытки со всех адресов складываются.
|
||||||
|
#
|
||||||
|
# Превышение порога НЕ блокирует учётку (это был бы вектор DoS против
|
||||||
|
# конкретного человека — злоумышленник выключал бы чужой вход по своему
|
||||||
|
# желанию), а растит задержку ответа: 1с, 2с, 4с… до потолка. Порог 20/час
|
||||||
|
# выбран так, чтобы живой человек с опечатками до него не доходил.
|
||||||
|
login_username_fail_threshold: int = Field(
|
||||||
|
default=20, validation_alias="LOGIN_USERNAME_FAIL_THRESHOLD"
|
||||||
|
)
|
||||||
|
login_username_fail_window_s: int = Field(
|
||||||
|
default=3600, validation_alias="LOGIN_USERNAME_FAIL_WINDOW_S"
|
||||||
|
)
|
||||||
|
# Потолок задержки одного ответа. Держим невысоким сознательно: задержка —
|
||||||
|
# это ещё и цена, которую платит легитимный владелец имени, пока его
|
||||||
|
# перебирают. 8с ощутимо режут перебор, но не выглядят как «сайт лёг».
|
||||||
|
login_username_throttle_max_delay_s: float = Field(
|
||||||
|
default=8.0, validation_alias="LOGIN_USERNAME_THROTTLE_MAX_DELAY_S"
|
||||||
|
)
|
||||||
|
# ── #2665: проверка пароля вне событийного цикла + СОЗНАТЕЛЬНЫЙ потолок ────
|
||||||
|
# Замер в прод-контейнере 2026-08-06: bcrypt cost 12 (все живые хеши —
|
||||||
|
# `$2b$12$`) = 282 мс медиана. Пока `verify_password` звался прямо в
|
||||||
|
# `async def login`, эти 282 мс были простоем ВСЕГО API, и они же были
|
||||||
|
# единственным настоящим потолком темпа логинов — замерено 3.6 попытки/с при
|
||||||
|
# стойле событийного цикла до 836 мс. Обе половины чинятся вместе, см.
|
||||||
|
# `app.core.password.verify_password_bounded`.
|
||||||
|
#
|
||||||
|
# `workers` — это и есть потолок темпа: не больше workers/282мс проверок в
|
||||||
|
# секунду, сколько бы соединений ни пришло. Дефолт 1 выбран так, чтобы
|
||||||
|
# ПОСЛЕ выноса в пул потолок остался тем же (~3.5/с), что случайно давала
|
||||||
|
# блокировка цикла: вынос не должен ускорять перебор. Поднимать имеет смысл
|
||||||
|
# только вместе с осознанным ответом «во сколько раз мы согласны ускорить
|
||||||
|
# перебор ради параллельных входов».
|
||||||
|
# ge=1: 0 или -1 роняют ThreadPoolExecutor прямо НА ИМПОРТЕ («max_workers must
|
||||||
|
# be greater than 0») — контейнер уходит в crash-loop, и причина видна только
|
||||||
|
# в трейсбеке старта. Пусть отказ будет на валидации настроек, с именем поля.
|
||||||
|
login_password_verify_workers: int = Field(
|
||||||
|
default=1, ge=1, validation_alias="LOGIN_PASSWORD_VERIFY_WORKERS"
|
||||||
|
)
|
||||||
|
# Сколько запросов одновременно допускаются к проверке (считая тех, кто ждёт
|
||||||
|
# очереди в пуле). Сверх — сразу 429, без ожидания. Не режет темп (его режут
|
||||||
|
# workers), а держит конечной ОЧЕРЕДЬ: каждый ждущий запрос удерживает
|
||||||
|
# соединение к БД (сессия реестра открыта после SELECT в
|
||||||
|
# `get_user_by_username`), а в QueuePool их всего 5+10. Неограниченная
|
||||||
|
# очередь выбрала бы пул и положила API ровно так же, как блокировка цикла,
|
||||||
|
# только другим способом. 4 из 15 соединений и худшее ожидание
|
||||||
|
# 4/1×282мс ≈ 1.1с — цена, которую живой вход переживает.
|
||||||
|
# ge=1: 0 читается как «выключить лимит», а означал бы обратное — КАЖДЫЙ вход
|
||||||
|
# получает 429 навсегда и молча (слотов нет ни одного). Выключать тут нечего:
|
||||||
|
# потолок — это workers, а очередь без границы выбирает пул соединений к БД.
|
||||||
|
login_password_verify_max_inflight: int = Field(
|
||||||
|
default=4, ge=1, validation_alias="LOGIN_PASSWORD_VERIFY_MAX_INFLIGHT"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Эпик «единый вход»: общий реестр людей в БД `auth` ─────────────────────
|
||||||
|
# DSN БД `auth` (роль auth_app) — единый реестр людей «Меры» (trade-in) и
|
||||||
|
# «Птицы» (Site Finder); схема — data/sql/auth/001-004.
|
||||||
|
#
|
||||||
|
# ПУСТО ПО УМОЛЧАНИЮ, И ЭТО НЕ ОШИБКА. На проде пароль роли auth_app ещё не
|
||||||
|
# заведён (переменной AUTH_DATABASE_URL там нет), данные (хеши/роли/живые
|
||||||
|
# сессии) в `auth` ещё не скопированы. Пока identity_store="tradein" (дефолт)
|
||||||
|
# к этой БД не обращается ни одна строка кода: engine не создаётся,
|
||||||
|
# соединение не открывается, пустой DSN на старте ничего не роняет — см.
|
||||||
|
# app.core.auth_db (ленивое создание engine). ENV: AUTH_DATABASE_URL.
|
||||||
|
#
|
||||||
|
# Задавать его РУКАМИ больше не обязательно — см. `resolved_auth_database_url`
|
||||||
|
# ниже: при пустом AUTH_DATABASE_URL и заданном AUTH_DB_PASSWORD DSN собирается
|
||||||
|
# из частей. Явное значение, если оно есть, по-прежнему выигрывает.
|
||||||
|
auth_database_url: str = Field(default="", validation_alias="AUTH_DATABASE_URL")
|
||||||
|
|
||||||
|
# ── Части DSN БД `auth` — чтобы пароль жил в ОДНОМ месте ────────────────────
|
||||||
|
# Пароль роли auth_app уже лежит в .env.runtime отдельной переменной
|
||||||
|
# AUTH_DB_PASSWORD: её читает .forgejo/workflows/deploy.yml, чтобы выполнить
|
||||||
|
# ALTER ROLE (ops/db-bootstrap/set_auth_app_password.sql). Требовать вдобавок
|
||||||
|
# целиковый AUTH_DATABASE_URL значило бы держать ОДИН секрет в ДВУХ местах:
|
||||||
|
# сменили пароль роли, забыли переписать DSN — и вход ложится молча и целиком
|
||||||
|
# (аутентификация к БД `auth` отваливается для всех сразу).
|
||||||
|
#
|
||||||
|
# ⚠️ ops-нюанс: deploy.yml делает ALTER ROLE, читая AUTH_DB_PASSWORD из
|
||||||
|
# backend/.env.runtime ГЛАВНОГО стека, а этот контейнер читает
|
||||||
|
# tradein-mvp/backend/.env.runtime (env_file в tradein-mvp/docker-compose.prod.yml).
|
||||||
|
# Файлы разные — переменная должна быть в обоих. Зато их значение сравнимо
|
||||||
|
# глазами, чего нельзя сказать про пароль, замурованный внутрь DSN.
|
||||||
|
#
|
||||||
|
# Пусто по умолчанию — как и AUTH_DATABASE_URL: в дефолтном режиме
|
||||||
|
# IDENTITY_STORE=tradein ничего из этого не читается. ENV: AUTH_DB_PASSWORD.
|
||||||
|
#
|
||||||
|
# SecretStr, а не str: это единственное поле-секрет, добавленное здесь, и
|
||||||
|
# обёртка бесплатно закрывает канал утечки, которого не видно глазами —
|
||||||
|
# `repr(settings)` и `settings.model_dump()` печатают обычные str-поля
|
||||||
|
# ДОСЛОВНО. Сегодня их никто не рендерит (grep по app: ни дампа env, ни
|
||||||
|
# `/debug`; sentry_sdk в app/main.py идёт с include_local_variables=False),
|
||||||
|
# но появиться такой рендер может в любой момент и тихо — с SecretStr он
|
||||||
|
# напечатает `SecretStr('**********')`. Значение достаётся ровно в одном
|
||||||
|
# месте — `.get_secret_value()` в резолвере ниже.
|
||||||
|
# ⚠️ Соседние секреты (database_url, telegram_bot_token, …) остались str —
|
||||||
|
# это предсуществующее положение, а не «здесь безопасно, а там нет».
|
||||||
|
auth_db_password: SecretStr = Field(default=SecretStr(""), validation_alias="AUTH_DB_PASSWORD")
|
||||||
|
# Остальные части — с дефолтами, верными для прод-стека (см. константы выше).
|
||||||
|
# Переопределяются через ENV для dev/локального запуска (напр. AUTH_DB_HOST=
|
||||||
|
# localhost + AUTH_DB_PORT=15432 поверх SSH-туннеля).
|
||||||
|
# ENV: AUTH_DB_HOST, AUTH_DB_PORT, AUTH_DB_NAME, AUTH_DB_USER.
|
||||||
|
auth_db_host: str = Field(default=_AUTH_DB_DEFAULT_HOST, validation_alias="AUTH_DB_HOST")
|
||||||
|
auth_db_port: int = Field(default=_AUTH_DB_DEFAULT_PORT, validation_alias="AUTH_DB_PORT")
|
||||||
|
auth_db_name: str = Field(default=_AUTH_DB_DEFAULT_NAME, validation_alias="AUTH_DB_NAME")
|
||||||
|
auth_db_user: str = Field(default=_AUTH_DB_DEFAULT_USER, validation_alias="AUTH_DB_USER")
|
||||||
|
|
||||||
|
@field_validator("auth_db_port", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _blank_port_means_default(cls, value: object) -> object:
|
||||||
|
"""`AUTH_DB_PORT=` (пустая строка) → прод-дефолт, а не падение на импорте.
|
||||||
|
|
||||||
|
Симметрия с host/name/user, у которых пустое значение переменной падает
|
||||||
|
обратно на дефолт в резолвере. Для порта того же добиться нельзя: он
|
||||||
|
типизирован `int` и валидируется pydantic'ом ДО всякой нашей логики, а
|
||||||
|
`settings = Settings()` выполняется на уровне модуля — то есть
|
||||||
|
`AUTH_DB_PORT=` в .env.runtime роняло бы ValidationError на импорте
|
||||||
|
конфига и уводило контейнер в restart-loop. Причём В ЛЮБОМ режиме,
|
||||||
|
включая дефолтный IDENTITY_STORE=tradein, где к БД `auth` не идёт ни
|
||||||
|
одного обращения — ровно тот инвариант «дефолт не трогаем», который
|
||||||
|
держит остальной код.
|
||||||
|
|
||||||
|
Сценарий не гипотетический: ops копирует блок AUTH_DB_* в .env.runtime и
|
||||||
|
заполняет только пароль — остальные строки остаются пустыми намеренно.
|
||||||
|
|
||||||
|
`mode="before"` — потому что вмешаться надо ДО приведения к int.
|
||||||
|
Непустой мусор (`AUTH_DB_PORT=abc`) по-прежнему валится, и правильно:
|
||||||
|
это опечатка со смыслом, а не «оставил пустым».
|
||||||
|
"""
|
||||||
|
if isinstance(value, str) and not value.strip():
|
||||||
|
return _AUTH_DB_DEFAULT_PORT
|
||||||
|
return value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def resolved_auth_database_url(self) -> str:
|
||||||
|
"""DSN БД `auth` — единственный источник правды для `app.core.auth_db`.
|
||||||
|
|
||||||
|
Приоритет:
|
||||||
|
1. `AUTH_DATABASE_URL`, если задан — выигрывает всегда. Обратная
|
||||||
|
совместимость (так настроено «до») плюс аварийный обход: если DSN
|
||||||
|
понадобился нестандартный (другой хост, sslmode, пул-байпас), его
|
||||||
|
можно вписать целиком, не трогая код.
|
||||||
|
2. Иначе, если задан `AUTH_DB_PASSWORD` — DSN собирается из частей.
|
||||||
|
3. Иначе — пустая строка, то есть «не сконфигурировано». Это НЕ ошибка
|
||||||
|
сама по себе: при `IDENTITY_STORE=tradein` (дефолт) сюда не заходит
|
||||||
|
никто. Ошибку — явную, а не тихий фолбэк — поднимает `app.core.auth_db`
|
||||||
|
и только когда реестр реально понадобился.
|
||||||
|
|
||||||
|
⚠️ Возвращаемое значение СОДЕРЖИТ ПАРОЛЬ: не логировать, не класть в текст
|
||||||
|
исключений, не отдавать наружу (`/health`, `/debug`, метрики).
|
||||||
|
|
||||||
|
Пароль экранируется `quote(..., safe="")`: спецсимвол (`@`, `:`, `/`, `?`,
|
||||||
|
`#`, `%`) внутри пароля иначе порвал бы URL по своей грамматике — `@`
|
||||||
|
сдвинул бы границу host, `/` открыл бы path. Разбор дал бы либо ошибку,
|
||||||
|
либо, что хуже, МОЛЧА другой хост/базу. По той же причине экранируется
|
||||||
|
имя пользователя.
|
||||||
|
|
||||||
|
А вот имя БД и хост — НЕ экранируются, и это не забывчивость: SQLAlchemy
|
||||||
|
раскодирует обратно только userinfo (user/password), а path отдаёт как
|
||||||
|
есть. Прогони мы имя БД через `quote`, в сервер уехало бы литеральное
|
||||||
|
`c%2Fd` вместо `c/d` (проверено round-trip'ом в тестах). Хосту
|
||||||
|
%-кодирование тоже только мешает — оно поломало бы IPv6-скобки.
|
||||||
|
"""
|
||||||
|
explicit = self.auth_database_url.strip()
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
|
||||||
|
# `.strip()` только для ПРОВЕРКИ «задан ли»: пробельная строка в .env — это
|
||||||
|
# опечатка, а не пароль. В сам DSN идёт значение КАК ЕСТЬ (не стриппится):
|
||||||
|
# ведущий/хвостовой пробел может быть частью настоящего пароля.
|
||||||
|
# Единственная точка распаковки SecretStr во всём коде — см. поле выше.
|
||||||
|
password = self.auth_db_password.get_secret_value()
|
||||||
|
if not password.strip():
|
||||||
|
return ""
|
||||||
|
|
||||||
|
user = quote(self.auth_db_user.strip() or _AUTH_DB_DEFAULT_USER, safe="")
|
||||||
|
secret = quote(password, safe="")
|
||||||
|
host = self.auth_db_host.strip() or _AUTH_DB_DEFAULT_HOST
|
||||||
|
port = self.auth_db_port
|
||||||
|
name = self.auth_db_name.strip() or _AUTH_DB_DEFAULT_NAME
|
||||||
|
# Схема — ровно та же, что у продуктового DATABASE_URL (psycopg v3;
|
||||||
|
# `postgresql://` без суффикса увёл бы SQLAlchemy на psycopg2, которого в
|
||||||
|
# зависимостях нет).
|
||||||
|
return f"postgresql+psycopg://{user}:{secret}@{host}:{port}/{name}"
|
||||||
|
|
||||||
|
# Где живут identity (люди + сессии):
|
||||||
|
# "tradein" (ДЕФОЛТ) — БД tradein, таблицы tradein_users/tradein_sessions
|
||||||
|
# (ровно сегодняшний прод, поведение не меняется);
|
||||||
|
# "auth" — БД auth, таблицы users/sessions (единый реестр).
|
||||||
|
# Переключать ТОЛЬКО после того, как на проде заведён пароль auth_app и
|
||||||
|
# перенесены данные. Дефолт = старое поведение: включить новый путь можно
|
||||||
|
# исключительно явной сменой этого флага. Единственный потребитель —
|
||||||
|
# app.services.identity_store. ENV: IDENTITY_STORE.
|
||||||
|
identity_store: Literal["tradein", "auth"] = Field(
|
||||||
|
default="tradein", validation_alias="IDENTITY_STORE"
|
||||||
|
)
|
||||||
|
|
||||||
# для User-Agent в Nominatim (Nominatim Usage Policy)
|
# для User-Agent в Nominatim (Nominatim Usage Policy)
|
||||||
contact_email: str = "erginrajpopxbe@outlook.com"
|
contact_email: str = "erginrajpopxbe@outlook.com"
|
||||||
|
|
||||||
|
|
@ -466,63 +706,67 @@ class Settings(BaseSettings):
|
||||||
"www.domclick.ru",
|
"www.domclick.ru",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Scraper mobile proxy (#806) ──────────────────────────────────────────
|
# ── Scraper mobile proxy (#806, #2616 шаг 2) ─────────────────────────────
|
||||||
# Мобильный прокси (RU, mobileproxy.space) используется ВСЕМИ scraper-сессиями:
|
# Мобильный резидентный прокси (ASocks) используется ВСЕМИ scraper-сессиями:
|
||||||
# Avito (#623) + Cian (#806). Datacenter-IP блокируется обоими сайтами.
|
# Avito (#623) + Cian (#806) + Yandex. Datacenter-IP блокируется всеми тремя.
|
||||||
# Пусто = прямое подключение (dev/staging без прокси).
|
# Пусто = прямое подключение (dev/staging без прокси).
|
||||||
#
|
#
|
||||||
# Приоритет ENV-переменных (precedence):
|
# #2616 шаг 2: per-provider legacy-переменные (AVITO_PROXY_URL/CIAN_PROXY_URL/
|
||||||
# 1. SCRAPER_PROXY_URL — новый общий ENV; когда задан — используется первым.
|
# YANDEX_PROXY_URL и их *_ROTATE_URL, changeip mobileproxy) удалены — указывали
|
||||||
# 2. AVITO_PROXY_URL — legacy ENV; fallback, чтобы prod-серверы с уже
|
# на закрытые аккаунты (407/connection refused, проверено вживую #2613).
|
||||||
# настроенным AVITO_PROXY_URL работали без изменений .env.runtime (#806).
|
# SCRAPER_PROXY_URL — единственный живой источник, общий для всех провайдеров.
|
||||||
# property `scraper_proxy_url` реализует эту логику; используй его везде.
|
|
||||||
# validation_alias привязывает поле к env SCRAPER_PROXY_URL (без него
|
# validation_alias привязывает поле к env SCRAPER_PROXY_URL (без него
|
||||||
# pydantic-settings читал бы SCRAPER_PROXY_URL_ENV по имени поля — #806 fixup).
|
# pydantic-settings читал бы SCRAPER_PROXY_URL_ENV по имени поля — #806 fixup).
|
||||||
scraper_proxy_url_env: str | None = Field(default=None, validation_alias="SCRAPER_PROXY_URL")
|
scraper_proxy_url_env: str | None = Field(default=None, validation_alias="SCRAPER_PROXY_URL")
|
||||||
avito_proxy_url: str | None = None # ENV: AVITO_PROXY_URL (legacy fallback)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def scraper_proxy_url(self) -> str | None:
|
def scraper_proxy_url(self) -> str | None:
|
||||||
"""Единый прокси URL для всех scraper-сессий (Avito + Cian).
|
"""Единый прокси URL для всех scraper-сессий (Avito + Cian + Yandex).
|
||||||
|
|
||||||
Приоритет: SCRAPER_PROXY_URL > AVITO_PROXY_URL > None (прямое подключение).
|
Прямая проекция SCRAPER_PROXY_URL (#2616 шаг 2: legacy AVITO_PROXY_URL
|
||||||
Prod-серверы с существующим AVITO_PROXY_URL работают без изменений env.
|
fallback снят — мёртвая mobileproxy-переменная).
|
||||||
"""
|
"""
|
||||||
return self.scraper_proxy_url_env or self.avito_proxy_url
|
return self.scraper_proxy_url_env
|
||||||
|
|
||||||
# changeip-ссылка mobileproxy: GET меняет мобильный IP за ~9с. Дёргается при
|
# ── Ban-recovery budget knobs (changeip-механизм снят #2616 шаг 2) ────────
|
||||||
# детекте бана Avito перед повтором. Пусто = ротация выключена (raise сразу).
|
# Раньше эти поля тюнили retry/settle для GET-changeip mobileproxy
|
||||||
# ENV: AVITO_PROXY_ROTATE_URL.
|
# (AVITO_PROXY_ROTATE_URL и т.д., см. историю выше) — сама ссылка удалена
|
||||||
avito_proxy_rotate_url: str | None = None
|
# (закрытый аккаунт), поэтому IP-ротация сейчас всегда no-op (_rotate_ip /
|
||||||
# Сколько раз сменить IP при блоке прежде чем сдаться (на одну страницу).
|
# _rotate_proxy_ip возвращают False без сетевого похода). Поля оставлены:
|
||||||
# #1731: 2→4 — больше шансов восстановиться mid-sweep после проактивной
|
# `*_proxy_max_rotations` продолжают гейтить бюджет попыток в ban-rotation
|
||||||
# ротации на старте (Datadome ban recovery).
|
# state machine (scraper_kit.orchestration.pipeline._try_rotate_within_budget)
|
||||||
|
# — те же 0 попыток "успеха", что и раньше при мёртвом changeip, просто без
|
||||||
|
# затрат на HTTP; `avito_proxy_rotate_settle_s` — верхняя граница
|
||||||
|
# asyncio.wait_for в app.tasks.avito_detail_backfill (страховка от зависания).
|
||||||
|
# Живая ротация IP — ASOCKS_API_TOKEN / app.services.proxy_rotation (#2611).
|
||||||
avito_proxy_max_rotations: int = 4
|
avito_proxy_max_rotations: int = 4
|
||||||
# Settle-sleep после changeip-вызова: мобильный модем поднимает новый IP.
|
|
||||||
# ~9с по умолчанию (эмпирика mobileproxy.space). ENV: AVITO_PROXY_ROTATE_SETTLE_S.
|
|
||||||
avito_proxy_rotate_settle_s: float = 9.0
|
avito_proxy_rotate_settle_s: float = 9.0
|
||||||
# #1950: retry-параметры changeip-GET (_rotate_proxy_ip). Вместо одношотного 30s-timeout
|
|
||||||
# делаем proxy_rotate_attempts попыток по proxy_rotate_attempt_timeout_s каждая.
|
|
||||||
# Короткий timeout (8s) означает, что зависший changeip не блокирует весь run на 30s.
|
|
||||||
# ENV: PROXY_ROTATE_ATTEMPT_TIMEOUT_S / PROXY_ROTATE_ATTEMPTS.
|
|
||||||
proxy_rotate_attempt_timeout_s: float = 8.0
|
proxy_rotate_attempt_timeout_s: float = 8.0
|
||||||
proxy_rotate_attempts: int = 3
|
proxy_rotate_attempts: int = 3
|
||||||
|
|
||||||
|
# ── ASocks pool-proxy rotation (#2600) ───────────────────────────────────
|
||||||
|
# Bearer-токен веб-кабинета ASocks для POST .../unlimited-proxy/{portId}/refresh-ip
|
||||||
|
# (app.services.proxy_rotation). Документированный публичный API (GET
|
||||||
|
# /v2/proxy/refresh/{portId}?apiKey=) для безлимитных портов не работает —
|
||||||
|
# подтверждено владельцем аккаунта; единственный рабочий путь — эта ручка
|
||||||
|
# веб-кабинета с сессионным токеном. Токен разово протухнет (осознанное
|
||||||
|
# решение владельца) — тогда provider вернёт 401, proxy_rotation.rotate_proxy
|
||||||
|
# логирует error + шлёт Sentry/GlitchTip alert. Пусто = ротация для всех
|
||||||
|
# прокси недоступна (rotate_proxy возвращает внятный отказ, не падает).
|
||||||
|
# ENV: ASOCKS_API_TOKEN. НИКОГДА не логировать / не возвращать в HTTP-ответе.
|
||||||
|
asocks_api_token: str = Field(default="", validation_alias="ASOCKS_API_TOKEN")
|
||||||
|
|
||||||
# #1950: если SERP уже сохранил лоты (ins+upd > 0) и упали только detail/houses,
|
# #1950: если SERP уже сохранил лоты (ins+upd > 0) и упали только detail/houses,
|
||||||
# ставим 'done' а не 'banned' — partial intake сохранён, 'banned' лишний.
|
# ставим 'done' а не 'banned' — partial intake сохранён, 'banned' лишний.
|
||||||
# False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED.
|
# False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED.
|
||||||
avito_serp_ok_not_banned: bool = True
|
avito_serp_ok_not_banned: bool = True
|
||||||
|
|
||||||
# ── Cian dedicated mobile proxy (separate egress from Avito) ──────────────
|
# ── Cian proxy budget (#2616 шаг 2: dedicated CIAN_PROXY_URL/ROTATE_URL снят) ──
|
||||||
# Cian и Avito делят один мобильный IP при общем scraper_proxy_url → конкуренция
|
# Раньше Cian мог получить СВОЙ мобильный прокси отдельно от Avito (контеншен на
|
||||||
# за единственный egress → взаимные таймауты/баны при параллельных прогонах.
|
# общем egress); CIAN_PROXY_URL указывал на закрытый аккаунт — удалён,
|
||||||
# Отдельный прокси для Cian устраняет contention. Если не задан — fallback на
|
# cian_proxy_url ниже теперь = scraper_proxy_url. cian_proxy_max_rotations
|
||||||
# общий scraper_proxy_url (backward-compat). ENV: CIAN_PROXY_URL.
|
# остаётся: гейтит бюджет в ban-rotation state machine наравне с avito/yandex
|
||||||
cian_proxy_url_env: str | None = Field(default=None, validation_alias="CIAN_PROXY_URL")
|
# (см. комментарий у avito_proxy_max_rotations выше — сама ротация no-op).
|
||||||
# changeip-ссылка для Cian-прокси (ротация IP при бане/таймауте). Если не задан —
|
|
||||||
# fallback на avito_proxy_rotate_url. ENV: CIAN_PROXY_ROTATE_URL.
|
|
||||||
cian_proxy_rotate_url: str | None = None
|
|
||||||
# Максимум IP-ротаций для Cian на один sweep-прогон. Аналог avito_proxy_max_rotations.
|
|
||||||
# ENV: CIAN_PROXY_MAX_ROTATIONS.
|
# ENV: CIAN_PROXY_MAX_ROTATIONS.
|
||||||
cian_proxy_max_rotations: int = 4
|
cian_proxy_max_rotations: int = 4
|
||||||
|
|
||||||
|
|
@ -542,25 +786,21 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cian_proxy_url(self) -> str | None:
|
def cian_proxy_url(self) -> str | None:
|
||||||
"""Прокси для Cian-скраперов. CIAN_PROXY_URL > scraper_proxy_url (fallback)."""
|
"""Прокси для Cian-скраперов (#2616 шаг 2: = scraper_proxy_url, per-provider
|
||||||
return self.cian_proxy_url_env or self.scraper_proxy_url
|
override снят — свойство оставлено для scraper_kit.contracts.ScraperConfig
|
||||||
|
совместимости)."""
|
||||||
|
return self.scraper_proxy_url
|
||||||
|
|
||||||
# ── Yandex dedicated mobile proxy (separate egress from Avito/Cian) ────────
|
# ── Yandex proxy budget (#2616 шаг 2: dedicated YANDEX_PROXY_URL/ROTATE_URL снят) ──
|
||||||
# Отдельный прокси для Yandex устраняет contention при параллельных прогонах.
|
# Симметрично Cian выше — YANDEX_PROXY_URL указывал на закрытый аккаунт.
|
||||||
# Если не задан — fallback на общий scraper_proxy_url (backward-compat).
|
# yandex_proxy_max_rotations остаётся для ban-rotation budget-гейта.
|
||||||
# ENV: YANDEX_PROXY_URL.
|
|
||||||
yandex_proxy_url_env: str | None = Field(default=None, validation_alias="YANDEX_PROXY_URL")
|
|
||||||
# changeip-ссылка для Yandex-прокси (ротация IP при капче/таймауте). Если не задан —
|
|
||||||
# fallback на avito_proxy_rotate_url. ENV: YANDEX_PROXY_ROTATE_URL.
|
|
||||||
yandex_proxy_rotate_url: str | None = None
|
|
||||||
# Максимум IP-ротаций для Yandex на один sweep-прогон. Аналог avito_proxy_max_rotations.
|
|
||||||
# ENV: YANDEX_PROXY_MAX_ROTATIONS.
|
# ENV: YANDEX_PROXY_MAX_ROTATIONS.
|
||||||
yandex_proxy_max_rotations: int = 4
|
yandex_proxy_max_rotations: int = 4
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def yandex_proxy_url(self) -> str | None:
|
def yandex_proxy_url(self) -> str | None:
|
||||||
"""Прокси для Yandex-скраперов. YANDEX_PROXY_URL > scraper_proxy_url (fallback)."""
|
"""Прокси для Yandex-скраперов (#2616 шаг 2: = scraper_proxy_url)."""
|
||||||
return self.yandex_proxy_url_env or self.scraper_proxy_url
|
return self.scraper_proxy_url
|
||||||
|
|
||||||
# full_load повторный прогон в день пропускает листинги уже обновлённые сегодня
|
# full_load повторный прогон в день пропускает листинги уже обновлённые сегодня
|
||||||
# (last_seen_at MSK) — экономит upsert + price-trigger churn; False = всегда
|
# (last_seen_at MSK) — экономит upsert + price-trigger churn; False = всегда
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ This helper:
|
||||||
- applies idempotent CREATE or ALTER mapping on every backend startup so
|
- applies idempotent CREATE or ALTER mapping on every backend startup so
|
||||||
password rotation through .env.runtime is picked up after restart.
|
password rotation through .env.runtime is picked up after restart.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -37,7 +38,7 @@ def ensure_fdw_user_mapping(db: Session) -> None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"GENDESIGN_FDW_PASSWORD not set — skipping FDW user mapping "
|
"GENDESIGN_FDW_PASSWORD not set — skipping FDW user mapping "
|
||||||
"(gendesign_cad_buildings queries will fail; cadastral lookups will "
|
"(gendesign_cad_buildings queries will fail; cadastral lookups will "
|
||||||
"fall back to Yandex/Nominatim)"
|
"fall back to Nominatim)"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -62,16 +63,20 @@ def ensure_fdw_user_mapping(db: Session) -> None:
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if exists is None:
|
if exists is None:
|
||||||
db.execute(text(
|
db.execute(
|
||||||
|
text(
|
||||||
f"CREATE USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
|
f"CREATE USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
|
||||||
f"OPTIONS (user 'tradein_fdw_reader', password '{password}')"
|
f"OPTIONS (user 'tradein_fdw_reader', password '{password}')"
|
||||||
))
|
)
|
||||||
|
)
|
||||||
logger.info("created FDW user mapping for gendesign_remote")
|
logger.info("created FDW user mapping for gendesign_remote")
|
||||||
else:
|
else:
|
||||||
db.execute(text(
|
db.execute(
|
||||||
|
text(
|
||||||
f"ALTER USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
|
f"ALTER USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
|
||||||
f"OPTIONS (SET password '{password}')"
|
f"OPTIONS (SET password '{password}')"
|
||||||
))
|
)
|
||||||
|
)
|
||||||
logger.info("refreshed FDW user mapping password for gendesign_remote")
|
logger.info("refreshed FDW user mapping password for gendesign_remote")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
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 self._window_s - (now - bucket[0])
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def record(self, key: str) -> None:
|
def record(self, key: str) -> int:
|
||||||
"""Регистрирует одну успешную попытку под *key*."""
|
"""Регистрирует одну попытку под *key* и возвращает их число в окне ПОСЛЕ неё.
|
||||||
|
|
||||||
|
Счётчик нужен вызывающим, которым мало булева «за лимитом / нет»: login
|
||||||
|
(#2571) по нему считает НАСКОЛЬКО перебран порог и растит задержку ответа
|
||||||
|
пропорционально. Значение можно игнорировать — `check()` так и делает.
|
||||||
|
"""
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
bucket = self._hits[key]
|
bucket = self._hits[key]
|
||||||
self._prune(bucket, now)
|
self._prune(bucket, now)
|
||||||
|
|
@ -125,6 +130,7 @@ class SlidingWindowLimiter:
|
||||||
if len(self._hits) > 10000:
|
if len(self._hits) > 10000:
|
||||||
for k in [k for k, v in self._hits.items() if not v]:
|
for k in [k for k, v in self._hits.items() if not v]:
|
||||||
del self._hits[k]
|
del self._hits[k]
|
||||||
|
return len(bucket)
|
||||||
|
|
||||||
def check(self, key: str) -> float | None:
|
def check(self, key: str) -> float | None:
|
||||||
"""Комбинированная проверка+регистрация (peek+record за один вызов) —
|
"""Комбинированная проверка+регистрация (peek+record за один вызов) —
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,22 @@ manually". The copy drifted: it was missing the #2213
|
||||||
``X-Internal-Auth-Secret`` defense-in-depth check that the real guard has,
|
``X-Internal-Auth-Secret`` defense-in-depth check that the real guard has,
|
||||||
so a regression in that check would NOT have failed CI.
|
so a regression in that check would NOT have failed CI.
|
||||||
|
|
||||||
This module holds the real guard with no DB/lifespan/scheduler side effects
|
This module holds the real guard. Historically it had "no DB/lifespan/scheduler
|
||||||
(only ``app.core.auth`` + ``app.core.config``, both side-effect-free at
|
side effects" beyond ``app.core.auth``/``app.core.config`` (both side-effect-free
|
||||||
import time beyond requiring ``DATABASE_URL`` in the environment for
|
at import time). #2552 (dual-mode DB-session auth) adds a conditional per-request
|
||||||
``Settings()``). ``app/main.py`` and the test apps both import THIS module,
|
DB round trip via ``app.services.identity_store.identity_session`` — но ТОЛЬКО
|
||||||
so tests exercise the exact production code path instead of a copy that can
|
когда запрос реально несёт session-cookie
|
||||||
silently fall out of sync.
|
(``request.cookies.get(settings.session_cookie_name)``); без cookie (весь
|
||||||
|
существующий тестовый трафик, legacy Caddy trusted-header запросы) ветка не
|
||||||
|
выполняется — ноль новых DB-побочных эффектов для старых путей. ``app/main.py``
|
||||||
|
and the test apps both import THIS module, so tests exercise the exact
|
||||||
|
production code path instead of a copy that can silently fall out of sync.
|
||||||
|
|
||||||
|
Сессия открывается через ``identity_session()``, а не через
|
||||||
|
``app.core.db.SessionLocal`` напрямую: guard — middleware, FastAPI-DI здесь нет,
|
||||||
|
а реестр людей при ``IDENTITY_STORE=auth`` лежит в другой БД. В дефолтном режиме
|
||||||
|
``identity_session()`` открывает ровно ``app.core.db.SessionLocal()`` — тот же
|
||||||
|
коннект-пул и то же поведение, что до эпика «единый вход».
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -21,12 +31,15 @@ import logging
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse, Response
|
||||||
|
|
||||||
from app.core.auth import get_role, is_path_allowed
|
from app.core.auth import get_role, is_path_allowed
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.services.auth_session import get_db_role_scope, get_session_user
|
||||||
|
from app.services.identity_store import identity_session
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -40,7 +53,37 @@ logger = logging.getLogger(__name__)
|
||||||
# Public paths без auth (/health, /docs, /openapi.json) пропускаем —
|
# Public paths без auth (/health, /docs, /openapi.json) пропускаем —
|
||||||
# X-Authenticated-User там не приходит из Caddy.
|
# X-Authenticated-User там не приходит из Caddy.
|
||||||
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
||||||
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
# #2552: /api/v1/auth/login + /logout — по определению вызываются ДО того, как
|
||||||
|
# клиент аутентифицирован (login) или могут вызываться с уже протухшей/отсутствующей
|
||||||
|
# сессией (logout — должен уметь чистить stale cookie без валидной auth). Свой
|
||||||
|
# rate-limit у /login отдельный (app.api.v1.auth._LOGIN_LIMITER), RateLimitMiddleware
|
||||||
|
# на /api/* всё равно применяется — это ослабляет ТОЛЬКО rbac_guard'овский
|
||||||
|
# auth-required gate, не остальные защиты.
|
||||||
|
#
|
||||||
|
# Инцидент 2026-07-31: /api/v1/trade-in/support/anon/* — по той же логике. Единственным
|
||||||
|
# каналом в поддержку был чат ЗА логином, а типовая причина писать в поддержку —
|
||||||
|
# «не могу войти» (в тот день так и вышло: «Практика» билась в форму весь день и
|
||||||
|
# достучаться из продукта не могла). Ветка НЕ трогает авторизованные
|
||||||
|
# /api/v1/trade-in/support/* — те по-прежнему требуют identity; у анонимной свой,
|
||||||
|
# заведомо более узкий бюджет (per-token + per-IP, см. app.api.v1.support) и своя
|
||||||
|
# идентичность из httpOnly-куки, которая структурно не может совпасть с чьим-то
|
||||||
|
# логином.
|
||||||
|
_PUBLIC_PATHS = frozenset(
|
||||||
|
{
|
||||||
|
"/health",
|
||||||
|
"/docs",
|
||||||
|
"/redoc",
|
||||||
|
"/openapi.json",
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
"/api/v1/auth/logout",
|
||||||
|
# NB: префикс — /api/v1/trade-in (app/main.py include_router), а Caddy
|
||||||
|
# срезает ВНЕШНИЙ /trade-in ещё раньше. Т.е. снаружи это
|
||||||
|
# /trade-in/api/v1/trade-in/support/anon/*, сюда приходит вот такое.
|
||||||
|
"/api/v1/trade-in/support/anon/messages",
|
||||||
|
"/api/v1/trade-in/support/anon/unread",
|
||||||
|
"/api/v1/trade-in/support/anon/read",
|
||||||
|
}
|
||||||
|
)
|
||||||
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
|
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
|
||||||
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
|
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
|
||||||
# scope-проверки восстанавливаем внешний путь.
|
# scope-проверки восстанавливаем внешний путь.
|
||||||
|
|
@ -51,6 +94,77 @@ _EXTERNAL_PREFIX = "/trade-in"
|
||||||
_RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand")
|
_RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand")
|
||||||
|
|
||||||
|
|
||||||
|
def _db_glob_match(pattern: str, path: str) -> bool:
|
||||||
|
"""Мини-матчер для фиксированного набора DB-role паттернов
|
||||||
|
(``app.services.auth_session.DB_ROLE_PATHS`` — только формы ``/**`` и
|
||||||
|
``<prefix>/**``, не нужна полная semantics ``app.core.auth._glob_to_regex``
|
||||||
|
— тот модуль private и MIRROR'ится вручную с основным бэкендом, лишний
|
||||||
|
импорт private-символа оттуда увеличивал бы drift-риск)."""
|
||||||
|
if pattern == "/**":
|
||||||
|
return True
|
||||||
|
if pattern.endswith("/**"):
|
||||||
|
prefix = pattern[: -len("/**")]
|
||||||
|
return path == prefix or path.startswith(prefix + "/")
|
||||||
|
return path == pattern
|
||||||
|
|
||||||
|
|
||||||
|
def _db_role_path_allowed(role: str, path: str) -> bool:
|
||||||
|
paths, deny = get_db_role_scope(role)
|
||||||
|
if any(_db_glob_match(p, path) for p in deny):
|
||||||
|
return False
|
||||||
|
return any(_db_glob_match(p, path) for p in paths)
|
||||||
|
|
||||||
|
|
||||||
|
def _propagate_authenticated_user(request: Request, username: str) -> None:
|
||||||
|
"""Инжектит ``X-Authenticated-User`` в ASGI scope — ПЕРЕЗАПИСЫВАЯ, а не
|
||||||
|
только добавляя при отсутствии, — чтобы ``RateLimitMiddleware``/
|
||||||
|
``RequestAuditMiddleware`` (оба читают сырой заголовок напрямую,
|
||||||
|
#2213/#2550) и downstream route-хендлеры (читающие его через FastAPI
|
||||||
|
``Header()``) видели РЕЗОЛВЛЕННОГО ИЗ СЕССИИ юзера — без правок в каждом
|
||||||
|
из этих мест по отдельности (минимально инвазивный способ).
|
||||||
|
|
||||||
|
#2552 post-review fix (CRITICAL): раньше это была skip-if-present
|
||||||
|
мутация (``if request.headers.get(...): return``) — сессия резолвилась
|
||||||
|
ПЕРВОЙ (см. rbac_guard), но клиент-контролируемый ``X-Authenticated-User``
|
||||||
|
(который Caddy шлёт на КАЖДЫЙ прод-запрос) выигрывал у неё для ВСЕГО
|
||||||
|
downstream-трафика: атакующий с валидной cookie юзера ``alice`` мог
|
||||||
|
подделать заголовок ``X-Authenticated-User: victim`` и получить доступ к
|
||||||
|
данным victim в ~15 роутах, читающих заголовок напрямую
|
||||||
|
(``_assert_estimate_access*``, ``account_quota``, ``/trade-in/history``,
|
||||||
|
``support.py``) — работало в ОБОИХ auth_mode (dual и db_only), т.к. эти
|
||||||
|
хендлеры не знают про rbac_guard'овский ``from_session`` флаг, только про
|
||||||
|
сырой заголовок. Session-identity ДОЛЖНА быть источником истины, если
|
||||||
|
сессия резолвлена — полная перезапись, не skip.
|
||||||
|
|
||||||
|
Механизм: ``request.scope`` — ОДИН и тот же dict-объект, прокинутый по
|
||||||
|
ссылке через весь ASGI call chain (Starlette не копирует scope между
|
||||||
|
слоями middleware). Мутация ``scope["headers"]`` ЗДЕСЬ видна:
|
||||||
|
- downstream call_next() цепочке (ExceptionMiddleware → Router →
|
||||||
|
endpoint) — т.к. rbac_guard мутирует scope ДО вызова call_next();
|
||||||
|
- ``RequestAuditMiddleware`` — он внешний относительно rbac_guard
|
||||||
|
(см. app/main.py: последний ``add_middleware`` оборачивает
|
||||||
|
предыдущие) и читает ``request.headers`` уже ПОСЛЕ ``call_next()``
|
||||||
|
отработал весь внутренний стек, включая эту мутацию.
|
||||||
|
|
||||||
|
ASGI header-имена — всегда lowercase bytes (см. ASGI spec), поэтому
|
||||||
|
фильтр по ``b"x-authenticated-user"`` ловит заголовок независимо от
|
||||||
|
регистра, в котором его прислал клиент (Starlette уже нормализует).
|
||||||
|
|
||||||
|
Известное ограничение: ``RateLimitMiddleware`` тоже внешний относительно
|
||||||
|
rbac_guard, но читает заголовок ДО вызова call_next() (до того, как этот
|
||||||
|
guard успевает отработать) — для ЭТОГО конкретного запроса сессионный
|
||||||
|
юзер лимитируется по IP, а не по username (per-user множитель не
|
||||||
|
применяется). Не регрессия (IP-лимит применялся бы и раньше — до
|
||||||
|
добавления session-auth такие запросы вообще были 401), просто более
|
||||||
|
строгий бюджет специфично для session-cookie-запросов; при необходимости
|
||||||
|
точного per-user квотинга для DB-юзеров — переносить резолв сессии выше
|
||||||
|
RateLimit в app/main.py отдельным issue.
|
||||||
|
"""
|
||||||
|
request.scope["headers"] = [
|
||||||
|
(k, v) for k, v in request.scope.get("headers", []) if k != b"x-authenticated-user"
|
||||||
|
] + [(b"x-authenticated-user", username.encode("latin-1", "replace"))]
|
||||||
|
|
||||||
|
|
||||||
async def rbac_guard(
|
async def rbac_guard(
|
||||||
request: Request,
|
request: Request,
|
||||||
call_next: Callable[[Request], Awaitable[Response]],
|
call_next: Callable[[Request], Awaitable[Response]],
|
||||||
|
|
@ -59,11 +173,56 @@ async def rbac_guard(
|
||||||
if path in _PUBLIC_PATHS:
|
if path in _PUBLIC_PATHS:
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
username: str | None = None
|
||||||
|
role: str | None = None
|
||||||
|
from_session = False
|
||||||
|
|
||||||
|
# #2552: session-cookie резолвится ПЕРВЫМ. Если cookie нет вообще —
|
||||||
|
# request.cookies.get() возвращает None без единого похода в БД (ноль
|
||||||
|
# side-effects для всего существующего трафика без cookie).
|
||||||
|
token = request.cookies.get(settings.session_cookie_name)
|
||||||
|
if token:
|
||||||
|
session_user: dict[str, Any] | None = None
|
||||||
|
try:
|
||||||
|
with identity_session() as db:
|
||||||
|
session_user = get_session_user(db, token)
|
||||||
|
except Exception:
|
||||||
|
# Сюда попадает и AuthDatabaseNotConfiguredError (IDENTITY_STORE=auth
|
||||||
|
# без AUTH_DATABASE_URL): резолв сессии не состоялся, дальше работает
|
||||||
|
# тот же путь, что и при любом сбое БД, — auth_mode решает, пускать ли
|
||||||
|
# legacy trusted-header.
|
||||||
|
#
|
||||||
|
# ⚠️ Этот except НЕ должен быть тем, что ловит сломанный DSN: молча
|
||||||
|
# деградировать в legacy trusted-header означало бы раздавать права
|
||||||
|
# из roles.yaml в обход реестра (включая аккаунты с access_state
|
||||||
|
# 'disabled'/'trial_expired'), причём сутками — продуктовая БД жива,
|
||||||
|
# приложение работоспособно, сигнал только в логах. Поэтому
|
||||||
|
# конфигурацию проверяет lifespan (app/main.py): при
|
||||||
|
# IDENTITY_STORE=auth пустой DSN роняет СТАРТ. Здесь остаётся второй
|
||||||
|
# рубеж — реестр, отвалившийся уже после успешного старта, не имеет
|
||||||
|
# права отдавать 500.
|
||||||
|
logger.exception("RBAC: session lookup failed for %s", path)
|
||||||
|
if session_user is not None:
|
||||||
|
username = session_user["username"]
|
||||||
|
role = session_user["role"]
|
||||||
|
from_session = True
|
||||||
|
_propagate_authenticated_user(request, username)
|
||||||
|
|
||||||
|
if not from_session:
|
||||||
|
# auth_mode == "db_only" — легаси trusted-header путь ПОЛНОСТЬЮ
|
||||||
|
# отключён, даже если валидный X-Authenticated-User присутствует.
|
||||||
|
if settings.auth_mode != "dual":
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "valid session required"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- legacy trusted-header path — BIT-FOR-BIT как было до #2552 ----
|
||||||
username = request.headers.get("X-Authenticated-User")
|
username = request.headers.get("X-Authenticated-User")
|
||||||
if not username:
|
if not username:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
content={"detail": "no authenticated user (Caddy basic_auth required)"},
|
content={"detail": "no authenticated user (valid session required)"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
|
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
|
||||||
|
|
@ -94,6 +253,9 @@ async def rbac_guard(
|
||||||
content={"detail": "user not in roles config"},
|
content={"detail": "user not in roles config"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert username is not None
|
||||||
|
assert role is not None
|
||||||
|
|
||||||
if _ADMIN_API_RE.match(path) and role != "admin":
|
if _ADMIN_API_RE.match(path) and role != "admin":
|
||||||
logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path)
|
logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|
@ -101,15 +263,14 @@ async def rbac_guard(
|
||||||
content={"detail": "admin only"},
|
content={"detail": "admin only"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# #R2-H3: энфорсим roles.yaml scope (paths/deny) для ВСЕХ non-admin путей, а не
|
# #R2-H3: энфорсим scope (paths/deny) для ВСЕХ non-admin путей, а не
|
||||||
# только /admin/*. Иначе revoked (role=expired, paths:[] deny:/**) или узко-
|
# только /admin/*. Bootstrap-пути (/me, /brand) исключены — иначе revoked/
|
||||||
# скоупленный аккаунт достаёт non-admin API (напр. POST /api/v1/search —
|
# scope-narrowed юзер не смог бы получить свою роль вовсе.
|
||||||
# экспорт листингов), который roles.yaml ему запрещает. Bootstrap-пути (/me,
|
|
||||||
# /brand) исключены выше по списку. roles.yaml globs внешние → восстанавливаем
|
|
||||||
# внешний путь (Caddy срезал /trade-in). На сбой парса — fail-open + громкий
|
|
||||||
# лог: не лочим платящего pilot из-за конфиг-бага (admin-гейт выше остаётся).
|
|
||||||
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
|
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
|
||||||
external_path = _EXTERNAL_PREFIX + path
|
external_path = _EXTERNAL_PREFIX + path
|
||||||
|
if from_session:
|
||||||
|
allowed = _db_role_path_allowed(role, external_path)
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
allowed = is_path_allowed(role, external_path)
|
allowed = is_path_allowed(role, external_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from sentry_sdk.integrations.starlette import StarletteIntegration
|
||||||
from app.api.v1 import (
|
from app.api.v1 import (
|
||||||
admin,
|
admin,
|
||||||
audit,
|
audit,
|
||||||
|
auth,
|
||||||
brand,
|
brand,
|
||||||
buildings,
|
buildings,
|
||||||
geocode,
|
geocode,
|
||||||
|
|
@ -31,8 +32,10 @@ from app.api.v1 import (
|
||||||
privacy_admin,
|
privacy_admin,
|
||||||
search,
|
search,
|
||||||
support,
|
support,
|
||||||
|
team,
|
||||||
trade_in,
|
trade_in,
|
||||||
)
|
)
|
||||||
|
from app.core.auth_db import get_auth_engine
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
from app.core.fdw import ensure_fdw_user_mapping
|
from app.core.fdw import ensure_fdw_user_mapping
|
||||||
|
|
@ -107,6 +110,40 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
".env.runtime ОБОИХ стеков (Caddy главного стека + tradein-backend)"
|
".env.runtime ОБОИХ стеков (Caddy главного стека + tradein-backend)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# #2552: session_secret зарезервирован на будущее (напр. подписанные токены) —
|
||||||
|
# opaque session-токены (secrets.token_urlsafe, см. app.services.auth_session)
|
||||||
|
# НЕ требуют подписи, их валидность проверяется исключительно наличием строки
|
||||||
|
# в tradein_sessions + expires_at/is_active. Пустой session_secret НЕ должен
|
||||||
|
# ронять старт контейнера (не startup-fail) — только громкий WARNING, чтобы
|
||||||
|
# прод не остался без него незамеченно до момента, когда он реально понадобится.
|
||||||
|
if not settings.session_secret:
|
||||||
|
logger.warning(
|
||||||
|
"SESSION_SECRET пуст — не блокирует старт (opaque session-токены не "
|
||||||
|
"требуют подписи), но задай его в .env.runtime до появления фич, "
|
||||||
|
"которым подпись реально нужна"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Эпик «единый вход»: при IDENTITY_STORE=auth реестр людей обязан быть
|
||||||
|
# СКОНФИГУРИРОВАН — иначе стартуем сломанными. Ошибка DSN не похожа на «БД
|
||||||
|
# недоступна»: продуктовая БД жива, приложение полностью работоспособно и
|
||||||
|
# может так работать сутками, а rbac_guard ловит AuthDatabaseNotConfiguredError
|
||||||
|
# вместе с любым другим сбоем резолва сессии и падает в legacy
|
||||||
|
# trusted-header ветку (auth_mode='dual'). То есть любой, кого пропустил
|
||||||
|
# Caddy basic_auth, молча получал бы права из roles.yaml — даже аккаунт с
|
||||||
|
# access_state='disabled'/'trial_expired' в реестре. Пусть лучше сломанный
|
||||||
|
# деплой не поднимется вообще, чем сутки раздаёт доступ мимо реестра.
|
||||||
|
#
|
||||||
|
# На ДЕФОЛТНЫЙ режим не влияет: при identity_store="tradein" (прод сегодня)
|
||||||
|
# ветка не выполняется, engine БД `auth` не создаётся, пустой
|
||||||
|
# AUTH_DATABASE_URL по-прежнему не ошибка.
|
||||||
|
if settings.identity_store == "auth":
|
||||||
|
# Наружу летит AuthDatabaseNotConfiguredError с внятным текстом
|
||||||
|
# (app.core.auth_db); create_engine к серверу не ходит, так что это
|
||||||
|
# проверка КОНФИГУРАЦИИ, а не доступности БД — недоступный сервер
|
||||||
|
# по-прежнему не мешает старту.
|
||||||
|
get_auth_engine()
|
||||||
|
logger.info("identity_store=auth: DSN общего реестра людей (БД `auth`) сконфигурирован")
|
||||||
|
|
||||||
# FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server.
|
# FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server.
|
||||||
# Best-effort: failure does not abort startup, just logs.
|
# Best-effort: failure does not abort startup, just logs.
|
||||||
try:
|
try:
|
||||||
|
|
@ -159,6 +196,7 @@ def health() -> dict[str, str]:
|
||||||
return {"status": "ok", "environment": settings.environment}
|
return {"status": "ok", "environment": settings.environment}
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||||
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
||||||
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
||||||
app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
|
app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
|
||||||
|
|
@ -170,3 +208,4 @@ app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-su
|
||||||
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
|
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
|
||||||
app.include_router(search.router, prefix="/api/v1", tags=["search"])
|
app.include_router(search.router, prefix="/api/v1", tags=["search"])
|
||||||
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
||||||
|
app.include_router(team.router, prefix="/api/v1/team", tags=["team"])
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,11 @@ _TG_BOT_TOKEN_REPLACEMENT = "/bot[REDACTED]"
|
||||||
_TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b")
|
_TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b")
|
||||||
|
|
||||||
# Query-string секреты в исходящих URL сторонних API (аудит-фикс, #security-audit):
|
# Query-string секреты в исходящих URL сторонних API (аудит-фикс, #security-audit):
|
||||||
# mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др., admin.py
|
# исторически — mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др.,
|
||||||
# rotate_proxy_ip) несёт провайдерский API-ключ в query (`?...&proxy_key=...`).
|
# admin.rotate_proxy_ip) несла провайдерский API-ключ в query
|
||||||
|
# (`?...&proxy_key=...`). Ручка и переменные удалены (#2616 шаг 2/3, мёртвая
|
||||||
|
# подписка) — редактор оставлен как generic safety net (не ключ-based, любой
|
||||||
|
# будущий query-секрет с распространённым именем параметра тоже покрыт).
|
||||||
# Два независимых пути утечки в GlitchTip, зеркалящих TG-токен выше:
|
# Два независимых пути утечки в GlitchTip, зеркалящих TG-токен выше:
|
||||||
# 1. `HttpxIntegration.send()` парсит URL через `parse_url(str(request.url),
|
# 1. `HttpxIntegration.send()` парсит URL через `parse_url(str(request.url),
|
||||||
# sanitize=False)` (ЯВНЫЙ opt-out из sentry_sdk `sanitize_url`, который иначе
|
# sanitize=False)` (ЯВНЫЙ opt-out из sentry_sdk `sanitize_url`, который иначе
|
||||||
|
|
@ -58,13 +61,14 @@ _TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b")
|
||||||
# span не сэмплится/не уходит), но молча перестанет спасать, если трейсинг
|
# span не сэмплится/не уходит), но молча перестанет спасать, если трейсинг
|
||||||
# когда-нибудь включат.
|
# когда-нибудь включат.
|
||||||
# 2. `include_local_variables=True` (sentry_sdk default в app/main.py — в отличие
|
# 2. `include_local_variables=True` (sentry_sdk default в app/main.py — в отличие
|
||||||
# от tgbot_main.py, где явно False) кладёт stack-frame locals (`rotate_url`,
|
# от tgbot_main.py, где явно False) кладёт stack-frame locals в traceback
|
||||||
# `exc` в rotate_proxy_ip) в traceback открытым текстом.
|
# открытым текстом (был прецедент: `rotate_url`/`exc` в удалённом
|
||||||
|
# admin.rotate_proxy_ip).
|
||||||
# Как и TG-токен — full-text regex по КАЖДОЙ строке event (не ключ-based): секрет
|
# Как и TG-токен — full-text regex по КАЖДОЙ строке event (не ключ-based): секрет
|
||||||
# может всплыть где угодно (frame locals, breadcrumb, exception message). НЕ
|
# может всплыть где угодно (frame locals, breadcrumb, exception message). НЕ
|
||||||
# завязано на конкретного провайдера — покрывает любой query-параметр из
|
# завязано на конкретного провайдера — покрывает любой query-параметр из
|
||||||
# общеупотребимого набора секретных имён (api_key/proxy_key/token/secret/password/
|
# общеупотребимого набора секретных имён (api_key/proxy_key/token/secret/password/
|
||||||
# access_token/auth), т.к. cian/yandex у нас имеют СВОИ rotate-URL (потенциально
|
# access_token/auth) — живой пример: ASOCKS_API_TOKEN (потенциально
|
||||||
# другой провайдер, другое имя параметра).
|
# другой провайдер, другое имя параметра).
|
||||||
_URL_SECRET_QUERY_RE = re.compile(
|
_URL_SECRET_QUERY_RE = re.compile(
|
||||||
r"(?i)([?&](?:api[_-]?key|proxy[_-]?key|token|secret|password|pwd|"
|
r"(?i)([?&](?:api[_-]?key|proxy[_-]?key|token|secret|password|pwd|"
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,9 @@ class SearchParams(BaseModel):
|
||||||
floors_total_max: int | None = Field(default=None, ge=1)
|
floors_total_max: int | None = Field(default=None, ge=1)
|
||||||
|
|
||||||
# --- Quality / cross-source ---
|
# --- Quality / cross-source ---
|
||||||
has_kadastr: bool = False
|
# has_kadastr снят (#2674): listings.cadastral_number пуст у всех 93 408 строк,
|
||||||
|
# фильтр мог вернуть только пустую выдачу. Лишний query-param FastAPI игнорирует,
|
||||||
|
# так что старые клиенты не ломаются.
|
||||||
sources: list[Literal["avito", "cian", "yandex_realty"]] | None = None
|
sources: list[Literal["avito", "cian", "yandex_realty"]] | None = None
|
||||||
multi_source_only: bool = False
|
multi_source_only: bool = False
|
||||||
require_avito: bool = False
|
require_avito: bool = False
|
||||||
|
|
|
||||||
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-ключе).
|
# geocode() (который падает на DaData-формах при мёртвом Yandex-ключе).
|
||||||
lat: float | None = Field(default=None, ge=-90, le=90)
|
lat: float | None = Field(default=None, ge=-90, le=90)
|
||||||
lon: float | None = Field(default=None, ge=-180, le=180)
|
lon: float | None = Field(default=None, ge=-180, le=180)
|
||||||
|
# #2576: город, если известен фронту (например выбран отдельным полем UI).
|
||||||
|
# Опционально — без него geocode() внутри estimate_quality() БОЛЬШЕ НЕ
|
||||||
|
# подставляет "Екатеринбург" молча (см. app.services.geocoder), что раньше
|
||||||
|
# давало уверенно неверную цену для жителей других городов области (те же
|
||||||
|
# улица+дом существуют и в ЕКБ, и, например, в Нижнем Тагиле).
|
||||||
|
city_hint: str | None = Field(default=None, max_length=100)
|
||||||
# ФИАС/ГАР OBJECTGUID целевого дома, если фронт разрешил его через suggest
|
# ФИАС/ГАР OBJECTGUID целевого дома, если фронт разрешил его через suggest
|
||||||
# (SuggestItem.fias_id у house-level кандидата). Прокидывается в матчер
|
# (SuggestItem.fias_id у house-level кандидата). Прокидывается в матчер
|
||||||
# (Tier 0.5 fias_exact) ПЕРВЫМ, до fias из DaData /clean. Additive/optional —
|
# (Tier 0.5 fias_exact) ПЕРВЫМ, до fias из DaData /clean. Additive/optional —
|
||||||
|
|
@ -194,6 +200,12 @@ class AggregatedEstimate(BaseModel):
|
||||||
target_address: str | None = None # geocoded full address
|
target_address: str | None = None # geocoded full address
|
||||||
target_lat: float | None = None
|
target_lat: float | None = None
|
||||||
target_lon: float | None = None
|
target_lon: float | None = None
|
||||||
|
# #2576: True если ни адрес, ни `TradeInEstimateInput.city_hint` не называли
|
||||||
|
# город явно — итоговый город (и, соответственно, набор аналогов/цена)
|
||||||
|
# определил геокодер-провайдер, а не пользователь. Честный сигнал для
|
||||||
|
# UI (снизить доверие / переспросить город), НЕ персистится в БД
|
||||||
|
# (ephemeral, только для текущего POST /estimate ответа).
|
||||||
|
target_city_ambiguous: bool = False
|
||||||
sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr']
|
sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr']
|
||||||
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
|
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
|
||||||
# абсолютный timestamp самого свежего парсинга аналогов
|
# абсолютный timestamp самого свежего парсинга аналогов
|
||||||
|
|
@ -258,6 +270,16 @@ class AggregatedEstimate(BaseModel):
|
||||||
# null — нет данных / оценка не построена
|
# null — нет данных / оценка не построена
|
||||||
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
|
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
|
||||||
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
|
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
|
||||||
|
# search_radius_m — фактический радиус (метры), по которому реально отбирались
|
||||||
|
# listings-аналоги (estimator.py: base_radius_m/fallback_radius_m, #2632). Может
|
||||||
|
# ОТЛИЧАТЬСЯ от TradeInEstimateInput.radius_m (выбор пользователя в дропдауне):
|
||||||
|
# сервер молча расширяет 1 км → 2 км при нехватке аналогов (см.
|
||||||
|
# confidence_explanation "расширили радиус до 2 км"). Фронт рисует круг на карте
|
||||||
|
# по ЭТОМУ полю (не по своему выбору) — иначе карта врёт о реально
|
||||||
|
# использованном радиусе. None на GET-rehydrate (не персистится, старые записи)
|
||||||
|
# и у _empty_estimate (поиск аналогов не выполнялся) — фронт в этом случае
|
||||||
|
# fallback'ает на выбор пользователя.
|
||||||
|
search_radius_m: int | None = None
|
||||||
# ── #2002: премиальный дом (флаг, НЕ ценовой сигнал) ──
|
# ── #2002: премиальный дом (флаг, НЕ ценовой сигнал) ──
|
||||||
# premium_building — целевой дом признан премиальным. Источник — curated overlay
|
# premium_building — целевой дом признан премиальным. Источник — curated overlay
|
||||||
# `premium_buildings_curated` (data/sql/142, AI/human-выверенный класс + false-
|
# `premium_buildings_curated` (data/sql/142, AI/human-выверенный класс + false-
|
||||||
|
|
@ -404,6 +426,11 @@ class ScheduleConfigUpdate(BaseModel):
|
||||||
window_start_hour: int = Field(default=2, ge=0, le=23)
|
window_start_hour: int = Field(default=2, ge=0, le=23)
|
||||||
window_end_hour: int = Field(default=5, ge=0, le=23)
|
window_end_hour: int = Field(default=5, ge=0, le=23)
|
||||||
default_params: dict[str, Any] = Field(default_factory=dict)
|
default_params: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
# #2674: явная воля оператора по времени следующего запуска. None (умолчание) —
|
||||||
|
# «не трогай, посчитай сам от такта». Заданное значение уважается как есть, включая
|
||||||
|
# прошедшее/now() — это и есть «запустить сейчас» (планировщик берёт строки с
|
||||||
|
# next_run_at <= NOW()), у которого до сих пор не было API и его делали UPDATE'ом.
|
||||||
|
next_run_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
# ── House analytics (house_placement_history backfill) ───────────────────────
|
# ── House analytics (house_placement_history backfill) ───────────────────────
|
||||||
|
|
@ -591,6 +618,13 @@ class SalesVsListingsResponse(BaseModel):
|
||||||
deals_with_listings: int # сколько имеют связанный listing
|
deals_with_listings: int # сколько имеют связанный listing
|
||||||
linkage_rate_pct: float # deals_with_listings / total_deals * 100
|
linkage_rate_pct: float # deals_with_listings / total_deals * 100
|
||||||
median_discount_pct: float | None # медиана по парам с listing
|
median_discount_pct: float | None # медиана по парам с listing
|
||||||
|
# #2666: None вместе с median_discount_pct=None означает «медианы просто нет»
|
||||||
|
# (пар не нашлось). Непустая строка = медиана посчиталась, но не прошла гейт
|
||||||
|
# правдоподобия (мало пар / значение вне санитарного диапазона — см. пороги
|
||||||
|
# SALES_VS_LISTINGS_* в api/v1/trade_in.py) и намеренно не показывается.
|
||||||
|
# Форма отказа зеркалит confidence_explanation оценщика: пользователю нужен
|
||||||
|
# текст «почему числа нет», иначе пустое место читается как поломка виджета.
|
||||||
|
median_discount_explanation: str | None = None
|
||||||
data_quality: str # "house_linked" | "street_only" | "no_data" (#721, ADR v3)
|
data_quality: str # "house_linked" | "street_only" | "no_data" (#721, ADR v3)
|
||||||
pairs: list[SalesListingPair] # все пары, sorted by deal_date DESC
|
pairs: list[SalesListingPair] # все пары, sorted by deal_date DESC
|
||||||
|
|
||||||
|
|
|
||||||
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 json
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from curl_cffi.requests import AsyncSession
|
from curl_cffi.requests import AsyncSession
|
||||||
|
|
@ -23,6 +24,11 @@ from app.core.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# За сколько дней до протухания кук предупреждать (#2658). Обновление кук — РУЧНАЯ
|
||||||
|
# операция (залить дамп через админку), человеку нужен запас: алерт по факту протухания
|
||||||
|
# приходит, когда сбор уже встал. save_session ставит ttl 30 дней, так что окно широкое.
|
||||||
|
COOKIE_EXPIRY_WARN_DAYS = 5
|
||||||
|
|
||||||
# Cookies критичные для Cian auth — фильтр перед сохранением.
|
# Cookies критичные для Cian auth — фильтр перед сохранением.
|
||||||
# Список обновлён по реальному DevTools-дампу из logged-in сессии cian.ru (2026-05-23).
|
# Список обновлён по реальному DevTools-дампу из logged-in сессии cian.ru (2026-05-23).
|
||||||
# Старые записи оставлены как fallback (backward compat).
|
# Старые записи оставлены как fallback (backward compat).
|
||||||
|
|
@ -294,6 +300,37 @@ def load_session(db: Session) -> dict[str, str] | None:
|
||||||
return cookies
|
return cookies
|
||||||
|
|
||||||
|
|
||||||
|
def session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None:
|
||||||
|
"""Когда протухают самые свежезагруженные куки (#2658).
|
||||||
|
|
||||||
|
`load_session` отбирает только ещё валидные записи (expires_at_estimate > NOW()) и на
|
||||||
|
протухших отдаёт None — вызывающий не мог отличить «кук никогда не загружали» от
|
||||||
|
«протухли позавчера» и не мог предупредить ЗАРАНЕЕ.
|
||||||
|
|
||||||
|
valid_only=False (диагностика после None от load_session) — свежайшая запись любая:
|
||||||
|
валидных по определению нет, нужен именно срок протухшей. valid_only=True — та же
|
||||||
|
запись, которую взял бы load_session: для предупреждения «скоро протухнут» нужен срок
|
||||||
|
ИМЕННО используемых кук, иначе при нескольких аккаунтах посчитаем по чужой строке.
|
||||||
|
"""
|
||||||
|
row = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT expires_at_estimate FROM cian_session_cookies
|
||||||
|
WHERE NOT CAST(:valid_only AS boolean)
|
||||||
|
OR (expires_at_estimate > NOW()
|
||||||
|
AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at))
|
||||||
|
ORDER BY uploaded_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"valid_only": valid_only},
|
||||||
|
).first()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
expires_at: datetime | None = row[0]
|
||||||
|
return expires_at
|
||||||
|
|
||||||
|
|
||||||
def mark_session_invalid(db: Session, account_user_id: int) -> None:
|
def mark_session_invalid(db: Session, account_user_id: int) -> None:
|
||||||
"""Flag session как expired/invalid (например после 401 во время scrape)."""
|
"""Flag session как expired/invalid (например после 401 во время scrape)."""
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
|
||||||
|
|
@ -343,6 +343,10 @@ async def suggest_addresses(
|
||||||
жёсткий фильтр (не boost) на уровне указанного admin-поля — доп.
|
жёсткий фильтр (не boost) на уровне указанного admin-поля — доп.
|
||||||
параметров не требуется. По умолчанию не задан — поведение (и body
|
параметров не требуется. По умолчанию не задан — поведение (и body
|
||||||
запроса) для существующих вызовов не меняется.
|
запроса) для существующих вызовов не меняется.
|
||||||
|
ВАЖНО: значение сравнивается с полем DaData `region`, где имя лежит
|
||||||
|
БЕЗ типа («Свердловская», а тип — отдельно в `region_type`="обл").
|
||||||
|
Передашь «Свердловская область» — совпадений не будет, и запрос
|
||||||
|
вернёт ПУСТО без всякой ошибки (hard-filter, не boost).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[DadataSuggestion] — пустой список если:
|
list[DadataSuggestion] — пустой список если:
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
@ -25,6 +26,13 @@ from app.core.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# За сколько дней до протухания кук предупреждать (#2674, по образцу #2658 для Циана).
|
||||||
|
# Обновление кук — РУЧНАЯ операция (залить дамп через админку), человеку нужен запас:
|
||||||
|
# сигнал по факту протухания приходит, когда обогащение уже встало. Прод 2026-08-03:
|
||||||
|
# куки протухли, единственным следом был WARNING в docker-логе, который к тому же
|
||||||
|
# теряется при редеплое. save_session ставит ttl 30 дней, так что окно широкое.
|
||||||
|
COOKIE_EXPIRY_WARN_DAYS = 5
|
||||||
|
|
||||||
# Cookies критичные для DomClick auth (Sber ID) — фильтр перед сохранением.
|
# Cookies критичные для DomClick auth (Sber ID) — фильтр перед сохранением.
|
||||||
# Список составлен по реальному DevTools/Cookie-Editor дампу авторизованной
|
# Список составлен по реальному DevTools/Cookie-Editor дампу авторизованной
|
||||||
# test-аккаунт сессии (Sber ID login), 2026-07-04.
|
# test-аккаунт сессии (Sber ID login), 2026-07-04.
|
||||||
|
|
@ -148,6 +156,37 @@ def load_session(db: Session) -> dict[str, str] | None:
|
||||||
return cookies
|
return cookies
|
||||||
|
|
||||||
|
|
||||||
|
def session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None:
|
||||||
|
"""Когда протухают самые свежезагруженные куки (#2674, зеркалит cian_session #2658).
|
||||||
|
|
||||||
|
`load_session` отбирает только ещё валидные записи (expires_at_estimate > NOW()) и на
|
||||||
|
протухших отдаёт None — вызывающий не мог отличить «кук никогда не загружали» от
|
||||||
|
«протухли позавчера» и не мог предупредить ЗАРАНЕЕ.
|
||||||
|
|
||||||
|
valid_only=False (диагностика после None от load_session) — свежайшая запись любая:
|
||||||
|
валидных по определению нет, нужен именно срок протухшей. valid_only=True — та же
|
||||||
|
запись, которую взял бы load_session: для предупреждения «скоро протухнут» нужен срок
|
||||||
|
ИМЕННО используемых кук, иначе при нескольких аккаунтах посчитаем по чужой строке.
|
||||||
|
"""
|
||||||
|
row = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT expires_at_estimate FROM domclick_session_cookies
|
||||||
|
WHERE NOT CAST(:valid_only AS boolean)
|
||||||
|
OR (expires_at_estimate > NOW()
|
||||||
|
AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at))
|
||||||
|
ORDER BY uploaded_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"valid_only": valid_only},
|
||||||
|
).first()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
expires_at: datetime | None = row[0]
|
||||||
|
return expires_at
|
||||||
|
|
||||||
|
|
||||||
def mark_session_invalid(db: Session, account_cas_id: int) -> None:
|
def mark_session_invalid(db: Session, account_cas_id: int) -> None:
|
||||||
"""Flag session как expired/invalid (например после блока во время scrape)."""
|
"""Flag session как expired/invalid (например после блока во время scrape)."""
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ from scraper_kit.providers.cian.valuation import (
|
||||||
estimate_via_cian_valuation,
|
estimate_via_cian_valuation,
|
||||||
)
|
)
|
||||||
from scraper_kit.providers.yandex.valuation import (
|
from scraper_kit.providers.yandex.valuation import (
|
||||||
|
ValuationHouseMeta,
|
||||||
YandexValuationResult,
|
YandexValuationResult,
|
||||||
YandexValuationScraper,
|
YandexValuationScraper,
|
||||||
)
|
)
|
||||||
|
|
@ -80,6 +81,7 @@ from app.services.house_metadata import get_house_metadata
|
||||||
from app.services.matching.houses import match_house_readonly, match_or_create_house
|
from app.services.matching.houses import match_house_readonly, match_or_create_house
|
||||||
from app.services.scraper_adapters import RealScraperConfig
|
from app.services.scraper_adapters import RealScraperConfig
|
||||||
from app.services.scraper_settings import get_scraper_delay
|
from app.services.scraper_settings import get_scraper_delay
|
||||||
|
from app.tasks.asking_to_sold_ratio import area_bucket
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -165,6 +167,24 @@ def _estimate_consent_persist_fields(
|
||||||
DKP_CORRIDOR_CITY_WIDE_MIN_N = 3
|
DKP_CORRIDOR_CITY_WIDE_MIN_N = 3
|
||||||
DEALS_HEADLINE_FALLBACK_MIN_N = 3
|
DEALS_HEADLINE_FALLBACK_MIN_N = 3
|
||||||
|
|
||||||
|
# #oblast-E (money-path sufficiency gate, live-audit 2026-08-02): минимум
|
||||||
|
# LIVE-объявлений, из которых можно честно построить headline-медиану по
|
||||||
|
# рынку. Ниже порога median() по 1-4 случайным лотам не отражает рынок —
|
||||||
|
# live repro на проде: Серов 2к/45м², n=3 листинга → 42 391 ₽/м² (−36% vs
|
||||||
|
# городской ДКП-коридор 54 126 ₽/м², которых сама эта улица не показывала
|
||||||
|
# из-за тонкой street-выборки), а соседняя улица того же города с другими
|
||||||
|
# 1-2 случайными лотами даёт разброс до ×1.66. Ниже порога
|
||||||
|
# _price_from_inputs ОБНУЛЯЕТ радиусную популяцию (listings_clean=[]) —
|
||||||
|
# 100%-переиспользует уже существующий и покрытый тестами путь «листингов
|
||||||
|
# нет» (same-building anchor / #oblast-D deals-headline-fallback /
|
||||||
|
# insufficient_data ниже), а не изобретает новую ветку. Значение 5 выбрано
|
||||||
|
# по live-данным (n=3 уже недостаточно; n=5 — тот же порог, что
|
||||||
|
# MIN_ANALOGS_TIER_0 использует для "хватает на строгий когортный тир" —
|
||||||
|
# согласованная планка "достаточно, чтобы не быть шумом одного-двух лотов").
|
||||||
|
# НЕ трогает подбор аналогов/тиры/радиусы — только решение, доверять ли
|
||||||
|
# ИТОГОВОЙ выборке как headline-источнику.
|
||||||
|
HEADLINE_LISTINGS_MIN_N = 5
|
||||||
|
|
||||||
# #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals.
|
# #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals.
|
||||||
# Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm²
|
# Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm²
|
||||||
# to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ).
|
# to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ).
|
||||||
|
|
@ -264,6 +284,55 @@ def _load_city_price_bands(db: Session) -> dict[str, tuple[int, int]]:
|
||||||
return bands
|
return bands
|
||||||
|
|
||||||
|
|
||||||
|
# Правдоподобный диапазон года постройки МКД (guard на входе — Mera-audit 2026-08-02).
|
||||||
|
# Нижняя граница 1917: массовая многоквартирная застройка в РФ/СССР началась
|
||||||
|
# после революции — год раньше почти гарантированно ошибка источника
|
||||||
|
# (house_metadata OSM/кадастр смешивают год постройки дома с годом основания
|
||||||
|
# места/памятника на тех же координатах — прод-инцидент 2026-08: house_metadata
|
||||||
|
# отдал year_built=1829 для обычной вторички, см. vault fixes). Верхняя граница
|
||||||
|
# — текущий год + 3: допуск на цели trade-in со строящимся домом (год сдачи по
|
||||||
|
# ДДУ известен заранее, но не более чем на несколько лет вперёд).
|
||||||
|
# Год вне диапазона трактуем как ОТСУТСТВУЮЩИЙ (None), а НЕ клампим к границе —
|
||||||
|
# хедонический фактор (_price_from_inputs, #2002) экстраполирует regression fit
|
||||||
|
# far вне обучающей выборки (COHORTS ниже даже не определяет когорту раньше
|
||||||
|
# 1955 — модель никогда не видела осмысленного объёма домов старше этого), и
|
||||||
|
# estimate_hedonic_factor_min=0.75 в таком случае не защита, а маскировка
|
||||||
|
# выхода за диапазон под видом уверенной −25% поправки. «Не знаем год» —
|
||||||
|
# честный сигнал, который просто отключает year-term фактора (нейтрален).
|
||||||
|
MIN_PLAUSIBLE_BUILD_YEAR = 1917
|
||||||
|
MAX_PLAUSIBLE_BUILD_YEAR_LEAD = 3 # текущий год + N — допуск на стройки
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_build_year(
|
||||||
|
year: int | None, *, house_id: int | None = None, address: str | None = None
|
||||||
|
) -> int | None:
|
||||||
|
"""Отбрасывает неправдоподобный год постройки, трактуя его как «неизвестен».
|
||||||
|
|
||||||
|
Валидный диапазон — [MIN_PLAUSIBLE_BUILD_YEAR, текущий год + LEAD]. Год вне
|
||||||
|
диапазона логируется на WARNING (с идентификатором дома — house_id либо
|
||||||
|
адрес) и заменяется на None, а не клампится к границе: клампинг превращает
|
||||||
|
заведомый мусор источника (house_metadata OSM/кадастр, либо year_built из
|
||||||
|
payload — ge=1800 в схеме пропускает подобные значения) в уверенный вход
|
||||||
|
для хедонической поправки (_price_from_inputs), хотя физического смысла
|
||||||
|
у результата нет.
|
||||||
|
"""
|
||||||
|
if year is None:
|
||||||
|
return None
|
||||||
|
max_year = datetime.now(UTC).year + MAX_PLAUSIBLE_BUILD_YEAR_LEAD
|
||||||
|
if year < MIN_PLAUSIBLE_BUILD_YEAR or year > max_year:
|
||||||
|
logger.warning(
|
||||||
|
"estimate: implausible year_built=%s dropped (house_id=%s, address=%s) — "
|
||||||
|
"valid range [%s, %s]",
|
||||||
|
year,
|
||||||
|
house_id,
|
||||||
|
address,
|
||||||
|
MIN_PLAUSIBLE_BUILD_YEAR,
|
||||||
|
max_year,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return year
|
||||||
|
|
||||||
|
|
||||||
# Когорта по году постройки — типизация массовой застройки РФ.
|
# Когорта по году постройки — типизация массовой застройки РФ.
|
||||||
# Используется как hard-filter в Tier 0 _fetch_analogs (PR 9, 2026-05-24).
|
# Используется как hard-filter в Tier 0 _fetch_analogs (PR 9, 2026-05-24).
|
||||||
# Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается.
|
# Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается.
|
||||||
|
|
@ -377,11 +446,22 @@ _asking_sold_ratio_cache: dict[int, tuple[float | None, str | None, float]] = {}
|
||||||
def _get_asking_sold_ratio(
|
def _get_asking_sold_ratio(
|
||||||
db: Session,
|
db: Session,
|
||||||
rooms: int | None,
|
rooms: int | None,
|
||||||
|
area_m2: float | None = None,
|
||||||
anchor_ppm2: float | None = None,
|
anchor_ppm2: float | None = None,
|
||||||
) -> tuple[float | None, str | None]:
|
) -> tuple[float | None, str | None]:
|
||||||
"""Возвращает (ratio, basis) asking→sold для бакета комнат.
|
"""Возвращает (ratio, basis) asking→sold для area-бакета клиентской квартиры.
|
||||||
|
|
||||||
bucket = min(max(rooms or 0, 0), 4).
|
bucket = area_bucket(area_m2) при area_m2 > 0, иначе min(max(rooms or 0, 0), 4)
|
||||||
|
(rooms-фолбэк — только когда площадь неизвестна).
|
||||||
|
|
||||||
|
#2620-2 (deep-review, вторая половина #2620): расчёт ratio (asking_to_sold_ratio.py
|
||||||
|
ask_side) теперь ключуется по area-бакету (rooms_bucket в asking_to_sold_ratios —
|
||||||
|
это на самом деле area-бакет, легаси-имя колонки), но ДО этой правки применение
|
||||||
|
здесь читало rooms_bucket по РЕАЛЬНЫМ комнатам клиента — тот же mismatch, что чинили
|
||||||
|
в расчёте, просто переехавший в применение. Прод-замер ревьюера (2026-08): 310/1038
|
||||||
|
(29.9%) исторических клиентских запросов легли бы в другой ratio-бакет при
|
||||||
|
rooms-ключе vs area-ключе. area_bucket() — тот же Python-двойник, что и в
|
||||||
|
asking_to_sold_ratio.py (см. комментарий там, границы 30/44/62/85 = import-rosreestr.sh).
|
||||||
|
|
||||||
Запрос к asking_to_sold_ratios (migration 080): per-rooms строка
|
Запрос к asking_to_sold_ratios (migration 080): per-rooms строка
|
||||||
(WHERE rooms_bucket = bucket AND district = '') → fallback на global -1
|
(WHERE rooms_bucket = bucket AND district = '') → fallback на global -1
|
||||||
|
|
@ -395,7 +475,7 @@ def _get_asking_sold_ratio(
|
||||||
Таблицы нет / любая ошибка → (None, None), НЕ raise (graceful).
|
Таблицы нет / любая ошибка → (None, None), НЕ raise (graceful).
|
||||||
Кэшируется на ключ bucket с TTL _ASKING_SOLD_RATIO_CACHE_TTL_S.
|
Кэшируется на ключ bucket с TTL _ASKING_SOLD_RATIO_CACHE_TTL_S.
|
||||||
"""
|
"""
|
||||||
bucket = min(max(rooms or 0, 0), 4)
|
bucket = area_bucket(area_m2) if area_m2 else min(max(rooms or 0, 0), 4)
|
||||||
|
|
||||||
cached = _asking_sold_ratio_cache.get(bucket)
|
cached = _asking_sold_ratio_cache.get(bucket)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
|
|
@ -811,10 +891,15 @@ def _save_yandex_history_items(
|
||||||
(address|publish_date|area|floor|prices) hash.
|
(address|publish_date|area|floor|prices) hash.
|
||||||
|
|
||||||
Batch semantics: single try/except; on any failure the batch rolls back.
|
Batch semantics: single try/except; on any failure the batch rolls back.
|
||||||
"""
|
|
||||||
if not result.history_items:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
#2674 (ревью): резолв дома и запись houses.has_panorama идут ДО раннего возврата по
|
||||||
|
пустой истории. Раньше возврат стоял первым, и страница, отрисованная идеально, но
|
||||||
|
без единого объявления в истории, до записи панорамы не доходила — на проде это
|
||||||
|
1519 оценок против 1360 домов с историей, ~10% страниц молча пропускались. Цена
|
||||||
|
переноса: match_or_create_house теперь вызывается и для таких страниц (может
|
||||||
|
СОЗДАТЬ дом). Это тот же вызов, с тем же адресом, что уже отрабатывает на
|
||||||
|
остальных 90% — новых сущностей класс не появляется, появляется недостающая доля.
|
||||||
|
"""
|
||||||
# Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16]
|
# Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16]
|
||||||
# — stable across re-runs, distinguishes pages for different addresses.
|
# — stable across re-runs, distinguishes pages for different addresses.
|
||||||
address_seed = (result.address or "").strip().lower()
|
address_seed = (result.address or "").strip().lower()
|
||||||
|
|
@ -850,6 +935,12 @@ def _save_yandex_history_items(
|
||||||
result.address,
|
result.address,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Наблюдение о доме не зависит от того, есть ли на странице история объявлений.
|
||||||
|
_save_yandex_house_panorama(db, house_id, result.house)
|
||||||
|
|
||||||
|
if not result.history_items:
|
||||||
|
return 0
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
skipped_area = 0
|
skipped_area = 0
|
||||||
for item in result.history_items:
|
for item in result.history_items:
|
||||||
|
|
@ -931,6 +1022,60 @@ def _save_yandex_history_items(
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# #2674: has_panorama разбирался парсером (providers/yandex/valuation.py:334), лежал в
|
||||||
|
# HOUSE_FIELD_PRIORITY и обещался публичным контрактом market.v_houses (мигр. 154) — но
|
||||||
|
# в houses не попадал НИ ОДНОЙ строкой кода: 0 непустых из 9366 домов на проде. Здесь —
|
||||||
|
# единственное место, где yandex_valuation уже держит и house_id, и разобранную мету.
|
||||||
|
#
|
||||||
|
# ГЕЙТ ЧЕСТНОСТИ. Парсер отдаёт `bool`, а не `bool | None`: "Панорама" not in body_text
|
||||||
|
# даёт False и когда метки правда нет, и когда страница не отрисовалась (капча, редизайн,
|
||||||
|
# пустой ответ). Записывать такой False — снова выдать «не измеряли» за «измерили и нет».
|
||||||
|
# Пишем только когда страница ТОЧНО отрисовалась: в мете есть год постройки или этажность
|
||||||
|
# (обе — обязательные блоки нормальной страницы оценки). Иначе колонка остаётся NULL.
|
||||||
|
def _save_yandex_house_panorama(
|
||||||
|
db: Session,
|
||||||
|
house_id: int | None,
|
||||||
|
meta: ValuationHouseMeta,
|
||||||
|
) -> None:
|
||||||
|
"""Пишет houses.has_panorama по разобранной мете yandex_valuation.
|
||||||
|
|
||||||
|
No-op без house_id или когда страница не подтверждена как отрисованная (см. гейт
|
||||||
|
выше). Best-effort: ошибка логируется и глотается — оценка не должна падать из-за
|
||||||
|
справочного флага. Именно поэтому UPDATE идёт в begin_nested: сбой откатывает
|
||||||
|
только свой SAVEPOINT и не отравляет транзакцию, в которой уже осела история.
|
||||||
|
"""
|
||||||
|
if house_id is None:
|
||||||
|
return
|
||||||
|
if meta.year_built is None and meta.total_floors is None:
|
||||||
|
logger.debug(
|
||||||
|
"yandex_valuation: has_panorama не пишем для house_id=%s — "
|
||||||
|
"страница не подтверждена (нет ни года, ни этажности)",
|
||||||
|
house_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with db.begin_nested():
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE houses
|
||||||
|
SET has_panorama = CAST(:panorama AS boolean)
|
||||||
|
WHERE id = CAST(:hid AS bigint)
|
||||||
|
AND has_panorama IS DISTINCT FROM CAST(:panorama AS boolean)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"hid": house_id, "panorama": meta.has_panorama},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"yandex_valuation: has_panorama save failed for house_id=%s (continuing): %s",
|
||||||
|
house_id,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
|
||||||
# ── #651: IMV / Yandex blend (killer accuracy fix) ─────────────────────────────
|
# ── #651: IMV / Yandex blend (killer accuracy fix) ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1686,6 +1831,20 @@ def _normalize_building_key(
|
||||||
(корпус) схлопываются к base (тот же дом). Литеры — РАЗНЫЕ дома (204г ≠ 204д).
|
(корпус) схлопываются к base (тот же дом). Литеры — РАЗНЫЕ дома (204г ≠ 204д).
|
||||||
- street_core прогоняется через _STREET_ALIAS_MAP (ткачева→ткачей).
|
- street_core прогоняется через _STREET_ALIAS_MAP (ткачева→ткачей).
|
||||||
|
|
||||||
|
#2581: город/р-н НАМЕРЕННО дропается (не возвращается в ключе) — это
|
||||||
|
единственное, что делает «Ткачёва 13»-вариант с городом и без города давать
|
||||||
|
один ключ (см. test_normalize_tkachei13_all_db_variants_same_key). Городской
|
||||||
|
токен как 4-й элемент ключа НЕ добавлен: (а) часть источников (Avito
|
||||||
|
anonymous-адреса) вообще не несёт городской токен в тексте — ключ с
|
||||||
|
обязательным городом сломал бы их матчинг; (б) написание города варьируется
|
||||||
|
(ЕКБ/Екатеринбург/г. Екатеринбург) — ещё один normalization-слой, дающий
|
||||||
|
те же false-negative риски, которые уже решает `_CITY_TOKENS`-дропинг.
|
||||||
|
Кросс-городская коллизия (одноимённая улица+дом в разных городах области)
|
||||||
|
закрыта на SQL-уровне через ST_DWithin от subject-координат — см. Tier A
|
||||||
|
в `_fetch_anchor_comps` (#2581) и Tier S в `_fetch_analogs` (#oblast-D,
|
||||||
|
f9ae6f0c) — геопредикат надёжнее строкового city-токена и не ломает то,
|
||||||
|
что уже нормализуется здесь.
|
||||||
|
|
||||||
Returns (street_core, base_no, letter) — любой элемент None если не извлёкся.
|
Returns (street_core, base_no, letter) — любой элемент None если не извлёкся.
|
||||||
Best-effort: при пустом адресе → (None, None, None).
|
Best-effort: при пустом адресе → (None, None, None).
|
||||||
"""
|
"""
|
||||||
|
|
@ -1786,6 +1945,18 @@ def _anchor_comp_from_row(r: Any) -> dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# #2581: Tier A ("same building") ST_DWithin safety-radius. Reuses the SAME
|
||||||
|
# city-scale DEFAULT_RADIUS_M already used by Tier S's mirrored geo-bound fix
|
||||||
|
# (f9ae6f0c, #oblast-D). The address-string match (_normalize_building_key +
|
||||||
|
# _house_boundary_regex) already establishes "same street + house number" —
|
||||||
|
# this radius only needs to reject GENUINELY cross-city collisions (e.g.
|
||||||
|
# «улица Ленина» exists in both Екатеринбург AND Серов/Нижний Тагил, 150+ km
|
||||||
|
# apart) — it does not need to be building-tight like Tier C's 500m
|
||||||
|
# micro-radius (that tier's precision comes from proximity alone, without a
|
||||||
|
# street/house string match to lean on).
|
||||||
|
ANCHOR_TIER_A_RADIUS_M = DEFAULT_RADIUS_M
|
||||||
|
|
||||||
|
|
||||||
def _fetch_anchor_comps(
|
def _fetch_anchor_comps(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -1798,9 +1969,19 @@ def _fetch_anchor_comps(
|
||||||
) -> tuple[list[dict[str, Any]], str | None]:
|
) -> tuple[list[dict[str, Any]], str | None]:
|
||||||
"""Тированный набор комплов для same-building якоря. Стоп на 1-м тире с ≥ min_comps.
|
"""Тированный набор комплов для same-building якоря. Стоп на 1-м тире с ≥ min_comps.
|
||||||
|
|
||||||
Tier A — SAME BUILDING: normalized street + base house no (+ литера если есть).
|
Tier A — SAME BUILDING: normalized street + base house no (+ литера если есть)
|
||||||
RELAXED rooms (без фильтра), БЕЗ area±15%. Не группируем по house_id_fk —
|
+ ST_DWithin(ANCHOR_TIER_A_RADIUS_M) от subject lat/lon (#2581 — до этого
|
||||||
один дом дробится на несколько fk (Хохрякова 48 = 7085/9878/12797).
|
SQL не имел ГЕО-предиката вовсе, и одноимённая улица+дом в ДРУГОМ городе
|
||||||
|
(область — 368 городов, «Ленина»/«Мира»/... повторяются) молча матчила
|
||||||
|
ЕКБ-листинги для областного subject'а — ~40 191 из ~40 200 активных
|
||||||
|
листингов ЕКБ, distance неизвестна без фильтра). RELAXED rooms (без
|
||||||
|
фильтра), БЕЗ area±15%. Не группируем по house_id_fk — один дом дробится
|
||||||
|
на несколько fk (Хохрякова 48 = 7085/9878/12797); ST_DWithin — тот же
|
||||||
|
компромисс, что и Tier S ниже (см. _fetch_analogs), не house_id_fk.
|
||||||
|
lat/lon subject'а обязательны (гейт как у Tier C) — без них геопредикат
|
||||||
|
невозможен, и Tier A целиком пропускается (в проде geo ВСЕГДА есть —
|
||||||
|
estimate_quality возвращает _empty_estimate раньше при неудачном
|
||||||
|
geocode, см. `if geo is None`).
|
||||||
Tier C — micro-radius ≤500m (ST_DWithin) + вторичка-канон guard (#1186): NULL = legacy
|
Tier C — micro-radius ≤500m (ST_DWithin) + вторичка-канон guard (#1186): NULL = legacy
|
||||||
вторичка + rooms match + area±25%. (Tier B «тот же ЖК» — skip: complex_id/cian_zhk_url
|
вторичка + rooms match + area±25%. (Tier B «тот же ЖК» — skip: complex_id/cian_zhk_url
|
||||||
ненадёжны.)
|
ненадёжны.)
|
||||||
|
|
@ -1816,7 +1997,7 @@ def _fetch_anchor_comps(
|
||||||
|
|
||||||
# ── Tier A: same building ────────────────────────────────────────────────
|
# ── Tier A: same building ────────────────────────────────────────────────
|
||||||
street, base_no, letter = _normalize_building_key(address)
|
street, base_no, letter = _normalize_building_key(address)
|
||||||
if street and base_no is not None:
|
if street and base_no is not None and lat is not None and lon is not None:
|
||||||
# ё→е в SQL для symmetry с нормализатором. psycopg v3: bind через :param,
|
# ё→е в SQL для symmetry с нормализатором. psycopg v3: bind через :param,
|
||||||
# оператор ~. Boundary-regex вынесен в _house_boundary_regex (общий с
|
# оператор ~. Boundary-regex вынесен в _house_boundary_regex (общий с
|
||||||
# Tier S radius-fallback ниже, см. _fetch_analogs).
|
# Tier S radius-fallback ниже, см. _fetch_analogs).
|
||||||
|
|
@ -1835,11 +2016,20 @@ def _fetch_anchor_comps(
|
||||||
AND price_per_m2 > 0
|
AND price_per_m2 > 0
|
||||||
AND lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like
|
AND lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like
|
||||||
AND lower(translate(address, 'ёЁ', 'ее')) ~ :house_re
|
AND lower(translate(address, 'ёЁ', 'ее')) ~ :house_re
|
||||||
|
AND geom IS NOT NULL
|
||||||
|
AND ST_DWithin(
|
||||||
|
geom::geography,
|
||||||
|
ST_MakePoint(:lon, :lat)::geography,
|
||||||
|
:radius
|
||||||
|
)
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
"street_like": "%" + street + "%",
|
"street_like": "%" + street + "%",
|
||||||
"house_re": house_re,
|
"house_re": house_re,
|
||||||
|
"lon": lon,
|
||||||
|
"lat": lat,
|
||||||
|
"radius": ANCHOR_TIER_A_RADIUS_M,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
|
|
@ -1964,12 +2154,17 @@ def _band_haircut(anchor_ppm2: float) -> float:
|
||||||
LOW audit #3: 0.04/0.07 (и mid из settings.asking_to_sold_haircut) —
|
LOW audit #3: 0.04/0.07 (и mid из settings.asking_to_sold_haircut) —
|
||||||
EKB-secondary-market calibration constants, но применяются ENGINE-WIDE (нет
|
EKB-secondary-market calibration constants, но применяются ENGINE-WIDE (нет
|
||||||
city-параметра ни здесь, ни у единственного вызывающего
|
city-параметра ни здесь, ни у единственного вызывающего
|
||||||
`_compute_same_building_anchor`). Реального импакта на не-ЕКБ область пока нет
|
`_compute_same_building_anchor`). #2581 update: та формулировка была НЕВЕРНОЙ —
|
||||||
(same-building anchor pool для oblast сейчас не формируется — anchor_ppm2 сюда
|
same-building anchor pool для oblast ВСЕГДА мог сформироваться (Tier A до
|
||||||
просто не доходит), но это доверие к отсутствию данных, а не к дизайну. Как
|
#2581 не имел гео-предиката вообще, поэтому for oblast-subject'ов он либо
|
||||||
только oblast anchor pools появятся (см. #oblast-D fallback выше), эти пороги
|
молча тянул ЕКБ-листинги по одноимённой улице/дому, либо — для действительно
|
||||||
нужно пересмотреть/сделать per-city — не оставлять ЕКБ-калибровку по умолчанию
|
уникальных названий — честно матчил местные листинги, если они были). После
|
||||||
для другого рынка. No behavior change here (doc-only).
|
#2581 (ST_DWithin(ANCHOR_TIER_A_RADIUS_M) на Tier A) anchor_ppm2 ДЛЯ ОБЛАСТИ
|
||||||
|
доходит сюда легитимно (местные комплы того же дома в Серове/Тагиле/etc, если
|
||||||
|
они есть в БД), но всё ещё через ЕКБ-калиброванный haircut — эти пороги
|
||||||
|
по-прежнему стоит пересмотреть/сделать per-city, не оставлять ЕКБ-калибровку
|
||||||
|
по умолчанию для другого рынка. No behavior change here (doc-only, кроме
|
||||||
|
исправления ложной посылки).
|
||||||
"""
|
"""
|
||||||
if anchor_ppm2 >= 350_000:
|
if anchor_ppm2 >= 350_000:
|
||||||
return 0.04
|
return 0.04
|
||||||
|
|
@ -2367,6 +2562,12 @@ class PricingResult:
|
||||||
# headline. Anchor-путь → CV комплов (anchor["cv"]); radius-путь → CV
|
# headline. Anchor-путь → CV комплов (anchor["cv"]); radius-путь → CV
|
||||||
# радиусной ₽/м²-выборки. None если <2 цен (недостаточно данных).
|
# радиусной ₽/м²-выборки. None если <2 цен (недостаточно данных).
|
||||||
cv: float | None = None
|
cv: float | None = None
|
||||||
|
# #oblast-E: >0 когда n листингов было найдено но ниже HEADLINE_LISTINGS_MIN_N
|
||||||
|
# (headline suppressed, listings_clean deliberately left intact — see gate
|
||||||
|
# comment above). Caller uses this to also keep the thin listings out of the
|
||||||
|
# display `analogs` cards when no anchor overrides the headline. 0 = either
|
||||||
|
# sufficient listings were used, or genuinely zero were found.
|
||||||
|
listings_headline_thin_n: int = 0
|
||||||
|
|
||||||
|
|
||||||
def _price_from_inputs(
|
def _price_from_inputs(
|
||||||
|
|
@ -2445,10 +2646,45 @@ def _price_from_inputs(
|
||||||
n_analogs = 0
|
n_analogs = 0
|
||||||
cv = None
|
cv = None
|
||||||
|
|
||||||
# 4b. Repair coefficient
|
# 4a. #oblast-E sufficiency gate (see HEADLINE_LISTINGS_MIN_N docstring above).
|
||||||
|
# 1..HEADLINE_LISTINGS_MIN_N-1 listings are a real find but too thin to trust
|
||||||
|
# as a market median — suppress the AGGREGATE (median/range/n_analogs/cv)
|
||||||
|
# exactly like "no usable listings", so the anchor/#oblast-D-deals-fallback/
|
||||||
|
# insufficient_data chain below all take the already-honest zero-analogs
|
||||||
|
# path automatically (no new branches there). `listings_clean` itself is
|
||||||
|
# deliberately LEFT INTACT (not cleared) — the same-building anchor's own
|
||||||
|
# ghost-anchor guard (#1871, `if not listings_clean`) uses it to tell
|
||||||
|
# "genuinely zero nearby listings" from "some nearby listings, just too few
|
||||||
|
# to trust as THIS estimate's headline" — those are different confidence
|
||||||
|
# signals and clearing the list here would conflate them. The caller
|
||||||
|
# (estimate_quality) uses `listings_headline_thin_n` on the returned
|
||||||
|
# PricingResult to also keep suppressed listings out of the display
|
||||||
|
# `analogs` cards when no anchor overrides the headline (n_analogs
|
||||||
|
# invariant: cards shown ⊆ what n_analogs counts).
|
||||||
|
listings_headline_thin_n = 0
|
||||||
|
if 0 < n_analogs < HEADLINE_LISTINGS_MIN_N:
|
||||||
|
listings_headline_thin_n = n_analogs
|
||||||
|
logger.info(
|
||||||
|
"headline sufficiency gate #oblast-E: n=%d < %d listings — suppressing "
|
||||||
|
"listings-derived median (falling back to anchor/deals/insufficient_data)",
|
||||||
|
n_analogs,
|
||||||
|
HEADLINE_LISTINGS_MIN_N,
|
||||||
|
)
|
||||||
|
median_ppm2 = 0.0
|
||||||
|
q1_ppm2 = 0.0
|
||||||
|
q3_ppm2 = 0.0
|
||||||
|
median_price = 0
|
||||||
|
range_low = 0
|
||||||
|
range_high = 0
|
||||||
|
n_analogs = 0
|
||||||
|
cv = None
|
||||||
|
|
||||||
|
# 4b. Repair coefficient — skipped when the headline was thin-suppressed
|
||||||
|
# above (median_price is already 0; applying a coefficient would leave it
|
||||||
|
# 0 but still emit a misleading "adjusted for repair state" note).
|
||||||
repair_coef = _repair_coefficient(repair_state)
|
repair_coef = _repair_coefficient(repair_state)
|
||||||
repair_note = ""
|
repair_note = ""
|
||||||
if listings_clean and repair_coef != 1.0:
|
if listings_clean and not listings_headline_thin_n and repair_coef != 1.0:
|
||||||
median_price = int(median_price * repair_coef)
|
median_price = int(median_price * repair_coef)
|
||||||
range_low = int(range_low * repair_coef)
|
range_low = int(range_low * repair_coef)
|
||||||
range_high = int(range_high * repair_coef)
|
range_high = int(range_high * repair_coef)
|
||||||
|
|
@ -2489,6 +2725,20 @@ def _price_from_inputs(
|
||||||
area_widened,
|
area_widened,
|
||||||
listings=listings_clean,
|
listings=listings_clean,
|
||||||
)
|
)
|
||||||
|
# #oblast-E: honest override — _compute_confidence's generic "не найдено
|
||||||
|
# аналогов" is FALSE here (we DID find listings_headline_thin_n of them,
|
||||||
|
# just too few to trust). Stays the final explanation unless a later block
|
||||||
|
# (anchor / #oblast-D deals-fallback) overwrites it with its OWN honest
|
||||||
|
# reasoning — both of those already check truthy `explanation` and either
|
||||||
|
# replace it (anchor) or append a construction-method clause that reads
|
||||||
|
# this same thin-count (deals-fallback), so no contradiction either way.
|
||||||
|
if listings_headline_thin_n:
|
||||||
|
confidence = "low"
|
||||||
|
explanation = (
|
||||||
|
f"Рядом найдено недостаточно объявлений ({listings_headline_thin_n} шт., "
|
||||||
|
f"минимум для оценки по рынку — {HEADLINE_LISTINGS_MIN_N}) — медиана по "
|
||||||
|
"такой маленькой выборке слишком чувствительна к случайным лотам."
|
||||||
|
)
|
||||||
|
|
||||||
# Tier note — информируем пользователя о качестве house-match
|
# Tier note — информируем пользователя о качестве house-match
|
||||||
tier_note = ""
|
tier_note = ""
|
||||||
|
|
@ -3041,9 +3291,20 @@ def _price_from_inputs(
|
||||||
# generic ghost-anchor). No repair_state adjustment: the deal corridor
|
# generic ghost-anchor). No repair_state adjustment: the deal corridor
|
||||||
# mixes conditions across sold units — unlike the listings comp pool,
|
# mixes conditions across sold units — unlike the listings comp pool,
|
||||||
# there is no per-unit signal to correct against.
|
# there is no per-unit signal to correct against.
|
||||||
|
#
|
||||||
|
# #oblast-E: guards on `anchor is None` (the actual computed anchor dict),
|
||||||
|
# NOT `anchor_tier is None`. Found via live backtest-fixture regen: when
|
||||||
|
# `_compute_same_building_anchor` rejects a candidate outright (e.g. its
|
||||||
|
# own MAD-clip drops comps below estimate_sb_min_comps), it returns None
|
||||||
|
# WITHOUT the caller resetting `anchor_tier` back to None (it stays
|
||||||
|
# whatever `anchor_tier_fetched` was, e.g. "C") — the anchor never fired,
|
||||||
|
# but the stale tier flag falsely reads as "anchor claimed the headline"
|
||||||
|
# and blocked this fallback even with a large, valid ДКП corridor
|
||||||
|
# available (observed: 677 deals for one fixture case). `anchor is None`
|
||||||
|
# is the ground truth of whether the anchor actually produced a headline.
|
||||||
if (
|
if (
|
||||||
median_ppm2 <= 0
|
median_ppm2 <= 0
|
||||||
and anchor_tier is None
|
and anchor is None
|
||||||
and dkp_raw is not None
|
and dkp_raw is not None
|
||||||
and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N
|
and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N
|
||||||
and dkp_raw.get("median_ppm2", 0) > 0
|
and dkp_raw.get("median_ppm2", 0) > 0
|
||||||
|
|
@ -3055,16 +3316,27 @@ def _price_from_inputs(
|
||||||
n_analogs = 0
|
n_analogs = 0
|
||||||
confidence = "low"
|
confidence = "low"
|
||||||
cv = None
|
cv = None
|
||||||
|
# #oblast-E: differentiate "genuinely zero listings" (unchanged wording)
|
||||||
|
# from "found some but below HEADLINE_LISTINGS_MIN_N, suppressed above" —
|
||||||
|
# the latter must NOT claim "рядом нет объявлений" (false, contradicts the
|
||||||
|
# thin-sufficiency explanation already set above this block).
|
||||||
|
no_listings_clause = (
|
||||||
|
f" Из {listings_headline_thin_n} найденных объявлений недостаточно для "
|
||||||
|
"надёжной медианы —"
|
||||||
|
if listings_headline_thin_n
|
||||||
|
else " Рядом нет актуальных объявлений —"
|
||||||
|
)
|
||||||
explanation = (explanation or "") + (
|
explanation = (explanation or "") + (
|
||||||
" Рядом нет актуальных объявлений — оценка построена по реальным "
|
f"{no_listings_clause} оценка построена по реальным "
|
||||||
f"сделкам Росреестра ({dkp_raw['count']} шт. за {dkp_raw['period_months']} мес.),"
|
f"сделкам Росреестра ({dkp_raw['count']} шт. за {dkp_raw['period_months']} мес.),"
|
||||||
" точность ориентировочная."
|
" точность ориентировочная."
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"deals_headline_fallback #oblast-D: dkp median=%d (n=%d) → headline"
|
"deals_headline_fallback #oblast-D: dkp median=%d (n=%d) → headline"
|
||||||
" (listings=0, anchor=None)",
|
" (listings=0 [thin_suppressed=%d], anchor=None)",
|
||||||
int(median_ppm2),
|
int(median_ppm2),
|
||||||
dkp_raw["count"],
|
dkp_raw["count"],
|
||||||
|
listings_headline_thin_n,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── #652: ДКП-коридор реальных сделок (advisory) ─────────────────────────
|
# ── #652: ДКП-коридор реальных сделок (advisory) ─────────────────────────
|
||||||
|
|
@ -3196,6 +3468,7 @@ def _price_from_inputs(
|
||||||
sources_used_pre=sources_used_pre,
|
sources_used_pre=sources_used_pre,
|
||||||
listings_clean=listings_clean,
|
listings_clean=listings_clean,
|
||||||
cv=cv,
|
cv=cv,
|
||||||
|
listings_headline_thin_n=listings_headline_thin_n,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -3245,12 +3518,13 @@ async def estimate_quality(
|
||||||
detail="consent required for anonymous estimate request",
|
detail="consent required for anonymous estimate request",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 1. Geocode (#654: time-budgeted — Yandex/Nominatim retry chain can stack
|
# 1. Geocode (#654: time-budgeted — Nominatim retry chain can stack
|
||||||
# multiple network round-trips + 1s Nominatim rate-limit sleeps).
|
# multiple network round-trips + 1s Nominatim rate-limit sleeps).
|
||||||
geo: GeocodeResult | None = None
|
geo: GeocodeResult | None = None
|
||||||
# Variant A: trust client-provided coords (resolved by autocomplete/map) when present
|
# Variant A: trust client-provided coords (resolved by autocomplete/map) when present
|
||||||
# and inside the oblast bbox — skips the geocode() chain that fails on DaData-format
|
# and inside the oblast bbox — skips the geocode() chain that fails on DaData-format
|
||||||
# addresses with the Yandex key dead. Out-of-bbox / partial → ignore, geocode normally.
|
# addresses (#2593: Yandex Geocoder, the previous fallback for those, removed).
|
||||||
|
# Out-of-bbox / partial → ignore, geocode normally.
|
||||||
# (oblast C2): was tight EKB-only bbox (60.40-60.85 / 56.65-56.95) — widened to
|
# (oblast C2): was tight EKB-only bbox (60.40-60.85 / 56.65-56.95) — widened to
|
||||||
# geocoder.is_within_oblast66_bbox (region 66) so client-coords from oblast towns also
|
# geocoder.is_within_oblast66_bbox (region 66) so client-coords from oblast towns also
|
||||||
# get this perf fast-path instead of always paying the geocode() round-trip. Perf-only,
|
# get this perf fast-path instead of always paying the geocode() round-trip. Perf-only,
|
||||||
|
|
@ -3274,8 +3548,12 @@ async def estimate_quality(
|
||||||
payload.lon,
|
payload.lon,
|
||||||
)
|
)
|
||||||
if geo is None and payload.address:
|
if geo is None and payload.address:
|
||||||
|
# #2576: city_hint прокидывается из payload — БЕЗ него geocode() больше не
|
||||||
|
# подставляет "Екатеринбург" молча (см. app.services.geocoder). Опционально:
|
||||||
|
# фронт пока (до отдельного изменения UI) его не шлёт, geo.city_ambiguous
|
||||||
|
# честно сигнализирует об этом ниже.
|
||||||
geo = await _with_budget(
|
geo = await _with_budget(
|
||||||
geocode(payload.address, db),
|
geocode(payload.address, db, city_hint=payload.city_hint),
|
||||||
settings.estimate_geocode_budget_s,
|
settings.estimate_geocode_budget_s,
|
||||||
label="geocode",
|
label="geocode",
|
||||||
)
|
)
|
||||||
|
|
@ -3380,6 +3658,16 @@ async def estimate_quality(
|
||||||
if target_house_type is None:
|
if target_house_type is None:
|
||||||
target_house_type = house_meta.house_type
|
target_house_type = house_meta.house_type
|
||||||
|
|
||||||
|
# 2b. Mera-audit 2026-08-02: неправдоподобный год (payload user-input ge=1800/le=2100 в схеме,
|
||||||
|
# либо house_metadata OSM/кадастр — прод-инцидент year_built=1829) — на
|
||||||
|
# «неизвестен» ДО того как target_year уйдёт в cohort-фильтр (ниже),
|
||||||
|
# _fetch_analogs house-match scoring и хедонический фактор
|
||||||
|
# (_price_from_inputs, #2002). Единая точка входа — все три места ниже
|
||||||
|
# используют этот же target_year.
|
||||||
|
target_year = _sanitize_build_year(
|
||||||
|
target_year, house_id=target_house_id, address=payload.address
|
||||||
|
)
|
||||||
|
|
||||||
# 3. Four-tier fallback (PR 9 — added Tier 0 with cohort filter):
|
# 3. Four-tier fallback (PR 9 — added Tier 0 with cohort filter):
|
||||||
# 0) 1km + ±15% area + cohort match (year_built — если задан)
|
# 0) 1km + ±15% area + cohort match (year_built — если задан)
|
||||||
# a) 1km + ±15% area (без cohort — drop fallback)
|
# a) 1km + ±15% area (без cohort — drop fallback)
|
||||||
|
|
@ -3392,6 +3680,13 @@ async def estimate_quality(
|
||||||
# радиус (он же — максимум, без авто-расширения за пределы выбранного).
|
# радиус (он же — максимум, без авто-расширения за пределы выбранного).
|
||||||
base_radius_m = payload.radius_m or DEFAULT_RADIUS_M
|
base_radius_m = payload.radius_m or DEFAULT_RADIUS_M
|
||||||
fallback_radius_m = payload.radius_m or FALLBACK_RADIUS_M
|
fallback_radius_m = payload.radius_m or FALLBACK_RADIUS_M
|
||||||
|
# #2632: фактический радиус, по которому реально отобраны listings-аналоги
|
||||||
|
# (в отличие от payload.radius_m — выбор пользователя в дропдауне). Стартует
|
||||||
|
# с base_radius_m, переключается на fallback_radius_m в тех же ветках, что
|
||||||
|
# выставляют fallback_used ниже (см. _compute_confidence "расширили радиус"
|
||||||
|
# note) — держим оба сигнала консистентными по построению. Прокидывается в
|
||||||
|
# AggregatedEstimate.search_radius_m для карты (ParamsPanel circle, #2632).
|
||||||
|
search_radius_m = base_radius_m
|
||||||
cohort_range = _target_cohort_range(target_year)
|
cohort_range = _target_cohort_range(target_year)
|
||||||
|
|
||||||
if cohort_range is not None:
|
if cohort_range is not None:
|
||||||
|
|
@ -3456,6 +3751,7 @@ async def estimate_quality(
|
||||||
listings = listings_wide
|
listings = listings_wide
|
||||||
fallback_used = True
|
fallback_used = True
|
||||||
analog_tier = analog_tier_wide
|
analog_tier = analog_tier_wide
|
||||||
|
search_radius_m = fallback_radius_m
|
||||||
|
|
||||||
# Tier C: если даже на 2км мало — расширяем area tolerance до ±25%
|
# Tier C: если даже на 2км мало — расширяем area tolerance до ±25%
|
||||||
# (актуально для отдалённых районов / новостроек с нестандартной планировкой)
|
# (актуально для отдалённых районов / новостроек с нестандартной планировкой)
|
||||||
|
|
@ -3480,6 +3776,7 @@ async def estimate_quality(
|
||||||
fallback_used = True
|
fallback_used = True
|
||||||
area_widened = True
|
area_widened = True
|
||||||
analog_tier = analog_tier_wa
|
analog_tier = analog_tier_wa
|
||||||
|
search_radius_m = fallback_radius_m
|
||||||
|
|
||||||
# ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ──────────────
|
# ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ──────────────
|
||||||
# #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы
|
# #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы
|
||||||
|
|
@ -3627,7 +3924,8 @@ async def estimate_quality(
|
||||||
def _ratio_resolver(
|
def _ratio_resolver(
|
||||||
appm2: float | None,
|
appm2: float | None,
|
||||||
) -> tuple[float | None, str | None]:
|
) -> tuple[float | None, str | None]:
|
||||||
return _get_asking_sold_ratio(db, payload.rooms, anchor_ppm2=appm2)
|
# #2620-2: area-bucket key (payload.rooms — фолбэк только без площади).
|
||||||
|
return _get_asking_sold_ratio(db, payload.rooms, payload.area_m2, anchor_ppm2=appm2)
|
||||||
|
|
||||||
def _qi_lookup(q: str) -> tuple[float, int] | None:
|
def _qi_lookup(q: str) -> tuple[float, int] | None:
|
||||||
return _lookup_quarter_index(
|
return _lookup_quarter_index(
|
||||||
|
|
@ -3692,6 +3990,7 @@ async def estimate_quality(
|
||||||
ratio_basis = pr.ratio_basis
|
ratio_basis = pr.ratio_basis
|
||||||
listings_clean = pr.listings_clean
|
listings_clean = pr.listings_clean
|
||||||
cv = pr.cv
|
cv = pr.cv
|
||||||
|
listings_headline_thin_n = pr.listings_headline_thin_n
|
||||||
|
|
||||||
# 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals.
|
# 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals.
|
||||||
# Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков
|
# Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков
|
||||||
|
|
@ -3727,6 +4026,14 @@ async def estimate_quality(
|
||||||
# иначе «обновлено N мин назад»/дата парсинга/срок продажи относятся к другому
|
# иначе «обновлено N мин назад»/дата парсинга/срок продажи относятся к другому
|
||||||
# набору (или = None при пустом listings_clean, хотя у комплов данные есть).
|
# набору (или = None при пустом listings_clean, хотя у комплов данные есть).
|
||||||
metadata_lots = display_pool
|
metadata_lots = display_pool
|
||||||
|
elif listings_headline_thin_n:
|
||||||
|
# #oblast-E: headline was suppressed (thin radius sample, no anchor to
|
||||||
|
# take over) — do NOT surface those same listings as display cards
|
||||||
|
# either, else `analogs` would show N cards while n_analogs==0 (broken
|
||||||
|
# invariant, same dishonesty this gate exists to remove). Degrades to
|
||||||
|
# the exact same empty-display state as "genuinely zero listings".
|
||||||
|
analogs_lots = []
|
||||||
|
metadata_lots = []
|
||||||
else:
|
else:
|
||||||
# display-consistency fix: только ЦЕНОВЫЕ листинги — та же популяция, что
|
# display-consistency fix: только ЦЕНОВЫЕ листинги — та же популяция, что
|
||||||
# дала n_analogs = len(prices_ppm2) в radius-ветке _price_from_inputs.
|
# дала n_analogs = len(prices_ppm2) в radius-ветке _price_from_inputs.
|
||||||
|
|
@ -3984,6 +4291,7 @@ async def estimate_quality(
|
||||||
target_address=geo.full_address,
|
target_address=geo.full_address,
|
||||||
target_lat=geo.lat,
|
target_lat=geo.lat,
|
||||||
target_lon=geo.lon,
|
target_lon=geo.lon,
|
||||||
|
target_city_ambiguous=geo.city_ambiguous,
|
||||||
sources_used=sources_used,
|
sources_used=sources_used,
|
||||||
data_freshness_minutes=freshness_min,
|
data_freshness_minutes=freshness_min,
|
||||||
last_scraped_at=last_scraped_at,
|
last_scraped_at=last_scraped_at,
|
||||||
|
|
@ -4033,6 +4341,11 @@ async def estimate_quality(
|
||||||
metro_nearest=(dadata.metro if dadata and dadata.metro else []),
|
metro_nearest=(dadata.metro if dadata and dadata.metro else []),
|
||||||
address_precision=_qc_geo_to_precision(dadata.qc_geo if dadata else None),
|
address_precision=_qc_geo_to_precision(dadata.qc_geo if dadata else None),
|
||||||
analog_tier=api_analog_tier, # type: ignore[arg-type]
|
analog_tier=api_analog_tier, # type: ignore[arg-type]
|
||||||
|
# #2632: фактический радиус отбора listings-аналогов (см. search_radius_m
|
||||||
|
# def выше) — может отличаться от payload.radius_m (выбор пользователя),
|
||||||
|
# когда сервер сам расширил поиск. None только у _empty_estimate (поиск
|
||||||
|
# аналогов вообще не выполнялся).
|
||||||
|
search_radius_m=search_radius_m,
|
||||||
premium_building=premium_building,
|
premium_building=premium_building,
|
||||||
premium_building_median_ppm2=premium_building_median_ppm2,
|
premium_building_median_ppm2=premium_building_median_ppm2,
|
||||||
premium_building_class=premium_building_class,
|
premium_building_class=premium_building_class,
|
||||||
|
|
@ -5621,11 +5934,6 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]:
|
||||||
return street, house
|
return street, house
|
||||||
|
|
||||||
|
|
||||||
def _extract_street_token(addr: str | None) -> str:
|
|
||||||
"""Нормализованный уличный токен для дедуп-ключа (#2265). См. _parse_street_house."""
|
|
||||||
return _parse_street_house(addr)[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _lot_dedup_components(
|
def _lot_dedup_components(
|
||||||
lot: dict[str, Any],
|
lot: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
|
|
@ -5663,15 +5971,13 @@ def _lot_dedup_components(
|
||||||
return cad_s, house, cad_key, street_key
|
return cad_s, house, cad_key, street_key
|
||||||
|
|
||||||
|
|
||||||
def _phys_dedup_key(lot: dict[str, Any]) -> tuple[str, Any, int, int] | None:
|
# #2674: здесь жили `_phys_dedup_key` и `_extract_street_token` — однострочные обёртки
|
||||||
"""Первичный физический ключ (building, floor, area_bucket, price_bucket).
|
# над _lot_dedup_components / _parse_street_house. Прод не звал ни ту, ни другую ни разу
|
||||||
|
# (25 ссылок, все из тестов). Хуже: _phys_dedup_key утверждала правило «первичный ключ =
|
||||||
building = cadnum (надёжнее) ИЛИ street_token (#2265). None, если нет
|
# кадастр ИЛИ улица», которого в проде нет — живой путь (_union_find_phys_dedup) держит
|
||||||
площади/цены или не из чего построить building. Сохраняет 4-кортежную форму
|
# ОБА композита и сливает по любому совпадению, с guard'ами на разные кадастры/номера
|
||||||
(canonical-ключ; union-find в _dedup_cross_source использует оба композита).
|
# домов. Тесты, проверявшие обёртку, проверяли не тот алгоритм; они переведены на живые
|
||||||
"""
|
# функции (tests/test_estimator_dedup_cross_source_2087.py).
|
||||||
_cad_s, _house, cad_key, street_key = _lot_dedup_components(lot)
|
|
||||||
return cad_key or street_key
|
|
||||||
|
|
||||||
|
|
||||||
def _dedup_rep_key(lot: dict[str, Any]) -> tuple[float, int, str, str]:
|
def _dedup_rep_key(lot: dict[str, Any]) -> tuple[float, int, str, str]:
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -132,9 +132,16 @@ _COMPLETENESS_EXPR = """
|
||||||
|
|
||||||
# Keeper ORDER BY, shared by the ROW_NUMBER() rank and the first_value() keeper pick so they
|
# Keeper ORDER BY, shared by the ROW_NUMBER() rank and the first_value() keeper pick so they
|
||||||
# agree row-for-row. Priority: geom present → most linked listings → most-populated → min id.
|
# agree row-for-row. Priority: geom present → most linked listings → most-populated → min id.
|
||||||
|
#
|
||||||
|
# NULLS LAST на listing_cnt (#2674): счётчик приходит из LEFT JOIN listing_counts, поэтому у дома
|
||||||
|
# БЕЗ объявлений он NULL, а `DESC` в Postgres по умолчанию NULLS FIRST — то есть строка с нулём
|
||||||
|
# объявлений обгоняла строку со 192 и забирала роль keeper'а, ровно наоборот задокументированному
|
||||||
|
# правилу. Последствие не косметическое: объявления проигравшего переезжают на запись, на которую
|
||||||
|
# корпус никогда не ссылался, а COALESCE-перенос полей неполон (год постройки / тип дома /
|
||||||
|
# этажность / застройщик не переносятся) — данные богатого проигравшего удаляются безвозвратно.
|
||||||
_KEEPER_ORDER = f"""
|
_KEEPER_ORDER = f"""
|
||||||
(h.geom IS NOT NULL) DESC,
|
(h.geom IS NOT NULL) DESC,
|
||||||
listing_cnt DESC,
|
listing_cnt DESC NULLS LAST,
|
||||||
({_COMPLETENESS_EXPR}) DESC,
|
({_COMPLETENESS_EXPR}) DESC,
|
||||||
h.id ASC
|
h.id ASC
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ from dataclasses import dataclass, field
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||||
|
from scraper_kit.house_type_normalizer import normalize_house_type
|
||||||
|
|
||||||
# #2337 (Group E4, эпик #2277): переключено на scraper_kit — тот же периметр риска,
|
# #2337 (Group E4, эпик #2277): переключено на scraper_kit — тот же периметр риска,
|
||||||
# что и estimator.py (обе точки читают/пишут house_imv_evaluations, #651 IMV/Yandex
|
# что и estimator.py (обе точки читают/пишут house_imv_evaluations, #651 IMV/Yandex
|
||||||
|
|
@ -65,22 +66,76 @@ _HEARTBEAT_EVERY_N_HOUSES = 5
|
||||||
|
|
||||||
# ── house_type normalisation ─────────────────────────────────────────────────
|
# ── house_type normalisation ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Ключи — КАНОНИЧНЫЕ значения listings.house_type (после normalize_house_type),
|
||||||
|
# значения — вокабуляр Avito IMV.
|
||||||
_HOUSE_TYPE_TO_IMV: dict[str, str] = {
|
_HOUSE_TYPE_TO_IMV: dict[str, str] = {
|
||||||
"panel": "panel",
|
"panel": "panel",
|
||||||
"brick": "brick",
|
"brick": "brick",
|
||||||
"monolith": "monolithic",
|
"monolith": "monolithic",
|
||||||
"monolithic": "monolithic",
|
|
||||||
"monolith_brick": "monolithic", # Avito API не принимает гибриды
|
"monolith_brick": "monolithic", # Avito API не принимает гибриды
|
||||||
"block": "block",
|
"block": "block",
|
||||||
"wood": "wood",
|
"wood": "wood",
|
||||||
}
|
}
|
||||||
_HOUSE_TYPE_DEFAULT = "panel" # самый распространённый в ЕКБ
|
|
||||||
|
|
||||||
|
|
||||||
def _map_house_type(raw: str | None) -> str:
|
def _map_house_type(raw: str | None) -> str | None:
|
||||||
if not raw:
|
"""Наш house_type → вокабуляр Avito IMV. None = тип неизвестен, запрос не шлём.
|
||||||
return _HOUSE_TYPE_DEFAULT
|
|
||||||
return _HOUSE_TYPE_TO_IMV.get(raw.lower().strip(), _HOUSE_TYPE_DEFAULT)
|
Сырое значение сначала прогоняем через общий normalize_house_type (#2007): он
|
||||||
|
знает camelCase-вокабуляр Циана (monolithBrick / gasSilicateBlock /
|
||||||
|
aerocreteBlock / stalin / ...) и SCREAMING-вокабуляр Яндекса, а нераспознанное
|
||||||
|
('other', 'wireframe', пустое) схлопывает в None. Приведения к нижнему регистру
|
||||||
|
тут мало: ключ канона пишется через подчёркивание (monolith_brick), поэтому
|
||||||
|
'monolithbrick' в словарь не попадал.
|
||||||
|
|
||||||
|
#2674: раньше здесь стоял дефолт 'panel' — и когда типа нет вовсе, и когда он
|
||||||
|
есть, но не распознан. Панель — почти самый дешёвый класс (медиана по нашим же
|
||||||
|
2685 оценкам: block 122.6k < panel 128.8k < brick 131.1k < monolithic 145.9k
|
||||||
|
₽/м²), то есть дефолт систематически ЗАНИЖАЛ оценку: на проде 363 дома совсем
|
||||||
|
без типа + 75 домов с camelCase-типом (56 из них monolithBrick, −11.7% к
|
||||||
|
monolithic) уехали как панель. Теперь неизвестный тип → None → дом помечается
|
||||||
|
и запрос к площадке не тратится (см. _process_one_house).
|
||||||
|
"""
|
||||||
|
canon = normalize_house_type(raw)
|
||||||
|
if canon is None:
|
||||||
|
return None
|
||||||
|
return _HOUSE_TYPE_TO_IMV.get(canon)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_renovation_type(repair_state: str | None) -> str:
|
||||||
|
"""listings.repair_state → renovation_type вокабуляра Avito IMV.
|
||||||
|
|
||||||
|
Переиспользуем _IMV_REPAIR_MAP эстиматора — единственный источник правды для
|
||||||
|
этого соответствия (needs_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 ────────────────────────────────────
|
# ── Region bbox prefix для Avito geocoder ────────────────────────────────────
|
||||||
|
|
@ -135,7 +190,8 @@ def pick_lot_params(db: Session, house_id: int) -> dict:
|
||||||
AS integer) AS floor,
|
AS integer) AS floor,
|
||||||
CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY total_floors)
|
CAST(percentile_cont(0.5) WITHIN GROUP (ORDER BY total_floors)
|
||||||
AS integer) AS total_floors,
|
AS integer) AS total_floors,
|
||||||
mode() WITHIN GROUP (ORDER BY house_type) AS house_type
|
mode() WITHIN GROUP (ORDER BY house_type) AS house_type,
|
||||||
|
mode() WITHIN GROUP (ORDER BY repair_state) AS repair_state
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE house_id_fk = :hid
|
WHERE house_id_fk = :hid
|
||||||
AND rooms IS NOT NULL
|
AND rooms IS NOT NULL
|
||||||
|
|
@ -173,7 +229,12 @@ def pick_lot_params(db: Session, house_id: int) -> dict:
|
||||||
"floor": floor,
|
"floor": floor,
|
||||||
"floor_at_home": floor_at_home,
|
"floor_at_home": floor_at_home,
|
||||||
"house_type": _map_house_type(row["house_type"] or (house and house["house_type"])),
|
"house_type": _map_house_type(row["house_type"] or (house and house["house_type"])),
|
||||||
"renovation_type": "cosmetic",
|
"renovation_type": _map_renovation_type(row["repair_state"]),
|
||||||
|
# has_balcony/has_loggia остаются константами намеренно (#2674): покрытие
|
||||||
|
# listings.has_balcony 13.8%, listings.balcony_loggia 9.4%, и две колонки
|
||||||
|
# противоречат друг другу (по has_balcony «есть» у 62%, а по
|
||||||
|
# balcony_loggia самый частый случай — loggia 5650 против balcony 2794).
|
||||||
|
# Мода по одному-двум объявлениям на таком покрытии — шум, а не данные.
|
||||||
"has_balcony": True,
|
"has_balcony": True,
|
||||||
"has_loggia": False,
|
"has_loggia": False,
|
||||||
}
|
}
|
||||||
|
|
@ -284,26 +345,37 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Suggestions
|
# 3. Suggestions
|
||||||
|
# #2674: до этого фикса в INSERT не входили image_link + area_m2/rooms/floor/
|
||||||
|
# total_floors — колонки есть с миграции 064, но писатель их не заполнял
|
||||||
|
# (25 055 строк на проде с NULL во всех пяти). Ссылка на фото приходит в
|
||||||
|
# suggestions.items[].imageLink, метрики квартиры парсятся из title.
|
||||||
for sug in result.suggestions:
|
for sug in result.suggestions:
|
||||||
db.execute(
|
db.execute(
|
||||||
text("""
|
text("""
|
||||||
INSERT INTO house_suggestions (
|
INSERT INTO house_suggestions (
|
||||||
house_id, ext_item_id, title, address, price_rub,
|
house_id, ext_item_id, title, address, price_rub,
|
||||||
|
area_m2, rooms, floor, total_floors,
|
||||||
exposure_days, publish_date,
|
exposure_days, publish_date,
|
||||||
item_link, metro_name, metro_distance,
|
item_link, image_link, metro_name, metro_distance,
|
||||||
has_good_price_badge, raw_payload, fetched_at
|
has_good_price_badge, raw_payload, fetched_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:hid, :ext, :title, :addr, :price,
|
:hid, :ext, :title, :addr, :price,
|
||||||
|
CAST(:area AS numeric), :rooms, :floor, :total_floors,
|
||||||
:exp, :pdate,
|
:exp, :pdate,
|
||||||
:link, :mname, :mdist,
|
:link, :img, :mname, :mdist,
|
||||||
:gpb, CAST(:raw AS jsonb), NOW()
|
:gpb, CAST(:raw AS jsonb), NOW()
|
||||||
)
|
)
|
||||||
ON CONFLICT (house_id, ext_item_id) DO UPDATE SET
|
ON CONFLICT (house_id, ext_item_id) DO UPDATE SET
|
||||||
title = EXCLUDED.title,
|
title = EXCLUDED.title,
|
||||||
price_rub = EXCLUDED.price_rub,
|
price_rub = EXCLUDED.price_rub,
|
||||||
|
area_m2 = EXCLUDED.area_m2,
|
||||||
|
rooms = EXCLUDED.rooms,
|
||||||
|
floor = EXCLUDED.floor,
|
||||||
|
total_floors = EXCLUDED.total_floors,
|
||||||
exposure_days = EXCLUDED.exposure_days,
|
exposure_days = EXCLUDED.exposure_days,
|
||||||
publish_date = EXCLUDED.publish_date,
|
publish_date = EXCLUDED.publish_date,
|
||||||
item_link = EXCLUDED.item_link,
|
item_link = EXCLUDED.item_link,
|
||||||
|
image_link = EXCLUDED.image_link,
|
||||||
metro_name = EXCLUDED.metro_name,
|
metro_name = EXCLUDED.metro_name,
|
||||||
metro_distance = EXCLUDED.metro_distance,
|
metro_distance = EXCLUDED.metro_distance,
|
||||||
has_good_price_badge = EXCLUDED.has_good_price_badge,
|
has_good_price_badge = EXCLUDED.has_good_price_badge,
|
||||||
|
|
@ -316,9 +388,14 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat
|
||||||
"title": sug.title,
|
"title": sug.title,
|
||||||
"addr": sug.address,
|
"addr": sug.address,
|
||||||
"price": sug.price_rub,
|
"price": sug.price_rub,
|
||||||
|
"area": sug.area_m2,
|
||||||
|
"rooms": sug.rooms,
|
||||||
|
"floor": sug.floor,
|
||||||
|
"total_floors": sug.total_floors,
|
||||||
"exp": sug.exposure_days,
|
"exp": sug.exposure_days,
|
||||||
"pdate": sug.publish_date,
|
"pdate": sug.publish_date,
|
||||||
"link": sug.item_url,
|
"link": sug.item_url,
|
||||||
|
"img": sug.image_link,
|
||||||
"mname": sug.metro_name,
|
"mname": sug.metro_name,
|
||||||
"mdist": sug.metro_distance,
|
"mdist": sug.metro_distance,
|
||||||
"gpb": sug.has_good_price_badge,
|
"gpb": sug.has_good_price_badge,
|
||||||
|
|
@ -500,8 +577,33 @@ async def backfill_house_imv(
|
||||||
# + прокси переиспользуются всеми домами; обходит datacenter-403, #562/#853).
|
# + прокси переиспользуются всеми домами; обходит datacenter-403, #562/#853).
|
||||||
# Флаг OFF → _bf=None → evaluate_via_imv делает свою curl-сессию как раньше
|
# Флаг OFF → _bf=None → evaluate_via_imv делает свою curl-сессию как раньше
|
||||||
# (поведение байт-в-байт идентично доспринтовому).
|
# (поведение байт-в-байт идентично доспринтовому).
|
||||||
|
#
|
||||||
|
# #2698: proxy_provider/use_pool/environment — обязательная часть проводки, а не
|
||||||
|
# опция. Без них BrowserFetcher не кладёт "proxy" в тело POST /fetch-json, и сайдкар
|
||||||
|
# берёт свой env-прокси SCRAPER_PROXY_URL — на проде это узел пула id=1
|
||||||
|
# (asocks-residential-1, provider_affinity='domclick'), который proxy_pool.acquire
|
||||||
|
# («affinity IN (provider,'any')» + защита последнего узла выделенной affinity от
|
||||||
|
# fallback) для avito не выдал бы НИКОГДА. Результат: 03.07-05.08 все 35 из 35 попыток
|
||||||
|
# каждого прогона падали на геокодере A (1240 домов — 503 «browser unavailable», затем
|
||||||
|
# 500 «Page.goto: NS_ERROR_PROXY_BAD_GATEWAY» и 403 от самого Авито), пока
|
||||||
|
# avito_city_sweep/avito_newbuilding_sweep в те же дни тянули сотни объявлений через
|
||||||
|
# ТОТ ЖЕ сайдкар и тот же инстанс камуфокса — они пул подключают (pipeline.py). Хуже:
|
||||||
|
# запрос без "proxy" в теле ещё и роняет сайдкару желаемый прокси на env → relaunch
|
||||||
|
# камуфокса на каждый дом (server.py::_ensure_browser).
|
||||||
if settings.avito_imv_use_browser_fetcher:
|
if settings.avito_imv_use_browser_fetcher:
|
||||||
async with BrowserFetcher(source="avito", endpoint=settings.browser_http_endpoint) as _bf:
|
# lazy import — тот же цикл scraper_adapters↔этот модуль, что и у RealScraperConfig.
|
||||||
|
from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
|
||||||
|
|
||||||
|
_cfg = RealScraperConfig()
|
||||||
|
async with BrowserFetcher(
|
||||||
|
source="avito",
|
||||||
|
endpoint=settings.browser_http_endpoint,
|
||||||
|
proxy_provider=RealProxyProvider(),
|
||||||
|
use_pool=_cfg.use_proxy_pool_browser,
|
||||||
|
# #2616 шаг 1: без environment прод-отказ «пул пуст» мёртв на этом пути —
|
||||||
|
# фетчер молча ушёл бы на тот самый env-прокси (см. _acquire_lease).
|
||||||
|
environment=_cfg.environment,
|
||||||
|
) as _bf:
|
||||||
await _run_loop(_bf)
|
await _run_loop(_bf)
|
||||||
else:
|
else:
|
||||||
await _run_loop(None)
|
await _run_loop(None)
|
||||||
|
|
@ -652,6 +754,14 @@ async def _process_one_house(
|
||||||
_mark_status(db, hid, "no_params", "no listings with rooms+area")
|
_mark_status(db, hid, "no_params", "no listings with rooms+area")
|
||||||
return "no_params"
|
return "no_params"
|
||||||
|
|
||||||
|
# #2674: тип дома неизвестен (нет ни в объявлениях, ни в houses — либо
|
||||||
|
# вокабуляр не распознан). Раньше такой дом молча уезжал как 'panel' и
|
||||||
|
# занижал оценку. Лучше не тратить запрос и честно пометить дом — тот же
|
||||||
|
# путь, что и при отсутствии комнат/площади.
|
||||||
|
if params["house_type"] is None:
|
||||||
|
_mark_status(db, hid, "no_params", "unknown house_type")
|
||||||
|
return "no_params"
|
||||||
|
|
||||||
address = house.get("address") or house.get("full_address")
|
address = house.get("address") or house.get("full_address")
|
||||||
if not address:
|
if not address:
|
||||||
_mark_status(db, hid, "no_address", "house.address is NULL")
|
_mark_status(db, hid, "no_address", "house.address is NULL")
|
||||||
|
|
|
||||||
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 ≠ цена).
|
POI-score его не улавливал (POI ranking ≠ цена).
|
||||||
|
|
||||||
НОВЫЙ ПОКАЗАТЕЛЬ (location index):
|
НОВЫЙ ПОКАЗАТЕЛЬ (location index):
|
||||||
location_index_pct = (медиана ₽/м² сопоставимых активных листингов в радиусе точки −
|
location_index_pct = (медиана ₽/м² сопоставимых листингов в радиусе точки −
|
||||||
медиана ₽/м² по всему ЕКБ) / медиана по ЕКБ * 100
|
медиана ₽/м² по всему ЕКБ) / медиана по ЕКБ * 100
|
||||||
|
|
||||||
|
«Сопоставимые» = ровно тот же пул, что берёт эстиматор (#2660): активные И свежие
|
||||||
|
(scraped_at в пределах LISTINGS_FRESH_DAYS — `is_active` на проде не равно «живо») И
|
||||||
|
только вторичка (гард #1186 — девелоперский прайс новостроек завышал обе медианы).
|
||||||
|
|
||||||
Самообновляем (те же `listings`, что уже скрейпятся под estimator), интерпретируем напрямую
|
Самообновляем (те же `listings`, что уже скрейпятся под estimator), интерпретируем напрямую
|
||||||
("район на N% дороже/дешевле среднего по городу"), устойчив к выбросам (percentile_cont(0.5) —
|
("район на N% дороже/дешевле среднего по городу"), устойчив к выбросам (percentile_cont(0.5) —
|
||||||
медиана самой природой игнорирует единичные экстремумы, в отличие от mean/min/max), и НЕ зажат
|
медиана самой природой игнорирует единичные экстремумы, в отличие от mean/min/max), и НЕ зажат
|
||||||
|
|
@ -45,6 +49,11 @@ from typing import Any
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# #2660: окно свежести берём ИЗ эстиматора — единственное определение в проекте.
|
||||||
|
# Дублировать значение здесь нельзя: две константы разъедутся при первой же
|
||||||
|
# перекалибровке, и витрина начнёт показывать другой пул, чем считает цена.
|
||||||
|
from app.services.estimator import LISTINGS_FRESH_DAYS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Гео-охват продукта: только Екатеринбург ──────────────────────────────────
|
# ── Гео-охват продукта: только Екатеринбург ──────────────────────────────────
|
||||||
|
|
@ -159,6 +168,43 @@ def _pct_deviation(local_median_ppm2: float, city_median_ppm2: float) -> float:
|
||||||
# price_per_m2 BETWEEN sanity-границы — не бизнес-калибровка, а защита от битых строк
|
# price_per_m2 BETWEEN sanity-границы — не бизнес-калибровка, а защита от битых строк
|
||||||
# (см. _PRICE_PER_M2_SANITY_MIN/MAX выше).
|
# (см. _PRICE_PER_M2_SANITY_MIN/MAX выше).
|
||||||
#
|
#
|
||||||
|
# #2660 свежесть + сегмент — оба предиката ЗЕРКАЛЯТ _COMMON_WHERE эстиматора.
|
||||||
|
# Вклад у них РАЗНЫЙ, и не тот, на который легко подумать. Прод-разложение
|
||||||
|
# (2026-08-05, пул location_index — bbox ЕКБ + sanity ₽/м² + geo_precision):
|
||||||
|
#
|
||||||
|
# было (только is_active) 30 222 строк 172 984 ₽/м²
|
||||||
|
# + только свежесть 11 453 строк 163 363 ₽/м²
|
||||||
|
# + только сегмент 11 219 строк 147 632 ₽/м²
|
||||||
|
# стало (оба) 7 715 строк 147 368 ₽/м²
|
||||||
|
#
|
||||||
|
# - listing_segment guard (#1186) — ЭТО и есть исправление смещения: из −14.8%
|
||||||
|
# сдвига городской медианы он даёт −14.7 п.п. Девелоперский прайс новостроек
|
||||||
|
# завышал и локальную, и городскую медиану. NULL = legacy вторичка до м.011.
|
||||||
|
# Мертвецы, кстати, живут почти целиком тут же: из 18 769 протухших строк
|
||||||
|
# пула 15 265 — новостройки, и гард выносит их заодно.
|
||||||
|
# - scraped_at > NOW() - LISTINGS_FRESH_DAYS — даёт ПОВЕРХ сегмента всего
|
||||||
|
# −0.18 п.п. Для ЭТОЙ метрики он не коррекция смещения, а СТРАХОВКА на
|
||||||
|
# будущее (пул совпадает с пулом цены; если завтра протухнет вторичка —
|
||||||
|
# виджет не соврёт), и страховка не бесплатная: выбрасывает 3 504 вторичных
|
||||||
|
# строки, из которых 2 724 — живые объявления, отскрейпленные 15-30 дней
|
||||||
|
# назад. Пул −31%, шум растёт: на центре ЕКБ (r=800) n падает 423 → 86, а
|
||||||
|
# сам индекс гуляет по выбору окна на 12-14 п.п. (7д +75.7% / 14д +77.0% /
|
||||||
|
# 21д +79.1% / 30д +64.7%) — при n=86 это в пределах шума выборки медианы.
|
||||||
|
# Размен «меньше смещения ↔ больше дисперсии» сделан осознанно: старое число
|
||||||
|
# было предвзятым, новое — шумным, но честным. Окно менять здесь НЕ надо,
|
||||||
|
# LISTINGS_FRESH_DAYS живёт в estimator.py (см. импорт выше).
|
||||||
|
#
|
||||||
|
# НОВЫЙ РЕЖИМ ОТКАЗА (знать обязательно): свежесть связала витрину со здоровьем
|
||||||
|
# СБОРА. Встанет скрейпинг на LISTINGS_FRESH_DAYS — городская выборка не наберёт
|
||||||
|
# MIN_SAMPLE_SIZE, и "insufficient_data" прилетит ВСЕМ пользователям разом; до
|
||||||
|
# этой правки виджет продолжал бы показывать устаревшее число. Учитывая, что
|
||||||
|
# #2574 — ровно месяц молчаливой поломки сбора, сценарий не гипотетический.
|
||||||
|
# Деградация честная (прочерк, а не выдуманное число), но она теперь массовая.
|
||||||
|
#
|
||||||
|
# Порог MIN_SAMPLE_SIZE после сужения пула набирается реже, но лестница радиусов
|
||||||
|
# упирается в отказ редко — прод-симуляция на 246 реальных точках оценок:
|
||||||
|
# insufficient_data 0 → 1 точка (0.4%), 800м хватает 241 точке из 246.
|
||||||
|
#
|
||||||
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если
|
# bbox-фильтр (lat/lon) — сопоставимые листинги считаются ТОЛЬКО по Екатеринбургу, даже если
|
||||||
# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index
|
# сам продукт уже скрейпит соседние города области (city-sweep): географию location_index
|
||||||
# явно ограничил владелец продукта.
|
# явно ограничил владелец продукта.
|
||||||
|
|
@ -173,6 +219,8 @@ _MEDIAN_PPM2_LOCAL_SQL = text(
|
||||||
AND price_per_m2 IS NOT NULL
|
AND price_per_m2 IS NOT NULL
|
||||||
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
|
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
|
||||||
AND (geo_precision IS DISTINCT FROM 'city')
|
AND (geo_precision IS DISTINCT FROM 'city')
|
||||||
|
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||||
|
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||||||
AND CAST(:bbox_north AS double precision)
|
AND CAST(:bbox_north AS double precision)
|
||||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||||
|
|
@ -196,6 +244,8 @@ _MEDIAN_PPM2_CITYWIDE_SQL = text(
|
||||||
AND price_per_m2 IS NOT NULL
|
AND price_per_m2 IS NOT NULL
|
||||||
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
|
AND price_per_m2 BETWEEN CAST(:price_min AS integer) AND CAST(:price_max AS integer)
|
||||||
AND (geo_precision IS DISTINCT FROM 'city')
|
AND (geo_precision IS DISTINCT FROM 'city')
|
||||||
|
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||||
|
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||||||
AND CAST(:bbox_north AS double precision)
|
AND CAST(:bbox_north AS double precision)
|
||||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||||
|
|
@ -235,6 +285,7 @@ def _local_median_ppm2(db: Any, lat: float, lon: float, radius_m: int) -> tuple[
|
||||||
"lat": lat,
|
"lat": lat,
|
||||||
"lon": lon,
|
"lon": lon,
|
||||||
"radius_m": radius_m,
|
"radius_m": radius_m,
|
||||||
|
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||||
"bbox_south": _EKB_BBOX_SOUTH,
|
"bbox_south": _EKB_BBOX_SOUTH,
|
||||||
|
|
@ -257,6 +308,7 @@ def _citywide_median_ppm2(db: Any) -> tuple[float | None, int]:
|
||||||
db.execute(
|
db.execute(
|
||||||
_MEDIAN_PPM2_CITYWIDE_SQL,
|
_MEDIAN_PPM2_CITYWIDE_SQL,
|
||||||
{
|
{
|
||||||
|
"fresh_days": LISTINGS_FRESH_DAYS,
|
||||||
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
"price_min": _PRICE_PER_M2_SANITY_MIN,
|
||||||
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
"price_max": _PRICE_PER_M2_SANITY_MAX,
|
||||||
"bbox_south": _EKB_BBOX_SOUTH,
|
"bbox_south": _EKB_BBOX_SOUTH,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,40 @@
|
||||||
"""House cross-source matching — tiered algorithm.
|
"""House cross-source matching — tiered algorithm.
|
||||||
|
|
||||||
Tier 0 (confidence 1.0): cadastral_number exact match on houses table.
|
`match_or_create_house` (путь скрейпинга, создаёт дома):
|
||||||
Tier 0.5 (confidence 0.95): house_fias_id (ГАР OBJECTGUID) exact match, case-insensitive.
|
Tier 0 (confidence 1.0): cadastral_number exact match on houses table.
|
||||||
Tier 1 (confidence 1.0): ext_source + ext_id already in house_sources.
|
Tier 1 (confidence 1.0): ext_source + ext_id already in house_sources.
|
||||||
Tier 2 (confidence 0.9): address_fingerprint match in house_address_aliases.
|
Tier 2 (confidence 0.9): address_fingerprint match in house_address_aliases.
|
||||||
Tier 3 (confidence 0.7): geo-proximity within 30 m (PostGIS ST_DWithin).
|
Tier 3 (confidence 0.7): geo-proximity within 30 m (PostGIS ST_DWithin).
|
||||||
New (confidence 1.0): INSERT new canonical house.
|
New (confidence 1.0): INSERT new canonical house.
|
||||||
|
|
||||||
|
`match_house_readonly` (путь estimate-таргета, ничего не создаёт) дополнительно
|
||||||
|
имеет Tier 0.5 fias_exact — у него ЕСТЬ источник ФИАС (DaData /suggest в
|
||||||
|
`estimator.resolve_target_house`), см. docstring функции.
|
||||||
|
|
||||||
|
ЧЕСТНОСТЬ ТИРОВ (#2674, замер на проде 2026-08-05, 49 502 строки house_sources):
|
||||||
|
fingerprint 58.97% · new 22.65% · geo_proximity 18.36% ·
|
||||||
|
**cadastr_exact 0 · fias_exact 0** — верхние тиры не срабатывали НИ РАЗУ.
|
||||||
|
|
||||||
|
• Tier 0.5 fias_exact из `match_or_create_house` УДАЛЁН: параметра `house_fias_id`
|
||||||
|
нет ни в Protocol `scraper_kit.contracts.HouseMatcher`, ни в
|
||||||
|
`app.services.scraper_adapters.RealMatcherAdapter`, ни у двух прямых вызывающих
|
||||||
|
(`estimator._save_yandex_history_items`, `scripts/backfill_listing_sources.py`) —
|
||||||
|
передать его было НЕКОМУ. Регресс сторожит
|
||||||
|
tests/test_matching_tier_reachability_2674.py.
|
||||||
|
• Tier 0 cadastr_exact ОСТАВЛЕН: он достижим по построению (`ScrapedLot.
|
||||||
|
building_cadastral_number` → адаптер → сюда), но площадки кадастр не отдают:
|
||||||
|
`listings.cadastral_number` 0/93 408, а все 28 504 заполненных
|
||||||
|
`listings.building_cadastral_number` — на 100% из локального гео-зеркала ЕГРН
|
||||||
|
(`tasks/cadastral_geo_match.py`, KNN ≤50 м), т.е. появляются ПОСЛЕ матчинга и
|
||||||
|
обратно в матчер не подаются. Подавать их сюда НЕЛЬЗЯ: как ключ здания KNN-кадастр
|
||||||
|
не инъективен — 656 из 3 260 значений накрывают >1 ГАР-здание (20.1%), это был бы
|
||||||
|
over-merge с confidence 1.0. Оставлен как рабочий приёмник на случай, если площадка
|
||||||
|
начнёт отдавать настоящий кадастр — но приёмник СУЖЕН до кадастра ЗДАНИЯ: параметр
|
||||||
|
`cadastral_number` (кадастр КВАРТИРЫ) убран из сигнатуры, Protocol и обоих вызывающих.
|
||||||
|
Он был отложенной миной: у каждой квартиры свой номер, Tier 0 не сматчил бы никогда,
|
||||||
|
падение в New-house INSERT записало бы номер квартиры в `houses.cadastral_number` и
|
||||||
|
попутно сняло P1-страж «безномерный адрес без кадастра не создаём» — по дому на
|
||||||
|
квартиру. В `listings` оба поля пишутся как раньше; из ключа дома ушло только ложное.
|
||||||
|
|
||||||
Algorithm reference: decisions/Cross_Source_Matching_Strategy.md sec 3
|
Algorithm reference: decisions/Cross_Source_Matching_Strategy.md sec 3
|
||||||
"""
|
"""
|
||||||
|
|
@ -46,8 +75,6 @@ def match_or_create_house(
|
||||||
*,
|
*,
|
||||||
year_built: int | None = None,
|
year_built: int | None = None,
|
||||||
building_cadastral_number: str | None = None,
|
building_cadastral_number: str | None = None,
|
||||||
cadastral_number: str | None = None,
|
|
||||||
house_fias_id: str | None = None,
|
|
||||||
source_url: str | None = None,
|
source_url: str | None = None,
|
||||||
) -> tuple[int | None, float, str]:
|
) -> tuple[int | None, float, str]:
|
||||||
"""Match existing house or create new canonical record.
|
"""Match existing house or create new canonical record.
|
||||||
|
|
@ -58,21 +85,18 @@ def match_or_create_house(
|
||||||
for an unknown address could both miss Tier 0-3 and each INSERT a duplicate
|
for an unknown address could both miss Tier 0-3 and each INSERT a duplicate
|
||||||
house row. Closes finding #1 from 2026-05-24 audit.
|
house row. Closes finding #1 from 2026-05-24 audit.
|
||||||
|
|
||||||
Args:
|
NB: параметра `house_fias_id` здесь НЕТ намеренно (#2674) — см. шапку модуля.
|
||||||
house_fias_id: ГАР OBJECTGUID (UUID) of the building, when known upstream
|
ФИАС-тир живёт только в `match_house_readonly`, у которого есть источник ФИАС.
|
||||||
(e.g. DaData /clean/address). Enables Tier 0.5 fias_exact — additive and
|
|
||||||
optional, existing callers are unaffected.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(house_id, confidence ∈ [0.0, 1.0], method ∈ {
|
(house_id, confidence ∈ [0.0, 1.0], method ∈ {
|
||||||
'cadastr_exact', 'fias_exact', 'source_exact', 'fingerprint',
|
'cadastr_exact', 'source_exact', 'fingerprint',
|
||||||
'geo_proximity', 'new', 'no_house_number'
|
'geo_proximity', 'new', 'no_house_number'
|
||||||
})
|
})
|
||||||
house_id is None only for the 'no_house_number' terminal case below.
|
house_id is None only for the 'no_house_number' terminal case below.
|
||||||
|
|
||||||
Method values:
|
Method values:
|
||||||
'cadastr_exact' — matched by cadastral number (confidence 1.0)
|
'cadastr_exact' — matched by cadastral number (confidence 1.0)
|
||||||
'fias_exact' — matched by house_fias_id (ГАР OBJECTGUID) (confidence 0.95)
|
|
||||||
'source_exact' — already in house_sources for this source+ext_id (confidence 1.0)
|
'source_exact' — already in house_sources for this source+ext_id (confidence 1.0)
|
||||||
'fingerprint' — matched by address fingerprint (confidence 0.9)
|
'fingerprint' — matched by address fingerprint (confidence 0.9)
|
||||||
'geo_proximity' — matched by geo within 30 m (confidence 0.7)
|
'geo_proximity' — matched by geo within 30 m (confidence 0.7)
|
||||||
|
|
@ -87,7 +111,16 @@ def match_or_create_house(
|
||||||
'р-н Чкаловский, мкр. Вторчермет' 480). A cadastral number is a precise building
|
'р-н Чкаловский, мкр. Вторчермет' 480). A cadastral number is a precise building
|
||||||
identity, so cad-carrying rows stay exempt (Tier 0 owns them).
|
identity, so cad-carrying rows stay exempt (Tier 0 owns them).
|
||||||
"""
|
"""
|
||||||
cad = building_cadastral_number or cadastral_number
|
# ТОЛЬКО кадастр ЗДАНИЯ (#2674). Раньше было `building_cadastral_number or cadastral_number`,
|
||||||
|
# где второе — кадастр КВАРТИРЫ (у каждой свой), и параметр `cadastral_number` тоже убран из
|
||||||
|
# сигнатуры. Пока площадки не отдают ни того ни другого, фолбэк спал; но он и есть ловушка,
|
||||||
|
# ради которой мы «оставили рабочий приёмник»: начни Циан отдавать `offer["cadastralNumber"]`
|
||||||
|
# (парсер читает именно его), квартирный номер поехал бы в ключ ЗДАНИЯ. Tier 0 не сматчил бы
|
||||||
|
# никогда (у каждой квартиры свой номер) → падение в New-house INSERT → номер КВАРТИРЫ
|
||||||
|
# проштампован в houses.cadastral_number, плюс снят P1-страж ниже («безномерный адрес без
|
||||||
|
# кадастра не создаём» — `cad` там же и разрешает создание). Две квартиры одного дома дали бы
|
||||||
|
# два дома — то самое дробление, против которого Tier 0 и заведён.
|
||||||
|
cad = building_cadastral_number
|
||||||
|
|
||||||
# Compute fingerprint early so we can acquire the advisory lock before any tier reads.
|
# Compute fingerprint early so we can acquire the advisory lock before any tier reads.
|
||||||
fp = address_fingerprint(address, lat, lon)
|
fp = address_fingerprint(address, lat, lon)
|
||||||
|
|
@ -136,34 +169,10 @@ def match_or_create_house(
|
||||||
logger.info("house match cadastr_exact house_id=%s cad=%s", house_id, cad)
|
logger.info("house match cadastr_exact house_id=%s cad=%s", house_id, cad)
|
||||||
return (house_id, 1.0, "cadastr_exact")
|
return (house_id, 1.0, "cadastr_exact")
|
||||||
|
|
||||||
# Tier 0.5: house_fias_id (ГАР OBJECTGUID) exact match, case-insensitive.
|
# Tier 0.5 fias_exact удалён (#2674): передать `house_fias_id` в этот путь было
|
||||||
# Stable ORDER BY id so concurrent/duplicate rows resolve deterministically.
|
# некому — ни Protocol HouseMatcher, ни RealMatcherAdapter, ни оба прямых вызывающих
|
||||||
if house_fias_id:
|
# такого параметра не имели, поэтому за всю историю тир не сработал ни разу (0 из
|
||||||
row = (
|
# 49 502 house_sources). Живой ФИАС-тир остался в match_house_readonly.
|
||||||
db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT id FROM houses "
|
|
||||||
"WHERE lower(house_fias_id) = lower(CAST(:fias AS text)) "
|
|
||||||
"ORDER BY id ASC LIMIT 1"
|
|
||||||
),
|
|
||||||
{"fias": house_fias_id},
|
|
||||||
)
|
|
||||||
.mappings()
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if row:
|
|
||||||
house_id = int(row["id"])
|
|
||||||
_upsert_house_source(
|
|
||||||
db,
|
|
||||||
house_id=house_id,
|
|
||||||
ext_source=ext_source,
|
|
||||||
ext_id=ext_id,
|
|
||||||
method="fias_exact",
|
|
||||||
confidence=0.95,
|
|
||||||
)
|
|
||||||
_insert_alias(db, house_id=house_id, address=address, fp=fp, source=ext_source)
|
|
||||||
logger.info("house match fias_exact house_id=%s fias=%s", house_id, house_fias_id)
|
|
||||||
return (house_id, 0.95, "fias_exact")
|
|
||||||
|
|
||||||
# Tier 1: source+ext_id already registered in house_sources
|
# Tier 1: source+ext_id already registered in house_sources
|
||||||
row = (
|
row = (
|
||||||
|
|
|
||||||
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 asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from scraper_kit.orchestration import runs as kit_runs
|
||||||
from scraper_kit.orchestration.scheduler import (
|
from scraper_kit.orchestration.scheduler import (
|
||||||
Handler,
|
Handler,
|
||||||
reschedule_after_minutes,
|
reschedule_after_minutes,
|
||||||
|
|
@ -39,39 +41,97 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ── cian_history_backfill — cookie-gated backfill ────────────────────────────
|
# ── cian_history_backfill — cookie-gated backfill ────────────────────────────
|
||||||
|
# Машиночитаемые причины пропуска (#2658) — пишутся в scrape_runs.error строки
|
||||||
|
# со status='skipped'. Отделены от kit-причин (already_running и т.п.): по слагу
|
||||||
|
# видно, встал ли сбор из-за кук или из-за конкурентного прогона.
|
||||||
|
SKIP_CIAN_COOKIES_MISSING = "cian_cookies_missing"
|
||||||
|
SKIP_CIAN_COOKIES_EXPIRED = "cian_cookies_expired"
|
||||||
|
SKIP_CIAN_COOKIES_INVALID = "cian_cookies_invalid"
|
||||||
|
|
||||||
|
|
||||||
|
def _alert_cian_cookies(source: str, detail: str) -> None:
|
||||||
|
"""Громкий алерт «сбор встал из-за кук» — logger.error, НЕ capture_message(warning).
|
||||||
|
|
||||||
|
В scraper-контейнере GlitchTip поднят с LoggingIntegration(event_level=ERROR)
|
||||||
|
(scheduler_main.py) — ERROR-запись сама становится событием, а прежний
|
||||||
|
`capture_message(..., level="warning")` до этого уровня не дотягивал (и стоял в
|
||||||
|
недостижимой ветке, см. докстринг _cian_pre_claim). Заодно причина остаётся в
|
||||||
|
docker-логах и в строке scrape_runs, которая переживает редеплой.
|
||||||
|
"""
|
||||||
|
logger.error(
|
||||||
|
"scheduler: %s пропущен — %s. Перезалейте куки Циана через админку "
|
||||||
|
"(до этого backfill истории стоит)",
|
||||||
|
source,
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _cian_pre_claim(db: Session, schedule_row: dict[str, Any], ctx: SchedulerContext) -> bool:
|
async def _cian_pre_claim(db: Session, schedule_row: dict[str, Any], ctx: SchedulerContext) -> bool:
|
||||||
"""Pre-claim gate: проверить наличие/валидность cian-cookies ДО claim (#1522).
|
"""Pre-claim gate: проверить наличие/валидность cian-cookies ДО claim (#1522).
|
||||||
|
|
||||||
Cookies отсутствуют/протухли → defer next_run_at на следующее окно и skip
|
Cookies отсутствуют/протухли → пишем строку прогона status='skipped' с причиной,
|
||||||
(иначе get_due_schedules переотбирает schedule каждые 60с и verify_session
|
двигаем next_run_at на следующее окно и skip (иначе get_due_schedules переотбирает
|
||||||
долбит Cian круглосуточно). Дословно из боевого trigger_cian_backfill_run.
|
schedule каждые 60с и verify_session долбит Cian круглосуточно).
|
||||||
"""
|
|
||||||
import sentry_sdk
|
|
||||||
|
|
||||||
from app.services.cian_session import load_session, verify_session
|
#2658 — что было не так. Первая ветка (load_session вернул None) молчала: warning в
|
||||||
|
docker-лог, сдвиг next_run_at, `return False`. Ни строки в scrape_runs, ни изменения
|
||||||
|
last_run_at — снаружи 37 дней простоя выглядели как «всё по расписанию». Sentry-алерт
|
||||||
|
стоял во ВТОРОЙ ветке (verify_session вернул None), до которой на протухших куках
|
||||||
|
исполнение не доходит НИКОГДА: load_session сам фильтрует expires_at_estimate > NOW()
|
||||||
|
и отдаёт None ещё в первой. Теперь громко в обеих + предупреждение ЗАРАНЕЕ, пока куки
|
||||||
|
ещё валидны (COOKIE_EXPIRY_WARN_DAYS) — обновление кук ручное, ему нужен запас.
|
||||||
|
"""
|
||||||
|
from app.services.cian_session import (
|
||||||
|
COOKIE_EXPIRY_WARN_DAYS,
|
||||||
|
load_session,
|
||||||
|
session_expires_at,
|
||||||
|
verify_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
source: str = schedule_row["source"]
|
||||||
|
now = datetime.now(tz=UTC)
|
||||||
|
|
||||||
cookies = load_session(db)
|
cookies = load_session(db)
|
||||||
if cookies is None:
|
if cookies is None:
|
||||||
logger.warning("scheduler: cian_history_backfill skipped — no valid session cookies in DB")
|
expires_at = session_expires_at(db)
|
||||||
|
if expires_at is None:
|
||||||
|
reason, detail = SKIP_CIAN_COOKIES_MISSING, "кук Циана нет в БД"
|
||||||
|
elif expires_at <= now:
|
||||||
|
reason = SKIP_CIAN_COOKIES_EXPIRED
|
||||||
|
detail = (
|
||||||
|
f"куки Циана протухли {expires_at:%Y-%m-%d} ({(now - expires_at).days} дн. назад)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reason = SKIP_CIAN_COOKIES_INVALID
|
||||||
|
detail = "куки Циана помечены невалидными (last_invalid_at)"
|
||||||
|
_alert_cian_cookies(source, detail)
|
||||||
|
kit_runs.mark_skipped(db, source=source, reason=reason, details=detail)
|
||||||
kit_defer_next_run_at(db, schedule_row)
|
kit_defer_next_run_at(db, schedule_row)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
state = await verify_session(cookies)
|
state = await verify_session(cookies)
|
||||||
if state is None:
|
if state is None:
|
||||||
logger.warning(
|
# verify вернул именно None (401 / isAuthenticated=false) — куки числятся
|
||||||
"scheduler: cian_history_backfill — cookies expired or invalid, skipping run"
|
# валидными по сроку, но Циан их не принимает. Sentinel-ответы (бан / источник
|
||||||
)
|
# недоступен / сменилась вёрстка) сюда НЕ попадают, они truthy — см. cian_session.
|
||||||
try:
|
detail = "Циан не принимает куки (разлогин)"
|
||||||
sentry_sdk.capture_message(
|
_alert_cian_cookies(source, detail)
|
||||||
"cian_history_backfill skipped: Cian session cookies expired — "
|
kit_runs.mark_skipped(db, source=source, reason=SKIP_CIAN_COOKIES_INVALID, details=detail)
|
||||||
"please re-upload via admin UI",
|
|
||||||
level="warning",
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass # sentry_sdk not initialised in dev
|
|
||||||
kit_defer_next_run_at(db, schedule_row)
|
kit_defer_next_run_at(db, schedule_row)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Куки рабочие — предупреждаем, пока есть время их обновить без простоя сбора.
|
||||||
|
# valid_only=True: срок ИМЕННО той записи, которую взял load_session (при нескольких
|
||||||
|
# аккаунтах свежайшая-любая может быть чужой протухшей строкой).
|
||||||
|
expires_at = session_expires_at(db, valid_only=True)
|
||||||
|
if expires_at is not None and expires_at - now <= timedelta(days=COOKIE_EXPIRY_WARN_DAYS):
|
||||||
|
logger.error(
|
||||||
|
"scheduler: куки Циана протухнут %s (осталось %.1f дн.) — обновите заранее, "
|
||||||
|
"иначе %s встанет молча",
|
||||||
|
expires_at.date().isoformat(),
|
||||||
|
(expires_at - now).total_seconds() / 86400,
|
||||||
|
source,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -94,13 +154,15 @@ async def _job_rosreestr_dkp(
|
||||||
|
|
||||||
|
|
||||||
# ── listing_source_snapshot — sync DB-snapshot в executor ────────────────────
|
# ── listing_source_snapshot — sync DB-snapshot в executor ────────────────────
|
||||||
|
# params прокинуты (#2607) — snapshot_listing_sources теперь читает budget_sec из
|
||||||
|
# default_params (SET LOCAL statement_timeout, см. app/tasks/listing_source_snapshot.py).
|
||||||
async def _job_listing_source_snapshot(
|
async def _job_listing_source_snapshot(
|
||||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
) -> None:
|
) -> None:
|
||||||
from app.tasks.listing_source_snapshot import snapshot_listing_sources
|
from app.tasks.listing_source_snapshot import snapshot_listing_sources
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
await loop.run_in_executor(None, snapshot_listing_sources, db, run_id)
|
await loop.run_in_executor(None, snapshot_listing_sources, db, run_id, params)
|
||||||
|
|
||||||
|
|
||||||
# ── asking_to_sold_ratio_refresh — sync re-derive в executor ─────────────────
|
# ── asking_to_sold_ratio_refresh — sync re-derive в executor ─────────────────
|
||||||
|
|
@ -113,6 +175,16 @@ async def _job_asking_to_sold_ratio(
|
||||||
await loop.run_in_executor(None, recompute_asking_to_sold_ratios, db, run_id)
|
await loop.run_in_executor(None, recompute_asking_to_sold_ratios, db, run_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── deal_city_price_bands_refresh — sync tier-aware re-derive в executor ──────
|
||||||
|
async def _job_deal_city_price_bands_refresh(
|
||||||
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
) -> None:
|
||||||
|
from app.tasks.deal_city_price_bands_refresh import refresh_deal_city_price_bands
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, refresh_deal_city_price_bands, db, run_id)
|
||||||
|
|
||||||
|
|
||||||
# ── refresh_search_matview — REFRESH MATVIEW CONCURRENTLY (own connection) ────
|
# ── refresh_search_matview — REFRESH MATVIEW CONCURRENTLY (own connection) ────
|
||||||
async def _job_refresh_search_matview(
|
async def _job_refresh_search_matview(
|
||||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
|
@ -144,12 +216,19 @@ async def _job_deactivate_stale(
|
||||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
) -> None:
|
) -> None:
|
||||||
from app.core.config import settings as _settings
|
from app.core.config import settings as _settings
|
||||||
from app.tasks.deactivate_stale_avito import deactivate_stale_listings
|
from app.tasks.deactivate_stale_avito import (
|
||||||
|
DEFAULT_MIN_CONFIRMATIONS,
|
||||||
|
deactivate_stale_listings,
|
||||||
|
)
|
||||||
|
|
||||||
listing_source: str = params.get("listing_source", "avito")
|
listing_source: str = params.get("listing_source", "avito")
|
||||||
ttl_days: int = params.get("ttl_days", _settings.avito_stale_ttl_days)
|
ttl_days: int = params.get("ttl_days", _settings.avito_stale_ttl_days)
|
||||||
segments: list[str] | None = params.get("segments")
|
segments: list[str] | None = params.get("segments")
|
||||||
staleness_column: str = params.get("staleness_column", "last_seen_at")
|
staleness_column: str = params.get("staleness_column", "last_seen_at")
|
||||||
|
# Гейт по здоровью сбора (#2659) включён по умолчанию: незасеянное расписание
|
||||||
|
# получает страховочный порог, а не «деактивируй вслепую». Посчитанные по
|
||||||
|
# источнику пороги приходят из default_params (миграция 219).
|
||||||
|
min_confirmations: int = params.get("min_confirmations", DEFAULT_MIN_CONFIRMATIONS)
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
await loop.run_in_executor(
|
await loop.run_in_executor(
|
||||||
|
|
@ -161,6 +240,7 @@ async def _job_deactivate_stale(
|
||||||
ttl_days=ttl_days,
|
ttl_days=ttl_days,
|
||||||
segments=segments,
|
segments=segments,
|
||||||
staleness_column=staleness_column,
|
staleness_column=staleness_column,
|
||||||
|
min_confirmations=min_confirmations,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -329,17 +409,38 @@ async def _job_house_imv_backfill(
|
||||||
only_status=only_status,
|
only_status=only_status,
|
||||||
heartbeat=_heartbeat,
|
heartbeat=_heartbeat,
|
||||||
)
|
)
|
||||||
ctx.runs.mark_done(
|
counters = {
|
||||||
db,
|
|
||||||
run_id,
|
|
||||||
{
|
|
||||||
"checked": result.checked,
|
"checked": result.checked,
|
||||||
"saved": result.saved,
|
"saved": result.saved,
|
||||||
"skipped": result.skipped,
|
"skipped": result.skipped,
|
||||||
"errors": result.errors,
|
"errors": result.errors,
|
||||||
"duration_sec": int(result.duration_sec),
|
"duration_sec": int(result.duration_sec),
|
||||||
},
|
# #2674: _column_counts (scrape_runs.py) берёт выделенные колонки из
|
||||||
|
# ключей total_seen|lots_fetched и new_count|lots_inserted — ни одного
|
||||||
|
# из них тут не было, поэтому все 39 прогонов этого source лежат в БД
|
||||||
|
# с total_seen=0. А mark_done по этой же колонке шлёт алерт «3 подряд
|
||||||
|
# done с нулевым результатом» (#2625) — то есть даже идеальный прогон
|
||||||
|
# с 50 сохранёнными считался бы нулевым и через три дня выстрелил бы
|
||||||
|
# ложной тревогой про капчу.
|
||||||
|
# Трейд-офф: на исчерпанной очереди checked=0 три дня подряд тоже даст
|
||||||
|
# алерт — но пустая очередь при ежедневном расписании это и правда сигнал.
|
||||||
|
"total_seen": result.checked,
|
||||||
|
"new_count": result.saved,
|
||||||
|
}
|
||||||
|
# Честный статус (#2674, тот же класс, что #2670/#2657): успех — это
|
||||||
|
# «сделали то, что собирались», а не «не поймали известное исключение».
|
||||||
|
# На проде так ушли в done 31 прогон подряд: saved=0 при errors≈35 из 50.
|
||||||
|
# Ноль сохранённых БЕЗ ошибок (всё отфильтровано в skipped) — честная
|
||||||
|
# пустота, она по-прежнему done.
|
||||||
|
if result.saved == 0 and result.errors > 0:
|
||||||
|
ctx.runs.mark_failed(
|
||||||
|
db,
|
||||||
|
run_id,
|
||||||
|
f"saved=0 при errors={result.errors} (checked={result.checked})",
|
||||||
|
counters,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
ctx.runs.mark_done(db, run_id, counters)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("scheduler: house_imv_backfill crashed run_id=%d", run_id)
|
logger.exception("scheduler: house_imv_backfill crashed run_id=%d", run_id)
|
||||||
try:
|
try:
|
||||||
|
|
@ -348,6 +449,57 @@ async def _job_house_imv_backfill(
|
||||||
logger.exception("scheduler: mark_failed crashed run_id=%d", run_id)
|
logger.exception("scheduler: mark_failed crashed run_id=%d", run_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── domrf_kapremont_load — sync загрузка open data ДОМ.РФ в executor ─────────
|
||||||
|
# #2674: loader (services/domrf_kapremont_loader.py) и CLI (tasks/domrf_kapremont_load.py)
|
||||||
|
# написаны и покрыты тестами с #2013, но Handler'а и строки расписания не было — источник
|
||||||
|
# запускали руками ровно один раз, 12.07.2026 (29 978 строк, один и тот же loaded_at у всех).
|
||||||
|
# Это не мёртвый код, а оборванная проводка: нечему было его вызвать.
|
||||||
|
async def _job_domrf_kapremont_load(
|
||||||
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
) -> None:
|
||||||
|
"""Скачать КР1.1+КР1.2 ДОМ.РФ → staging → backfill houses → propagate listings.
|
||||||
|
|
||||||
|
Тело переиспользует те же три функции, что и CLI (дизайн-инвариант модуля: не
|
||||||
|
дублируем логику). Lifecycle не свой — mark_done/mark_failed здесь, как у
|
||||||
|
_job_yandex_newbuilding_sweep.
|
||||||
|
|
||||||
|
Счётчики кладём в total_seen/new_count: `scrape_runs._column_counts` берёт выделенные
|
||||||
|
колонки именно из этих ключей, и по ним же mark_done ловит «три подряд нулевых
|
||||||
|
прогона» (#2625) — без них идеальный прогон лежал бы в БД как нулевой (тот же
|
||||||
|
промах, что чинили у house_imv_backfill).
|
||||||
|
"""
|
||||||
|
from app.services.domrf_kapremont_loader import (
|
||||||
|
backfill_houses_from_domrf,
|
||||||
|
load_domrf_kapremont,
|
||||||
|
propagate_listings_year_from_houses,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _run() -> dict[str, int]:
|
||||||
|
load_counts = load_domrf_kapremont(db)
|
||||||
|
db.commit()
|
||||||
|
houses_counts = backfill_houses_from_domrf(db)
|
||||||
|
listings_counts = propagate_listings_year_from_houses(db)
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"kr11_rows": load_counts["kr11_rows"],
|
||||||
|
"upserted": load_counts["upserted"],
|
||||||
|
"houses_updated": houses_counts["houses_updated"],
|
||||||
|
"listings_updated": listings_counts["listings_updated"],
|
||||||
|
# см. докстринг: выделенные колонки прогона + гейт «нулевой прогон».
|
||||||
|
"total_seen": load_counts["kr11_rows"],
|
||||||
|
"new_count": houses_counts["houses_updated"] + listings_counts["listings_updated"],
|
||||||
|
}
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
counters = await loop.run_in_executor(None, _run)
|
||||||
|
ctx.runs.mark_done(db, run_id, counters)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("scheduler: domrf_kapremont_load crashed run_id=%d", run_id)
|
||||||
|
db.rollback()
|
||||||
|
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
|
||||||
|
|
||||||
|
|
||||||
# ── purge_expired_trade_in_data — ЭТАП 4 B2C retention (152-ФЗ) ───────────────
|
# ── purge_expired_trade_in_data — ЭТАП 4 B2C retention (152-ФЗ) ───────────────
|
||||||
async def _job_purge_expired_trade_in_data(
|
async def _job_purge_expired_trade_in_data(
|
||||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||||
|
|
@ -400,8 +552,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
||||||
"""Реестр НЕ-sweep продуктовых source→Handler для kit build_registry.
|
"""Реестр НЕ-sweep продуктовых source→Handler для kit build_registry.
|
||||||
|
|
||||||
Kit-native sweeps (avito/yandex/cian/domclick city/full-load/newbuilding) НЕ здесь —
|
Kit-native sweeps (avito/yandex/cian/domclick city/full-load/newbuilding) НЕ здесь —
|
||||||
их даёт build_registry(_default_kit_handlers). Здесь — 18 именованных + 1 wildcard
|
их даёт build_registry(_default_kit_handlers). Здесь — именованные + 1 wildcard
|
||||||
(deactivate_stale_*), покрывающие каждый НЕ-sweep source боевого scheduler-dispatch.
|
(deactivate_stale_*), покрывающие каждый НЕ-sweep source боевого scheduler-dispatch.
|
||||||
|
(Число намеренно не названо: прежнее «19» разошлось с реальностью на пять записей.)
|
||||||
|
|
||||||
`ctx` — принят для симметрии контракта; сами Handler-job'ы получают ctx во время
|
`ctx` — принят для симметрии контракта; сами Handler-job'ы получают ctx во время
|
||||||
dispatch (см. kit `_dispatch`), поэтому здесь он не замыкается.
|
dispatch (см. kit `_dispatch`), поэтому здесь он не замыкается.
|
||||||
|
|
@ -417,6 +570,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
||||||
"asking_to_sold_ratio_refresh": Handler(
|
"asking_to_sold_ratio_refresh": Handler(
|
||||||
_job_asking_to_sold_ratio, "asking_to_sold_ratio_refresh"
|
_job_asking_to_sold_ratio, "asking_to_sold_ratio_refresh"
|
||||||
),
|
),
|
||||||
|
"deal_city_price_bands_refresh": Handler(
|
||||||
|
_job_deal_city_price_bands_refresh, "deal_city_price_bands_refresh"
|
||||||
|
),
|
||||||
"refresh_search_matview": Handler(_job_refresh_search_matview, "refresh_search_matview"),
|
"refresh_search_matview": Handler(_job_refresh_search_matview, "refresh_search_matview"),
|
||||||
"yandex_address_backfill": Handler(_job_yandex_address_backfill, "yandex_address_backfill"),
|
"yandex_address_backfill": Handler(_job_yandex_address_backfill, "yandex_address_backfill"),
|
||||||
"sber_index_pull": Handler(_job_sber_index_pull, "sber_index_pull"),
|
"sber_index_pull": Handler(_job_sber_index_pull, "sber_index_pull"),
|
||||||
|
|
@ -442,6 +598,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
||||||
"osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"),
|
"osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"),
|
||||||
"house_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"),
|
"house_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"),
|
||||||
"house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"),
|
"house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"),
|
||||||
|
"domrf_kapremont_load": Handler(_job_domrf_kapremont_load, "domrf_kapremont_load"),
|
||||||
"purge_expired_trade_in_data": Handler(
|
"purge_expired_trade_in_data": Handler(
|
||||||
_job_purge_expired_trade_in_data, "purge_expired_trade_in_data"
|
_job_purge_expired_trade_in_data, "purge_expired_trade_in_data"
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,68 @@ ipify-пробу через каждый прокси и обновляет heal
|
||||||
за одну строку — второй параллельный вызов пропустит залоченную и возьмёт следующую).
|
за одну строку — второй параллельный вызов пропустит залоченную и возьмёт следующую).
|
||||||
|
|
||||||
Health:
|
Health:
|
||||||
- mark_health(ok=True) → consecutive_fails=0, last_ok_at/last_check_at, exit_ip, latency.
|
- mark_health(ok=True) → consecutive_fails=0, enabled=true, last_ok_at/last_check_at,
|
||||||
|
exit_ip, latency. enabled=true — реанимация: узел, выключенный
|
||||||
|
ранее авто-disable'ом, возвращается в строй первой же успешной
|
||||||
|
пробой (см. run_proxy_healthcheck).
|
||||||
- mark_health(ok=False) → consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси
|
- mark_health(ok=False) → consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси
|
||||||
авто-disable (enabled=false), чтобы битый узел выпал из пула.
|
авто-disable (enabled=false), чтобы битый узел выпал из пула.
|
||||||
- acquire отфильтровывает enabled=false И consecutive_fails >= MAX_FAILS.
|
- acquire отфильтровывает enabled=false И consecutive_fails >= MAX_FAILS.
|
||||||
|
|
||||||
|
Self-healing (#2600):
|
||||||
|
- run_proxy_healthcheck проверяет не только enabled-узлы, но и disabled — реже, раз в
|
||||||
|
DISABLED_RECHECK_MINUTES (или если ни разу не проверялся). Успешная проба выключенного
|
||||||
|
узла реанимирует его (enabled=true), инкрементит счётчик `revived` и пишет INFO-лог.
|
||||||
|
Без этого auto-disable необратим: транзиентный сбой = вечный приговор узлу.
|
||||||
|
- acquire, не найдя свободного здорового узла нужной provider_affinity, вторым заходом
|
||||||
|
берёт любой свободный здоровый узел ЛЮБОЙ affinity (WARNING-лог) — иначе источник
|
||||||
|
голодает при живых свободных узлах чужой affinity. Fallback НЕ забирает последний
|
||||||
|
enabled-узел выделенной affinity (пример — domclick, один узел на всё, см. acquire
|
||||||
|
docstring) — иначе чинили бы один источник ценой полной поломки другого.
|
||||||
|
|
||||||
|
Бан по паре «узел × источник» (#2600 п.2, таблица scrape_proxy_source_bans, миграция 210):
|
||||||
|
- Авито банит IP, Яндекс через тот же IP ходит чисто. Поэтому распознанный бан
|
||||||
|
площадкой (`mark_banned`) НЕ выключает узел глобально (так делал #2600 п.1), а
|
||||||
|
пишет строку (proxy_id, source, banned_until) — `acquire(source)` перестаёт
|
||||||
|
выдавать узел ЭТОМУ источнику, для остальных узел остаётся первосортным.
|
||||||
|
- Отличие от `enabled=false`: глобальное выключение — это либо решение оператора
|
||||||
|
(disabled_reason НЕ NULL, #2610), либо авто-disable по серии ТРАНСПОРТНЫХ сбоев
|
||||||
|
(mark_health, DISABLE_THRESHOLD). Бан площадкой — свойство ПАРЫ, а не узла, и
|
||||||
|
снимается сам по времени, без ручного PATCH и без ipify-пробы (ipify площадку не
|
||||||
|
эмулирует, бана не видит — ровно тот баг, из-за которого п.1 требовал ручного
|
||||||
|
вмешательства).
|
||||||
|
- Срок эскалирует на повторных банах той же пары: SOURCE_BAN_BASE_HOURS *
|
||||||
|
2^(ban_count-1), но не больше SOURCE_BAN_MAX_HOURS. Истёкшие строки сносятся
|
||||||
|
purge'ем в run_proxy_healthcheck только через SOURCE_BAN_PURGE_DAYS — это же и
|
||||||
|
механизм сброса ban_count (см. комментарий у purge, НЕ «оптимизировать»).
|
||||||
|
- Защита последнего узла сохранена, но теперь ПО ИСТОЧНИКУ: если после записи бана
|
||||||
|
у acquire(source) не останется ни одного кандидата — бан не пишется, только
|
||||||
|
WARNING (пул надо пополнять, #2638).
|
||||||
|
- Ручное снятие — `clear_source_bans` (ложный бан детектора капчи, #2642) плюс
|
||||||
|
автоматическое после успешной ротации exit-IP: бан привязан к proxy_id, а банился
|
||||||
|
IP, поэтому смена адреса делает строку недействительной.
|
||||||
|
|
||||||
|
Ручное выключение vs авто-выключение (#2610):
|
||||||
|
- scrape_proxies.disabled_reason (миграция 209) различает ДВЕ разные причины
|
||||||
|
enabled=false: пул выключил сам после серии сбоев (disabled_reason IS NULL) —
|
||||||
|
воскрешается первой же успешной пробой, как задумано #2609; оператор выключил
|
||||||
|
руками через admin API (disabled_reason НЕ NULL) — mark_health(ok=True) НЕ
|
||||||
|
трогает enabled, пишет WARNING с id узла и причиной. Без этого узел, снятый
|
||||||
|
оператором из ротации (например забаненный площадкой — ipify через него всё
|
||||||
|
равно отвечает 200), возвращался бы в строй первой же health-пробой молча.
|
||||||
|
- Сброс флага (возврат к авто-восстанавливаемому состоянию) — только через
|
||||||
|
admin API PATCH /proxies/{id} enabled=true (app/api/v1/admin.py:patch_proxy),
|
||||||
|
который явно обнуляет disabled_reason в NULL.
|
||||||
|
|
||||||
|
Sticky session lease (browser-путь, живая регрессия 2026-08):
|
||||||
|
- `BrowserFetcher` (scraper_kit) берёт ОДИН lease на весь жизненный цикл сессии
|
||||||
|
(весь прогон), а не на каждый `/fetch` — иначе при N>=2 живых узлах пула каждый
|
||||||
|
/fetch получал ДРУГОЙ прокси (acquire сортирует по last_ok_at) и camoufox
|
||||||
|
релончился на каждый запрос (server.py: relaunch только при реальной смене
|
||||||
|
желаемого прокси). См. `touch()` — heartbeat, которым сессия продлевает leased_at
|
||||||
|
на каждый /fetch, чтобы reap_stale_leases не отобрал прокси у многочасового
|
||||||
|
прогона.
|
||||||
|
|
||||||
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
|
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -36,16 +93,23 @@ from sqlalchemy.orm import Session
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"DISABLED_RECHECK_MINUTES",
|
||||||
"DISABLE_THRESHOLD",
|
"DISABLE_THRESHOLD",
|
||||||
"MAX_CONSECUTIVE_FAILS",
|
"MAX_CONSECUTIVE_FAILS",
|
||||||
"NON_RUN_LEASE_MARKER",
|
"NON_RUN_LEASE_MARKER",
|
||||||
|
"SOURCE_BAN_BASE_HOURS",
|
||||||
|
"SOURCE_BAN_MAX_HOURS",
|
||||||
|
"SOURCE_BAN_PURGE_DAYS",
|
||||||
"STALE_LEASE_MINUTES",
|
"STALE_LEASE_MINUTES",
|
||||||
"ProxyLease",
|
"ProxyLease",
|
||||||
"acquire",
|
"acquire",
|
||||||
|
"clear_source_bans",
|
||||||
|
"mark_banned",
|
||||||
"mark_health",
|
"mark_health",
|
||||||
"reap_stale_leases",
|
"reap_stale_leases",
|
||||||
"release",
|
"release",
|
||||||
"run_proxy_healthcheck",
|
"run_proxy_healthcheck",
|
||||||
|
"touch",
|
||||||
]
|
]
|
||||||
|
|
||||||
# ── Пороги ───────────────────────────────────────────────────────────────────
|
# ── Пороги ───────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -62,14 +126,42 @@ DISABLE_THRESHOLD = 5
|
||||||
# освобождается reap_stale_leases — иначе прокси навсегда «занят» мёртвым run'ом.
|
# освобождается reap_stale_leases — иначе прокси навсегда «занят» мёртвым run'ом.
|
||||||
STALE_LEASE_MINUTES = 30
|
STALE_LEASE_MINUTES = 30
|
||||||
|
|
||||||
|
# Disabled-узлы перепроверяются не каждый прогон (это долбёж по мёртвому/дорогому
|
||||||
|
# провайдеру), а раз в это число минут — либо если ни разу не проверялся. Успешная
|
||||||
|
# проба реанимирует узел (см. run_proxy_healthcheck). Без recheck'а auto-disable
|
||||||
|
# необратим: транзиентный сбой = вечный приговор (#2600).
|
||||||
|
DISABLED_RECHECK_MINUTES = 60
|
||||||
|
|
||||||
# Маркер lease для не-run вызовов (leased_by NOT NULL = занят, но это не id из scrape_runs).
|
# Маркер lease для не-run вызовов (leased_by NOT NULL = занят, но это не id из scrape_runs).
|
||||||
NON_RUN_LEASE_MARKER = -1
|
NON_RUN_LEASE_MARKER = -1
|
||||||
|
|
||||||
|
# ── Бан по паре «узел × источник» (#2600 п.2) ────────────────────────────────
|
||||||
|
# Срок ПЕРВОГО бана пары (proxy_id, source). 6 часов — эмпирический компромисс:
|
||||||
|
# площадки снимают IP-баны обычно за часы, а не минуты (короче — вернём узел под тот
|
||||||
|
# же бан и потратим прогон впустую), но и не сутки (узел дефицитный, #2638).
|
||||||
|
SOURCE_BAN_BASE_HOURS = 6
|
||||||
|
|
||||||
|
# Потолок эскалации: SOURCE_BAN_BASE_HOURS * 2^(ban_count-1) обрезается этим значением
|
||||||
|
# (6 → 12 → 24 → 48 → 72 → 72 …). Дольше 3 суток держать бесполезно: либо площадка
|
||||||
|
# сняла бан, либо узел мёртв насовсем и его должен вычистить оператор.
|
||||||
|
SOURCE_BAN_MAX_HOURS = 72
|
||||||
|
|
||||||
|
# Через столько суток ПОСЛЕ истечения бана строка сносится purge'ем (см.
|
||||||
|
# run_proxy_healthcheck). Это же и сброс ban_count — см. комментарий там.
|
||||||
|
SOURCE_BAN_PURGE_DAYS = 7
|
||||||
|
|
||||||
# URL для health-пробы: возвращает exit-IP JSON'ом. Тот же эндпоинт, что и admin
|
# URL для health-пробы: возвращает exit-IP JSON'ом. Тот же эндпоинт, что и admin
|
||||||
# /scraper/health (_probe_current_ip).
|
# /scraper/health (_probe_current_ip).
|
||||||
_HEALTH_PROBE_URL = "https://api.ipify.org"
|
_HEALTH_PROBE_URL = "https://api.ipify.org"
|
||||||
_HEALTH_PROBE_TIMEOUT_S = 10.0
|
_HEALTH_PROBE_TIMEOUT_S = 10.0
|
||||||
|
|
||||||
|
# deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для
|
||||||
|
# mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что
|
||||||
|
# в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не
|
||||||
|
# столкнуться с advisory-локами других частей системы, которые тоже могут
|
||||||
|
# использовать pg_advisory_lock с мелкими/предсказуемыми ключами.
|
||||||
|
_MARK_BANNED_ADVISORY_LOCK_KEY = 0x2600_BA22 # "2600 BAn" — мнемоника, не magic
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ProxyLease:
|
class ProxyLease:
|
||||||
|
|
@ -90,10 +182,30 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
||||||
(last_ok_at NULLS LAST). Затем помечает строку leased_by=run_id (или
|
(last_ok_at NULLS LAST). Затем помечает строку leased_by=run_id (или
|
||||||
NON_RUN_LEASE_MARKER если run_id не задан) и коммитит.
|
NON_RUN_LEASE_MARKER если run_id не задан) и коммитит.
|
||||||
|
|
||||||
|
Если свободных здоровых узлов нужной affinity (provider/'any') нет — вторым заходом
|
||||||
|
берётся любой свободный здоровый узел ЛЮБОЙ affinity (тот же ORDER BY/FOR UPDATE SKIP
|
||||||
|
LOCKED), с WARNING-логом. Приоритет не меняется: своя affinity всегда предпочтительнее,
|
||||||
|
чужая — только запасной вариант, чтобы источник не голодал при живых свободных узлах
|
||||||
|
чужой affinity (#2600).
|
||||||
|
|
||||||
|
Fallback НЕ трогает последний enabled-узел выделенной (не-'any') affinity — см.
|
||||||
|
173_scrape_proxies_add_domclick_affinity.sql: у domclick ровно один узел (id=1),
|
||||||
|
намеренно вырезанный из общего пула, потому что QRATOR банит все прокси кроме этого
|
||||||
|
одного чистого residential-адреса. Если fallback заберёт его под avito/cian/yandex,
|
||||||
|
domclick останется без прокси вообще — хуже, чем голодание исходного источника,
|
||||||
|
которое фикс призван устранить. Кандидат участвует в fallback, только если его
|
||||||
|
affinity='any' ИЛИ у этой affinity есть ДРУГОЙ enabled-узел (EXISTS-подзапрос) —
|
||||||
|
т.е. выдача не обнулит доступность выделенной affinity целиком.
|
||||||
|
|
||||||
|
ОБА запроса отсекают узлы с АКТИВНЫМ баном по ЭТОМУ provider'у
|
||||||
|
(scrape_proxy_source_bans.banned_until > now(), #2600 п.2) — узел, забаненный Авито,
|
||||||
|
остаётся полноценным кандидатом для Яндекса и остальных источников. Бан по чужому
|
||||||
|
source на выдачу не влияет вообще.
|
||||||
|
|
||||||
Конкурентные acquire не дерутся за одну строку: SKIP LOCKED пропускает залоченную
|
Конкурентные acquire не дерутся за одну строку: SKIP LOCKED пропускает залоченную
|
||||||
другим вызовом строку, второй параллельный acquire берёт следующую свободную.
|
другим вызовом строку, второй параллельный acquire берёт следующую свободную.
|
||||||
|
|
||||||
Returns ProxyLease или None если свободных здоровых прокси нет.
|
Returns ProxyLease или None если свободных здоровых прокси нет вообще.
|
||||||
"""
|
"""
|
||||||
lease_marker = run_id if run_id is not None else NON_RUN_LEASE_MARKER
|
lease_marker = run_id if run_id is not None else NON_RUN_LEASE_MARKER
|
||||||
|
|
||||||
|
|
@ -107,6 +219,13 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
||||||
AND consecutive_fails < CAST(:max_fails AS integer)
|
AND consecutive_fails < CAST(:max_fails AS integer)
|
||||||
AND provider_affinity IN (:provider, 'any')
|
AND provider_affinity IN (:provider, 'any')
|
||||||
AND leased_by IS NULL
|
AND leased_by IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM scrape_proxy_source_bans b
|
||||||
|
WHERE b.proxy_id = scrape_proxies.id
|
||||||
|
AND b.source = :provider
|
||||||
|
AND b.banned_until > now()
|
||||||
|
)
|
||||||
ORDER BY last_ok_at NULLS LAST, id
|
ORDER BY last_ok_at NULLS LAST, id
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
|
|
@ -117,6 +236,65 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
||||||
.mappings()
|
.mappings()
|
||||||
.fetchone()
|
.fetchone()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fallback_used = False
|
||||||
|
if row is None:
|
||||||
|
# Нет своих (provider/'any') — запасной заход: любой свободный здоровый узел
|
||||||
|
# ЛЮБОЙ affinity, кроме последнего enabled-узла выделенной affinity (domclick и
|
||||||
|
# т.п.) — EXISTS-подзапрос требует хотя бы ОДИН ДРУГОЙ enabled-узел той же
|
||||||
|
# affinity, иначе affinity='any' достаточно.
|
||||||
|
row = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT sp.id, sp.url, sp.kind, sp.rotate_url
|
||||||
|
FROM scrape_proxies AS sp
|
||||||
|
WHERE sp.enabled
|
||||||
|
AND sp.consecutive_fails < CAST(:max_fails AS integer)
|
||||||
|
AND sp.leased_by IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM scrape_proxy_source_bans b
|
||||||
|
WHERE b.proxy_id = sp.id
|
||||||
|
AND b.source = :provider
|
||||||
|
AND b.banned_until > now()
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
sp.provider_affinity = 'any'
|
||||||
|
-- backup обязан быть ПРИГОДЕН для своей affinity, а не просто
|
||||||
|
-- enabled (#2600 п.2 deep-review): после перехода на per-source
|
||||||
|
-- баны узел бывает enabled и одновременно забанен СВОИМ же
|
||||||
|
-- источником. Засчитывать такой как backup — значит разрешить
|
||||||
|
-- fallback увести последний реально рабочий узел выделенной
|
||||||
|
-- affinity и обрушить её (два domclick-узла, один забанен
|
||||||
|
-- domclick'ом → второй уходит под avito → domclick без прокси).
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM scrape_proxies AS other
|
||||||
|
WHERE other.provider_affinity = sp.provider_affinity
|
||||||
|
AND other.enabled
|
||||||
|
AND other.id <> sp.id
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM scrape_proxy_source_bans b2
|
||||||
|
WHERE b2.proxy_id = other.id
|
||||||
|
AND b2.source = other.provider_affinity
|
||||||
|
AND b2.banned_until > now()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY sp.last_ok_at NULLS LAST, sp.id
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"max_fails": MAX_CONSECUTIVE_FAILS, "provider": provider},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.fetchone()
|
||||||
|
)
|
||||||
|
fallback_used = row is not None
|
||||||
|
|
||||||
if row is None:
|
if row is None:
|
||||||
db.rollback() # снять FOR UPDATE-транзакцию (ничего не залочено, но чисто)
|
db.rollback() # снять FOR UPDATE-транзакцию (ничего не залочено, но чисто)
|
||||||
return None
|
return None
|
||||||
|
|
@ -133,6 +311,15 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
|
||||||
{"run_id": lease_marker, "id": proxy_id},
|
{"run_id": lease_marker, "id": proxy_id},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
if fallback_used:
|
||||||
|
logger.warning(
|
||||||
|
"proxy_pool: leased proxy id=%d provider=%s by=%s — FALLBACK affinity "
|
||||||
|
"(no free healthy proxy of matching affinity, issuing proxy of other affinity)",
|
||||||
|
proxy_id,
|
||||||
|
provider,
|
||||||
|
lease_marker,
|
||||||
|
)
|
||||||
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
"proxy_pool: leased proxy id=%d provider=%s by=%s", proxy_id, provider, lease_marker
|
"proxy_pool: leased proxy id=%d provider=%s by=%s", proxy_id, provider, lease_marker
|
||||||
)
|
)
|
||||||
|
|
@ -160,6 +347,47 @@ def release(db: Session, proxy_id: int) -> None:
|
||||||
logger.info("proxy_pool: released proxy id=%d", proxy_id)
|
logger.info("proxy_pool: released proxy id=%d", proxy_id)
|
||||||
|
|
||||||
|
|
||||||
|
def touch(db: Session, proxy_id: int) -> None:
|
||||||
|
"""Heartbeat: продлить lease (leased_at=now()) без трогания health-полей.
|
||||||
|
|
||||||
|
#2164 P4 sticky-session fix (2026-08).
|
||||||
|
|
||||||
|
Раньше `BrowserFetcher` брал/отпускал прокси на КАЖДЫЙ `/fetch` — при N>=2 живых узлах
|
||||||
|
это гарантированно меняло прокси между соседними запросами (`acquire` сортирует ORDER
|
||||||
|
BY last_ok_at NULLS LAST, id — «давно не использованный первый») и гоняло camoufox
|
||||||
|
relaunch на каждый /fetch (см. server.py `_ensure_browser` — relaunch только при
|
||||||
|
реальной смене желаемого прокси). Фикс: один lease на весь жизненный цикл
|
||||||
|
`BrowserFetcher` (весь прогон, часы). Но `reap_stale_leases` освобождает lease старше
|
||||||
|
`STALE_LEASE_MINUTES` (=30) — прогон ДОЛЬШЕ 30 минут (полная загрузка Циана шла часами)
|
||||||
|
остался бы без прокси на середине, а второй consumer мог бы получить тот же прокси.
|
||||||
|
|
||||||
|
Решение: НЕ увеличивать `STALE_LEASE_MINUTES` (это притупило бы реальную задачу
|
||||||
|
reaper'а — освобождать lease мёртвого/зависшего run'а, который никогда не вызовет
|
||||||
|
release). Вместо этого `BrowserFetcher` вызывает `touch` на каждый /fetch (успешный
|
||||||
|
ИЛИ неуспешный — сам факт завершённого запроса доказывает, что процесс жив и активно
|
||||||
|
использует прокси) — `leased_at` подтверждается заново, окно `STALE_LEASE_MINUTES`
|
||||||
|
сдвигается вперёд, пока идёт трафик. Реальный мёртвый/зависший run (упал/завис БЕЗ
|
||||||
|
единого /fetch дольше 30 минут) по-прежнему реапится штатно — семантика reaper'а не
|
||||||
|
ослаблена, просто измеряется от «последней активности», а не от «момента acquire».
|
||||||
|
|
||||||
|
No-op (0 rows), если прокси уже не арендован (leased_by IS NULL, например reaper
|
||||||
|
успел отобрать в гонке) — defensive, вызывающий код (BrowserFetcher) не должен падать.
|
||||||
|
"""
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE scrape_proxies
|
||||||
|
SET leased_at = now()
|
||||||
|
WHERE id = CAST(:id AS bigint)
|
||||||
|
AND leased_by IS NOT NULL
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"id": proxy_id},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
logger.debug("proxy_pool: touch (heartbeat) proxy id=%d", proxy_id)
|
||||||
|
|
||||||
|
|
||||||
def mark_health(
|
def mark_health(
|
||||||
db: Session,
|
db: Session,
|
||||||
proxy_id: int,
|
proxy_id: int,
|
||||||
|
|
@ -167,15 +395,34 @@ def mark_health(
|
||||||
*,
|
*,
|
||||||
exit_ip: str | None = None,
|
exit_ip: str | None = None,
|
||||||
latency_ms: int | None = None,
|
latency_ms: int | None = None,
|
||||||
|
fail_kind: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Записать результат health-check'а прокси.
|
"""Записать результат health-check'а прокси.
|
||||||
|
|
||||||
ok=True → consecutive_fails обнуляется, обновляются last_ok_at/last_check_at/
|
ok=True → consecutive_fails обнуляется, обновляются last_ok_at/last_check_at/
|
||||||
exit_ip/latency_ms.
|
exit_ip/latency_ms. enabled=true — РЕАНИМАЦИЯ, но ТОЛЬКО если узел не
|
||||||
|
выключен вручную (disabled_reason IS NULL, #2610): узел, ранее выключенный
|
||||||
|
auto-disable'ом (disabled_reason IS NULL), возвращается в строй первой же
|
||||||
|
успешной пробой, как задумано #2609 п.1. Узел, выключенный оператором
|
||||||
|
(disabled_reason НЕ NULL), остаётся enabled=false — иначе снятый с ротации
|
||||||
|
забаненный площадкой узел воскрешался бы первой же ipify-пробой (ipify
|
||||||
|
площадку не эмулирует, значит бан ею не ловится). Этот случай логируется
|
||||||
|
WARNING'ом — раньше (до #2610) происходил молча.
|
||||||
ok=False → consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси
|
ok=False → consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси
|
||||||
авто-disable (enabled=false). last_check_at обновляется в любом случае.
|
авто-disable (enabled=false, disabled_reason НЕ трогается — узел уходит в
|
||||||
|
disable БЕЗ причины, т.е. остаётся авто-воскрешаемым). last_check_at
|
||||||
|
обновляется в любом случае.
|
||||||
|
|
||||||
|
fail_kind — необязательная классификация неуспеха ("timeout" / "connect_error" /
|
||||||
|
"http_error" / "other", см. _probe_proxy), используется ТОЛЬКО для логирования.
|
||||||
|
Счётчик consecutive_fails/порог disable инкрементится одинаково для любого fail_kind —
|
||||||
|
аккуратное разделение "транзиентный сбой vs перманентный бан" (разные пороги/скорость
|
||||||
|
инкремента по типу ошибки) требует более глубокой переработки модуля (отдельный
|
||||||
|
трекинг по типам ошибок, вероятно per-fail_kind счётчики) и намеренно НЕ сделано в
|
||||||
|
рамках #2600 п.2 — см. обоснование в PR. fail_kind — задел под это на будущее.
|
||||||
"""
|
"""
|
||||||
if ok:
|
if ok:
|
||||||
|
row = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
|
|
@ -185,12 +432,26 @@ def mark_health(
|
||||||
last_check_at = now(),
|
last_check_at = now(),
|
||||||
exit_ip = CAST(:exit_ip AS text),
|
exit_ip = CAST(:exit_ip AS text),
|
||||||
latency_ms = CAST(:latency_ms AS integer),
|
latency_ms = CAST(:latency_ms AS integer),
|
||||||
|
enabled = CASE
|
||||||
|
WHEN disabled_reason IS NULL THEN true ELSE enabled
|
||||||
|
END,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = CAST(:id AS bigint)
|
WHERE id = CAST(:id AS bigint)
|
||||||
|
RETURNING disabled_reason
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"exit_ip": exit_ip, "latency_ms": latency_ms, "id": proxy_id},
|
{"exit_ip": exit_ip, "latency_ms": latency_ms, "id": proxy_id},
|
||||||
)
|
)
|
||||||
|
.mappings()
|
||||||
|
.fetchone()
|
||||||
|
)
|
||||||
|
if row is not None and row["disabled_reason"] is not None:
|
||||||
|
logger.warning(
|
||||||
|
"proxy_pool: mark_health id=%d ok=True but stays disabled — manually "
|
||||||
|
"disabled (reason=%r), auto-revive skipped (#2610)",
|
||||||
|
proxy_id,
|
||||||
|
row["disabled_reason"],
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# consecutive_fails+1 >= порог → enabled=false (авто-вывод битого узла).
|
# consecutive_fails+1 >= порог → enabled=false (авто-вывод битого узла).
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -210,7 +471,245 @@ def mark_health(
|
||||||
{"disable_threshold": DISABLE_THRESHOLD, "id": proxy_id},
|
{"disable_threshold": DISABLE_THRESHOLD, "id": proxy_id},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info("proxy_pool: mark_health id=%d ok=%s exit_ip=%s", proxy_id, ok, exit_ip)
|
logger.info(
|
||||||
|
"proxy_pool: mark_health id=%d ok=%s exit_ip=%s fail_kind=%s",
|
||||||
|
proxy_id,
|
||||||
|
ok,
|
||||||
|
exit_ip,
|
||||||
|
fail_kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
||||||
|
"""Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2.
|
||||||
|
|
||||||
|
Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и
|
||||||
|
авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация —
|
||||||
|
транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно
|
||||||
|
распознана вызывающим кодом (валидная HTML-заглушка/капча/QRATOR-маркер — НЕ
|
||||||
|
исключение транспорта, НЕ голый network-fail).
|
||||||
|
|
||||||
|
ЧТО ИМЕННО ДЕЛАЕТСЯ (изменение против #2600 п.1): узел БОЛЬШЕ НЕ выключается
|
||||||
|
глобально (`enabled=false, disabled_reason='banned:<source>'` — так было в п.1).
|
||||||
|
Пишется строка в `scrape_proxy_source_bans` (миграция 210): пока
|
||||||
|
`banned_until > now()`, `acquire(source)` этот узел не выдаёт, а для ЛЮБОГО
|
||||||
|
другого источника он остаётся первосортным. Авито банит IP — Яндекс через тот же
|
||||||
|
IP ходит чисто; глобальное выключение выкидывало живой узел отовсюду и худило пул
|
||||||
|
в разы быстрее, чем его пополняют (#2638). `enabled`/`disabled_reason` остаются
|
||||||
|
исключительно за оператором (#2610) и за авто-disable'ом по транспортным сбоям.
|
||||||
|
|
||||||
|
ЭСКАЛАЦИЯ: первый бан пары — SOURCE_BAN_BASE_HOURS; каждый следующий удваивает
|
||||||
|
срок (ban_count после инкремента N → SOURCE_BAN_BASE_HOURS * 2^(N-1)), но не выше
|
||||||
|
SOURCE_BAN_MAX_HOURS. Узел, который площадка банит раз за разом, отдыхает от неё
|
||||||
|
всё дольше, вместо того чтобы жечь прогоны. Сброс ban_count — только purge'ем
|
||||||
|
истёкших строк (run_proxy_healthcheck, SOURCE_BAN_PURGE_DAYS).
|
||||||
|
|
||||||
|
ЗАЩИТА ПОСЛЕДНЕГО УЗЛА, ТЕПЕРЬ ПО ИСТОЧНИКУ (issue #2600 риск, паттерн #2609):
|
||||||
|
если после записи бана у `acquire(source)` не останется НИ ОДНОГО кандидата — бан
|
||||||
|
НЕ пишется, только WARNING. Доступность считается ТЕМ ЖЕ правилом, что и acquire()
|
||||||
|
(primary affinity ИЛИ 'any' + fallback на чужую affinity, которая не последняя из
|
||||||
|
своей) ПЛЮС отсутствие активной бан-строки для этого source — EXISTS ниже, а не
|
||||||
|
наивный `COUNT(*) WHERE enabled`. Голодать без прокси хуже, чем ходить через
|
||||||
|
забаненный: капча хотя бы иногда пропускает, отсутствие узла — нет.
|
||||||
|
|
||||||
|
`leased_by IS NULL` защита НАМЕРЕННО не проверяет (в отличие от acquire) — так было
|
||||||
|
и в п.1, и это не оплошность: lease живёт минуты-часы и снимается сам (release /
|
||||||
|
reap_stale_leases), т.е. занятый узел — это доступный узел через мгновение, а вот
|
||||||
|
отказ записать бан из-за чужого lease был бы вечным (узел так и остался бы в выдаче
|
||||||
|
забаненным). Точность здесь не бесплатна: с проверкой lease защита срабатывала бы
|
||||||
|
ложно при каждом параллельном прогоне.
|
||||||
|
|
||||||
|
КОНКУРЕНТНОСТЬ (deep-review fix 2 из #2600 п.1, сохранено): один
|
||||||
|
`INSERT ... WHERE EXISTS(...)` — НЕ атомарная гарантия поперёк СТРОК. EXISTS читает
|
||||||
|
состояние других строк на момент своего снапшота (READ COMMITTED), но не лочит их —
|
||||||
|
два ПАРАЛЛЕЛЬНЫХ mark_banned для РАЗНЫХ proxy_id (напр. avito банит A, cian банит B
|
||||||
|
миллисекундами позже) каждый может увидеть другого как "ещё живого" в своём EXISTS и
|
||||||
|
оба закоммититься → для источника не остаётся ни одного узла разом. Фикс:
|
||||||
|
`pg_advisory_xact_lock` в начале транзакции сериализует ВСЕ mark_banned-вызовы между
|
||||||
|
собой (xact-scoped — снимается сам на commit/rollback, leak невозможен). Один
|
||||||
|
глобальный ключ вместо per-source — сериализует и непересекающиеся баны тоже, но
|
||||||
|
частота вызовов низкая (несколько банов в час, не hot-path) — цена оправдана
|
||||||
|
простотой против per-row `SELECT ... FOR UPDATE` по кандидатам (выше риск deadlock
|
||||||
|
между параллельными mark_banned, лочащими пересекающиеся строки в разном порядке).
|
||||||
|
ponytail: global advisory lock, не per-source — переходи на составной ключ
|
||||||
|
(напр. hashtext(source)) если частота банов когда-нибудь станет hot-path.
|
||||||
|
|
||||||
|
Идемпотентно: повторный бан той же пары не создаёт дубль (PK (proxy_id, source)) —
|
||||||
|
продлевает срок по правилу эскалации. Несуществующий proxy_id — no-op + WARNING.
|
||||||
|
|
||||||
|
Best-effort по контракту вызывающих (`BrowserFetcher.report_ban`, `curl_proxy_url`) —
|
||||||
|
сюда попадают уже обёрнутыми в try/except, но сам mark_banned ошибки БД не глотает
|
||||||
|
(падает как обычно) — caller решает, ловить или нет.
|
||||||
|
"""
|
||||||
|
# Сериализует check+insert ниже с другими конкурентными mark_banned (см. докстринг
|
||||||
|
# "КОНКУРЕНТНОСТЬ"). Держится до db.commit()/rollback() этой транзакции.
|
||||||
|
db.execute(
|
||||||
|
text("SELECT pg_advisory_xact_lock(CAST(:key AS bigint))"),
|
||||||
|
{"key": _MARK_BANNED_ADVISORY_LOCK_KEY},
|
||||||
|
)
|
||||||
|
# INSERT ... SELECT ... WHERE EXISTS: guard'ы в WHERE источника строк — не прошли,
|
||||||
|
# значит строк на вставку нет, конфликта нет, эскалации нет (0 rows → ветка логов ниже).
|
||||||
|
# LEAST(ban_count, 16) в показателе — страховка от переполнения double при абсурдном
|
||||||
|
# ban_count (потолок SOURCE_BAN_MAX_HOURS всё равно срежет результат гораздо раньше).
|
||||||
|
row = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO scrape_proxy_source_bans (proxy_id, source, banned_until, reason)
|
||||||
|
SELECT CAST(:proxy_id AS bigint),
|
||||||
|
CAST(:source AS text),
|
||||||
|
now() + make_interval(hours => CAST(:base_hours AS integer)),
|
||||||
|
CAST(:reason AS text)
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM scrape_proxies
|
||||||
|
WHERE id = CAST(:proxy_id AS bigint)
|
||||||
|
)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM scrape_proxies sp
|
||||||
|
WHERE sp.id <> CAST(:proxy_id AS bigint)
|
||||||
|
AND sp.enabled
|
||||||
|
AND sp.consecutive_fails < CAST(:max_fails AS integer)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM scrape_proxy_source_bans b
|
||||||
|
WHERE b.proxy_id = sp.id
|
||||||
|
AND b.source = CAST(:source AS text)
|
||||||
|
AND b.banned_until > now()
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
sp.provider_affinity IN (:source, 'any')
|
||||||
|
-- other.id <> sp.id (а не NOT IN (sp.id, :proxy_id), как в
|
||||||
|
-- п.1): банимый узел остаётся enabled и по-прежнему обслуживает
|
||||||
|
-- СВОЮ affinity — значит он и есть валидный backup для неё.
|
||||||
|
-- NOT EXISTS b2 — тот же критерий пригодности, что в acquire()
|
||||||
|
-- fallback: enabled-узел, забаненный СВОИМ источником, backup'ом
|
||||||
|
-- не считается (иначе защита сочла бы affinity живой, когда она
|
||||||
|
-- уже нет).
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM scrape_proxies other
|
||||||
|
WHERE other.provider_affinity = sp.provider_affinity
|
||||||
|
AND other.enabled
|
||||||
|
AND other.id <> sp.id
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM scrape_proxy_source_bans b2
|
||||||
|
WHERE b2.proxy_id = other.id
|
||||||
|
AND b2.source = other.provider_affinity
|
||||||
|
AND b2.banned_until > now()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ON CONFLICT (proxy_id, source) DO UPDATE
|
||||||
|
SET ban_count = scrape_proxy_source_bans.ban_count + 1,
|
||||||
|
banned_until = now() + make_interval(hours => CAST(
|
||||||
|
LEAST(
|
||||||
|
CAST(:base_hours AS integer)
|
||||||
|
* power(2, LEAST(scrape_proxy_source_bans.ban_count, 16)),
|
||||||
|
CAST(:max_hours AS integer)
|
||||||
|
) AS integer)),
|
||||||
|
reason = CAST(:reason AS text),
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING ban_count, banned_until
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"proxy_id": proxy_id,
|
||||||
|
"source": source,
|
||||||
|
"reason": f"banned:{source}",
|
||||||
|
"base_hours": SOURCE_BAN_BASE_HOURS,
|
||||||
|
"max_hours": SOURCE_BAN_MAX_HOURS,
|
||||||
|
"max_fails": MAX_CONSECUTIVE_FAILS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.fetchone()
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
if row is not None:
|
||||||
|
logger.warning(
|
||||||
|
"proxy_pool: proxy id=%d BANNED by source=%s — узел снят с выдачи ТОЛЬКО для "
|
||||||
|
"этого источника до %s (ban_count=%s); для остальных источников остаётся в "
|
||||||
|
"строю (#2600 п.2)",
|
||||||
|
proxy_id,
|
||||||
|
source,
|
||||||
|
row["banned_until"],
|
||||||
|
row["ban_count"],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 0 rows: либо узла нет, либо защита последнего узла отменила запись бана — читаем
|
||||||
|
# текущее состояние ТОЛЬКО для точного лога (на решение уже не влияет).
|
||||||
|
current = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT enabled, disabled_reason FROM scrape_proxies "
|
||||||
|
"WHERE id = CAST(:id AS bigint)"
|
||||||
|
),
|
||||||
|
{"id": proxy_id},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.fetchone()
|
||||||
|
)
|
||||||
|
if current is None:
|
||||||
|
logger.warning("proxy_pool: mark_banned id=%d not found — no-op", proxy_id)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для "
|
||||||
|
"source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому "
|
||||||
|
"источнику (голодание хуже, чем работа через забаненный узел).",
|
||||||
|
proxy_id,
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, reason: str) -> int:
|
||||||
|
"""Снять баны узла по источникам (#2600 п.2). Returns число снятых строк.
|
||||||
|
|
||||||
|
ЗАЧЕМ ОТДЕЛЬНАЯ РУЧКА: до п.2 ложный бан лечился оператором через
|
||||||
|
`PATCH /proxies/{id} enabled=true` — включение обнуляло `disabled_reason`, и узел
|
||||||
|
возвращался в строй. Теперь бан живёт в отдельной таблице и сам по себе истекает
|
||||||
|
только по таймеру, вплоть до 72 часов при эскалации. Без этой функции ложное
|
||||||
|
срабатывание детектора капчи (#2642) парковало бы узел на часы, а снять это можно
|
||||||
|
было бы только руками в SQL.
|
||||||
|
|
||||||
|
ГДЕ ВЫЗЫВАЕТСЯ:
|
||||||
|
- `admin.patch_proxy` при ручном включении узла — «оператор включил» означает
|
||||||
|
чистый лист, ровно как обнуление disabled_reason рядом (#2610);
|
||||||
|
- после УСПЕШНОЙ ротации exit-IP (`proxy_rotation.rotate_proxy`) — площадка
|
||||||
|
банила IP, а строка бана привязана к proxy_id и пережила бы смену адреса,
|
||||||
|
держа узел вне выдачи уже без причины.
|
||||||
|
|
||||||
|
source=None — снять все баны узла; конкретный source — только его. DELETE, а не
|
||||||
|
`banned_until = now()`: строка живёт ещё и ради `ban_count` (память об эскалации),
|
||||||
|
а здесь мы как раз объявляем историю недействительной — новый бан начнётся с базовых
|
||||||
|
SOURCE_BAN_BASE_HOURS.
|
||||||
|
|
||||||
|
`reason` идёт только в лог (человекочитаемый повод — «manual enable», «ip rotated»).
|
||||||
|
"""
|
||||||
|
rows = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
DELETE FROM scrape_proxy_source_bans
|
||||||
|
WHERE proxy_id = CAST(:proxy_id AS bigint)
|
||||||
|
AND (CAST(:source AS text) IS NULL OR source = CAST(:source AS text))
|
||||||
|
RETURNING source
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"proxy_id": proxy_id, "source": source},
|
||||||
|
).fetchall()
|
||||||
|
db.commit()
|
||||||
|
if rows:
|
||||||
|
logger.info(
|
||||||
|
"proxy_pool: cleared %d source ban(s) for proxy id=%d (%s) — reason=%s",
|
||||||
|
len(rows),
|
||||||
|
proxy_id,
|
||||||
|
[r.source for r in rows],
|
||||||
|
reason,
|
||||||
|
)
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int:
|
def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int:
|
||||||
|
|
@ -237,10 +736,17 @@ def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES
|
||||||
return len(rows)
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None]:
|
async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | None]:
|
||||||
"""GET ipify через прокси (timeout _HEALTH_PROBE_TIMEOUT_S).
|
"""GET ipify через прокси (timeout _HEALTH_PROBE_TIMEOUT_S).
|
||||||
|
|
||||||
Returns (ok, exit_ip, latency_ms). ok=False + (None, None) при любой ошибке.
|
Returns (ok, exit_ip, latency_ms, fail_kind). При успехе fail_kind=None. При неуспехе
|
||||||
|
exit_ip/latency_ms=None, а fail_kind классифицирует что случилось (#2600 п.2 —
|
||||||
|
транзиентный сбой узла ≠ перманентный бан, используется пока только для логов):
|
||||||
|
- "timeout" — сеть недоступна/медленная (httpx.TimeoutException)
|
||||||
|
- "connect_error" — прокси не поднят/не слушает/DNS (httpx.ConnectError)
|
||||||
|
- "http_error" — ipify ответил ошибкой через прокси (auth/upstream)
|
||||||
|
- "other" — прочее
|
||||||
|
|
||||||
url несёт схему (http:// / socks5://) — httpx[socks] обрабатывает оба.
|
url несёт схему (http:// / socks5://) — httpx[socks] обрабатывает оба.
|
||||||
"""
|
"""
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
|
|
@ -250,10 +756,23 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None]:
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
ip = resp.json().get("ip")
|
ip = resp.json().get("ip")
|
||||||
latency_ms = int((time.monotonic() - started) * 1000)
|
latency_ms = int((time.monotonic() - started) * 1000)
|
||||||
return True, (str(ip) if ip else None), latency_ms
|
return True, (str(ip) if ip else None), latency_ms, None
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.warning("proxy_pool: health probe timeout proxy=%s", _mask(url))
|
||||||
|
return False, None, None, "timeout"
|
||||||
|
except httpx.ConnectError:
|
||||||
|
logger.warning("proxy_pool: health probe connect_error proxy=%s", _mask(url))
|
||||||
|
return False, None, None, "connect_error"
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"proxy_pool: health probe http_error proxy=%s status=%s",
|
||||||
|
_mask(url),
|
||||||
|
exc.response.status_code,
|
||||||
|
)
|
||||||
|
return False, None, None, "http_error"
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("proxy_pool: health probe failed proxy=%s", _mask(url), exc_info=True)
|
logger.warning("proxy_pool: health probe failed proxy=%s", _mask(url), exc_info=True)
|
||||||
return False, None, None
|
return False, None, None, "other"
|
||||||
|
|
||||||
|
|
||||||
def _mask(url: str) -> str:
|
def _mask(url: str) -> str:
|
||||||
|
|
@ -269,16 +788,29 @@ def _mask(url: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
"""Периодический health-check всех enabled-прокси пула (#2162).
|
"""Периодический health-check прокси пула — enabled каждый прогон, disabled реже (#2162, #2600).
|
||||||
|
|
||||||
Сначала reap_stale_leases (освобождает протухшие lease'ы), затем для каждого
|
Сначала reap_stale_leases (освобождает протухшие lease'ы), затем гоняет ipify-пробу
|
||||||
enabled-прокси гоняет ipify-пробу через сам прокси и пишет результат через
|
через каждый кандидат и пишет результат через mark_health (успех → сброс fails +
|
||||||
mark_health (успех → сброс fails + свежий exit_ip/latency; фейл → инкремент,
|
enabled=true + свежий exit_ip/latency; фейл → инкремент, авто-disable при
|
||||||
авто-disable при DISABLE_THRESHOLD).
|
DISABLE_THRESHOLD).
|
||||||
|
|
||||||
|
Кандидаты: ВСЕ enabled-узлы (как раньше) + disabled-узлы, которые ни разу не
|
||||||
|
проверялись (last_check_at IS NULL) или проверялись давнее DISABLED_RECHECK_MINUTES
|
||||||
|
назад. Без этого auto-disable необратим — узел, ушедший в disable из-за транзиентного
|
||||||
|
сбоя, никогда больше не проверяется и не может вернуться (#2600 п.1). Успешная проба
|
||||||
|
disabled-узла реанимирует его (enabled=true через mark_health) — инкрементит `revived`
|
||||||
|
и пишет отдельный INFO-лог. Ручно-выключенные узлы (disabled_reason НЕ NULL, #2610)
|
||||||
|
тоже пробуются (чтобы после ручного включения признак немедленно ожил без ожидания
|
||||||
|
следующего disable/enable цикла), но mark_health их не воскрешает — revived не растёт,
|
||||||
|
WARNING пишет сам mark_health.
|
||||||
|
|
||||||
|
В конце — purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад
|
||||||
|
(см. комментарий у самого DELETE: отложенность — это и есть сброс ban_count).
|
||||||
|
|
||||||
Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на
|
Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на
|
||||||
один и тот же upstream-endpoint (ipify) не нужен. Returns counters
|
один и тот же upstream-endpoint (ipify) не нужен. Returns counters
|
||||||
{reaped, checked, ok, failed}.
|
{reaped, checked, ok, failed, revived, bans_purged}.
|
||||||
"""
|
"""
|
||||||
reaped = reap_stale_leases(db)
|
reaped = reap_stale_leases(db)
|
||||||
|
|
||||||
|
|
@ -286,12 +818,17 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT id, url, kind
|
SELECT id, url, kind, enabled, disabled_reason
|
||||||
FROM scrape_proxies
|
FROM scrape_proxies
|
||||||
WHERE enabled
|
WHERE enabled
|
||||||
|
OR last_check_at IS NULL
|
||||||
|
OR last_check_at < now() - make_interval(
|
||||||
|
mins => CAST(:disabled_recheck_minutes AS integer)
|
||||||
|
)
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
"""
|
"""
|
||||||
)
|
),
|
||||||
|
{"disabled_recheck_minutes": DISABLED_RECHECK_MINUTES},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
|
|
@ -300,22 +837,64 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
checked = 0
|
checked = 0
|
||||||
ok_count = 0
|
ok_count = 0
|
||||||
failed = 0
|
failed = 0
|
||||||
|
revived = 0
|
||||||
for row in proxies:
|
for row in proxies:
|
||||||
proxy_id = int(row["id"])
|
proxy_id = int(row["id"])
|
||||||
url = str(row["url"])
|
url = str(row["url"])
|
||||||
ok, exit_ip, latency_ms = await _probe_proxy(url)
|
was_disabled = not bool(row["enabled"])
|
||||||
mark_health(db, proxy_id, ok, exit_ip=exit_ip, latency_ms=latency_ms)
|
manually_disabled = row["disabled_reason"] is not None
|
||||||
|
ok, exit_ip, latency_ms, fail_kind = await _probe_proxy(url)
|
||||||
|
mark_health(db, proxy_id, ok, exit_ip=exit_ip, latency_ms=latency_ms, fail_kind=fail_kind)
|
||||||
checked += 1
|
checked += 1
|
||||||
if ok:
|
if ok:
|
||||||
ok_count += 1
|
ok_count += 1
|
||||||
|
# manually_disabled → mark_health не тронул enabled (см. её WARNING-лог);
|
||||||
|
# revived считает только реальное авто-воскрешение (#2610).
|
||||||
|
if was_disabled and not manually_disabled:
|
||||||
|
revived += 1
|
||||||
|
logger.info(
|
||||||
|
"proxy_pool: REVIVED proxy id=%d — successful probe of a disabled node, "
|
||||||
|
"returned to service (enabled=true, consecutive_fails=0)",
|
||||||
|
proxy_id,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
|
|
||||||
|
# Purge ДАВНО истёкших бан-строк (#2600 п.2). Порог — banned_until + SOURCE_BAN_PURGE_DAYS,
|
||||||
|
# НЕ просто `banned_until < now()`: строка после истечения бана ещё ничего не блокирует
|
||||||
|
# (acquire фильтрует по banned_until > now()), но хранит ban_count — память об эскалации.
|
||||||
|
# Снесём раньше — узел, который площадка банит каждые сутки, каждый раз начинал бы с
|
||||||
|
# 6 часов и никогда не доходил до длинных пауз. Отложенный purge и есть механизм сброса:
|
||||||
|
# неделя без нового бана = пара считается чистой, эскалация с нуля. НЕ «оптимизировать».
|
||||||
|
purged = len(
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
DELETE FROM scrape_proxy_source_bans
|
||||||
|
WHERE banned_until < now() - make_interval(days => CAST(:days AS integer))
|
||||||
|
RETURNING proxy_id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"days": SOURCE_BAN_PURGE_DAYS},
|
||||||
|
).fetchall()
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d",
|
"proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d "
|
||||||
|
"bans_purged=%d",
|
||||||
reaped,
|
reaped,
|
||||||
checked,
|
checked,
|
||||||
ok_count,
|
ok_count,
|
||||||
failed,
|
failed,
|
||||||
|
revived,
|
||||||
|
purged,
|
||||||
)
|
)
|
||||||
return {"reaped": reaped, "checked": checked, "ok": ok_count, "failed": failed}
|
return {
|
||||||
|
"reaped": reaped,
|
||||||
|
"checked": checked,
|
||||||
|
"ok": ok_count,
|
||||||
|
"failed": failed,
|
||||||
|
"revived": revived,
|
||||||
|
"bans_purged": purged,
|
||||||
|
}
|
||||||
|
|
|
||||||
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 (тот же паттерн,
|
отвечает HTTP 403 без браузерного User-Agent — шлём Chrome UA (тот же паттерн,
|
||||||
что DEFAULT_UA в zhkh_flats_loader.py).
|
что DEFAULT_UA в zhkh_flats_loader.py).
|
||||||
|
|
||||||
При сетевой ошибке / HTTP 5xx / таймауте — логируем warning, возвращаем
|
УРОВНИ СИГНАЛОВ (#2674 — в контейнере скрапера событием GlitchTip становится только
|
||||||
available=False. Отсутствие папки/файла квартала → available=False (штатный
|
запись ERROR, см. scheduler_main.py LoggingIntegration(event_level=ERROR)):
|
||||||
случай до публикации квартала, до начала следующего месяца после конца квартала).
|
- Портал ответил не-200 на листинг каталога/папки → ERROR. Каталог — единственная
|
||||||
|
опора поллера; портал УЖЕ один раз переехал (см. "ИСТОРИЯ"), и тогда поллер молча
|
||||||
|
врал целыми кварталами. Такое обязано быть событием.
|
||||||
|
- Файл датасета НАЙДЕН в листинге, но HEAD не отдал zip / размер ниже порога →
|
||||||
|
ERROR. Тот же класс: это ровно поведение старой Bitrix-заглушки (200 + text/html).
|
||||||
|
Ветка может сработать легитимно (файл выложили в листинг раньше, чем докачали),
|
||||||
|
но цена асимметрична — ложное срабатывание стоит одного события в месяц (такт
|
||||||
|
28 дней), пропуск стоит квартала молчания.
|
||||||
|
- Таймаут / сетевая ошибка → WARNING, как раньше. Это транспортный блип раз в месяц
|
||||||
|
(такт поллера), сам пройдёт; а «квартал так и не приехал» ловит отдельный
|
||||||
|
deals_freshness_monitor ERROR-ом по max(deal_date).
|
||||||
|
- Папки/файла квартала нет → INFO. Штатное состояние до публикации: квартал выходит
|
||||||
|
4 раза в год, поллер ходит 12 — большинство прогонов ЗАКОННО пустые.
|
||||||
|
- Квартал вышел → INFO + ЯВНОЕ событие capture_message(level="info"), см.
|
||||||
|
poll_rosreestr_new_quarter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -63,6 +77,7 @@ from typing import Any
|
||||||
from urllib.parse import quote, unquote, urljoin
|
from urllib.parse import quote, unquote, urljoin
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import sentry_sdk
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -243,7 +258,8 @@ async def check_new_quarter_available(
|
||||||
try:
|
try:
|
||||||
index_resp = await client.get(_DATA_SETS_BASE_URL, follow_redirects=True)
|
index_resp = await client.get(_DATA_SETS_BASE_URL, follow_redirects=True)
|
||||||
if index_resp.status_code != 200:
|
if index_resp.status_code != 200:
|
||||||
logger.warning(
|
# ERROR (#2674): без каталога поллер слеп — см. "УРОВНИ СИГНАЛОВ".
|
||||||
|
logger.error(
|
||||||
"rosreestr_poll: unexpected HTTP %d listing %s — treating Q%d %d as unavailable",
|
"rosreestr_poll: unexpected HTTP %d listing %s — treating Q%d %d as unavailable",
|
||||||
index_resp.status_code,
|
index_resp.status_code,
|
||||||
_DATA_SETS_BASE_URL,
|
_DATA_SETS_BASE_URL,
|
||||||
|
|
@ -265,7 +281,9 @@ async def check_new_quarter_available(
|
||||||
folder_url = urljoin(_DATA_SETS_BASE_URL, folder_href)
|
folder_url = urljoin(_DATA_SETS_BASE_URL, folder_href)
|
||||||
folder_resp = await client.get(folder_url, follow_redirects=True)
|
folder_resp = await client.get(folder_url, follow_redirects=True)
|
||||||
if folder_resp.status_code != 200:
|
if folder_resp.status_code != 200:
|
||||||
logger.warning(
|
# ERROR (#2674): папка квартала НАЙДЕНА в каталоге, но не открывается —
|
||||||
|
# это уже не «ещё не опубликовали», а поломка портала.
|
||||||
|
logger.error(
|
||||||
"rosreestr_poll: unexpected HTTP %d listing folder %s — "
|
"rosreestr_poll: unexpected HTTP %d listing folder %s — "
|
||||||
"treating Q%d %d as unavailable",
|
"treating Q%d %d as unavailable",
|
||||||
folder_resp.status_code,
|
folder_resp.status_code,
|
||||||
|
|
@ -309,7 +327,13 @@ async def check_new_quarter_available(
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
logger.info(
|
# ERROR (#2674, ревью PR #2681): файл ЕСТЬ в листинге, но HEAD отдал не zip
|
||||||
|
# либо размер ниже порога — это буквально тот сбой, из-за которого поллер уже
|
||||||
|
# врал (Bitrix-заглушка отвечала 200 с text/html вместо архива, см. "ИСТОРИЯ").
|
||||||
|
# Ветка может сработать и легитимно — файл появился в листинге раньше, чем
|
||||||
|
# докачался, — но цена асимметрична: такт 28 дней, значит ложное срабатывание
|
||||||
|
# стоит максимум одного события в месяц, а пропуск стоит квартала молчания.
|
||||||
|
logger.error(
|
||||||
"rosreestr_poll: Q%d %d file found (%s) but failed availability check "
|
"rosreestr_poll: Q%d %d file found (%s) but failed availability check "
|
||||||
"(HTTP %d, Content-Type=%r, Content-Length=%d) — soft-404 guard, "
|
"(HTTP %d, Content-Type=%r, Content-Length=%d) — soft-404 guard, "
|
||||||
"treating as unavailable",
|
"treating as unavailable",
|
||||||
|
|
@ -338,12 +362,14 @@ async def check_new_quarter_available(
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
logger.warning(
|
# ERROR + traceback (#2674): сюда попадает НАШ баг (сменилась разметка, упал
|
||||||
"rosreestr_poll: unexpected error checking Q%d %d: %s — treating as unavailable",
|
# парсер href'ов), а не сбой сети. Под WARNING он молча превращался в
|
||||||
|
# «квартала нет» — ровно тот сценарий, из-за которого поллер врал кварталами.
|
||||||
|
logger.exception(
|
||||||
|
"rosreestr_poll: unexpected error checking Q%d %d — treating as unavailable",
|
||||||
quarter,
|
quarter,
|
||||||
year,
|
year,
|
||||||
exc,
|
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -409,6 +435,21 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
|
||||||
rosreestr_dataset_url(next_year, next_quarter),
|
rosreestr_dataset_url(next_year, next_quarter),
|
||||||
_DATA_SETS_BASE_URL,
|
_DATA_SETS_BASE_URL,
|
||||||
)
|
)
|
||||||
|
# #2674: это ХОРОШАЯ новость, но она требует ручного шага оператора (импорт
|
||||||
|
# много-гигабайтного ZIP), а INFO-строка живёт только в docker-логах и
|
||||||
|
# теряется на редеплое. Отсюда явный capture_message вместо logger.error:
|
||||||
|
# событие в GlitchTip будет, а error-rate и стрик-алерты не соврут «сбой».
|
||||||
|
# Шума не создаёт: такт поллера — раз в 28 дней, квартал выходит 4 раза в
|
||||||
|
# год, а повтор до самого импорта — это и есть нужное напоминание (#2670).
|
||||||
|
try:
|
||||||
|
sentry_sdk.capture_message(
|
||||||
|
f"Rosreestr: доступен новый квартал Q{next_quarter} {next_year} — "
|
||||||
|
"нужен ручной импорт (02_load_all_quarters.sh + import-rosreestr.sh)",
|
||||||
|
level="info",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Алертинг best-effort: падение отправки события не должно валить поллер.
|
||||||
|
logger.warning("rosreestr_poll: capture_message failed", exc_info=True)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"available": available,
|
"available": available,
|
||||||
|
|
|
||||||
|
|
@ -464,8 +464,16 @@ async def pull_sber_indices(
|
||||||
# path or its filter dims are stale (sber renames slugs / changes
|
# path or its filter dims are stale (sber renames slugs / changes
|
||||||
# dimension codes). Surface it loudly with the slug + filter so the
|
# dimension codes). Surface it loudly with the slug + filter so the
|
||||||
# next breakage is diagnosable instead of a silent error-counter bump.
|
# next breakage is diagnosable instead of a silent error-counter bump.
|
||||||
|
#
|
||||||
|
# #2674: "loudly" было сказано, но написано WARNING — тише, чем
|
||||||
|
# соседние 5xx/сетевые ветки, и НЕ событие в скрапере
|
||||||
|
# (LoggingIntegration event_level=ERROR). При этом 404 — самая
|
||||||
|
# ПЕРМАНЕНТНАЯ из трёх: 5xx и сетевой сбой сами пройдут, а
|
||||||
|
# переименованный slug будет 404-ить каждый месяц, пока человек не
|
||||||
|
# перезахватит dataset-path. Ровно тот сбой, из-за которого бенчмарк
|
||||||
|
# перестаёт обновляться.
|
||||||
if exc.response.status_code == 404:
|
if exc.response.status_code == 404:
|
||||||
logger.warning(
|
logger.error(
|
||||||
"sber_index: 404 for dashboard=%s ref_area=%s filter=%s — "
|
"sber_index: 404 for dashboard=%s ref_area=%s filter=%s — "
|
||||||
"dataset-path invalid? slug renamed or filter dims stale "
|
"dataset-path invalid? slug renamed or filter dims stale "
|
||||||
"(re-capture /dataset/v1/<slug> via dashboard route-interception)",
|
"(re-capture /dataset/v1/<slug> via dashboard route-interception)",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ scheduling-путь (`app/scheduler_main.py` безусловно запуска
|
||||||
Что осталось в этом модуле — НЕ scheduler-loop, а функции с живыми потребителями вне
|
Что осталось в этом модуле — НЕ scheduler-loop, а функции с живыми потребителями вне
|
||||||
удалённой machinery:
|
удалённой machinery:
|
||||||
- `compute_next_run_at` — читается admin.py (операторский предпросмотр "next run").
|
- `compute_next_run_at` — читается admin.py (операторский предпросмотр "next run").
|
||||||
|
С #2674 это re-export kit-версии, а не вторая копия формулы.
|
||||||
- `has_running_run` — читается admin.py (UI-индикатор "уже бежит").
|
- `has_running_run` — читается admin.py (UI-индикатор "уже бежит").
|
||||||
- `import_rosreestr_dkp` — job-тело, вызываемое kit-handler'ом
|
- `import_rosreestr_dkp` — job-тело, вызываемое kit-handler'ом
|
||||||
product_handlers._job_rosreestr_dkp (lazy import).
|
product_handlers._job_rosreestr_dkp (lazy import).
|
||||||
|
|
@ -25,16 +26,24 @@ Zombie-reap, advisory-lock claim и tick-loop теперь целиком в
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import random
|
|
||||||
from datetime import UTC, datetime, time, timedelta
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
# compute_next_run_at жил здесь ВТОРОЙ, побайтово одинаковой копией kit-версии (#2674).
|
||||||
|
# Обе копии одинаково умели interval_days — но такт доезжал до next_run_at только через
|
||||||
|
# kit (_claim_run/_defer_next_run_at читают default_params["interval_days"]); admin.py
|
||||||
|
# звал эту копию БЕЗ аргумента, получал default=1 и сбивал любой источник на «завтра».
|
||||||
|
# Копия удалена, а не подправлена: пока формула лежит в двух файлах, следующая правка
|
||||||
|
# такта снова разъедется по одному из них. Re-export (а не правка импорта у вызывающих)
|
||||||
|
# сохраняет `from app.services.scheduler import compute_next_run_at` в admin.py и тестах.
|
||||||
|
from scraper_kit.orchestration.scheduler import compute_next_run_at
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.shutdown import shutdown_requested
|
from app.core.shutdown import shutdown_requested
|
||||||
from app.services import scrape_runs as runs_mod
|
from app.services import scrape_runs as runs_mod
|
||||||
|
|
||||||
|
__all__ = ["compute_next_run_at", "has_running_run"]
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# import_rosreestr_dkp: доля per-row INSERT-ошибок (rows_errored / rows_fetched), выше
|
# import_rosreestr_dkp: доля per-row INSERT-ошибок (rows_errored / rows_fetched), выше
|
||||||
|
|
@ -43,54 +52,6 @@ logger = logging.getLogger(__name__)
|
||||||
DKP_IMPORT_ERROR_RATE_THRESHOLD = 0.05
|
DKP_IMPORT_ERROR_RATE_THRESHOLD = 0.05
|
||||||
|
|
||||||
|
|
||||||
def compute_next_run_at(
|
|
||||||
window_start_hour: int,
|
|
||||||
window_end_hour: int,
|
|
||||||
*,
|
|
||||||
now: datetime | None = None,
|
|
||||||
interval_days: int = 1,
|
|
||||||
) -> datetime:
|
|
||||||
"""Pick random datetime в window [start, end) UTC, через interval_days суток после now.
|
|
||||||
|
|
||||||
interval_days задаёт каденс источника: 1 (default) = daily (back-compat), 7 = weekly.
|
|
||||||
Берётся из schedule.default_params["interval_days"] вызывающим кодом; отсутствие ключа
|
|
||||||
→ 1 → прежнее ежедневное поведение.
|
|
||||||
|
|
||||||
Если window_end_hour <= window_start_hour → cross-midnight window
|
|
||||||
(например 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:
|
def has_running_run(db: Session, source: str) -> bool:
|
||||||
"""Есть ли активный run для source (status='running')."""
|
"""Есть ли активный run для source (status='running')."""
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
|
|
@ -115,7 +76,16 @@ async def _execute_cian_backfill(
|
||||||
"""Orchestrate Cian history backfill with heartbeat + checkpoint.
|
"""Orchestrate Cian history backfill with heartbeat + checkpoint.
|
||||||
|
|
||||||
Wraps backfill_cian_history(), updating scrape_runs counters (via update_heartbeat)
|
Wraps backfill_cian_history(), updating scrape_runs counters (via update_heartbeat)
|
||||||
before and after the batch call for zombie-detection visibility.
|
НА КАЖДОЙ сущности батча, а не только до и после него (#2725). Раньше сигнал
|
||||||
|
живости слался ровно один раз — до батча, — а `reap_zombies` меряет именно
|
||||||
|
heartbeat_at с порогом 6 ч, и добивал живые прогоны строго на 6-м часу: 6 прод-
|
||||||
|
прогонов этого источника помечены 'zombie' со сдвигом heartbeat 16-32 мс, при том
|
||||||
|
что у пятерых внутри окна писались строки offer_price_history (у прогона 304 — до
|
||||||
|
5.4 ч после старта), а штатная длительность источника доходит до 5.06 ч (346).
|
||||||
|
Цена ошибки не косметическая: mark_done апдейтит WHERE status='running', так что
|
||||||
|
после ложной пометки собственный финал прогона становится no-op (отсюда нулевые
|
||||||
|
counters у всех шести), а has_running_run перестаёт видеть прогон и следующий тик
|
||||||
|
может запустить второй такой же батч поверх работающего.
|
||||||
|
|
||||||
Checkpoint/resume semantics: backfill_cian_history() queries rows WHERE history IS
|
Checkpoint/resume semantics: backfill_cian_history() queries rows WHERE history IS
|
||||||
NULL via LEFT JOIN — so re-running after a partial completion naturally skips
|
NULL via LEFT JOIN — so re-running after a partial completion naturally skips
|
||||||
|
|
@ -124,9 +94,33 @@ async def _execute_cian_backfill(
|
||||||
Params (from default_params jsonb):
|
Params (from default_params jsonb):
|
||||||
batch_size: int — rows per run (listings + houses counted separately).
|
batch_size: int — rows per run (listings + houses counted separately).
|
||||||
"""
|
"""
|
||||||
from app.tasks.cian_history_backfill import backfill_cian_history
|
from app.tasks.cian_history_backfill import CianBackfillResult, backfill_cian_history
|
||||||
|
|
||||||
batch_size = int(params.get("batch_size", 100))
|
batch_size = int(params.get("batch_size", 100))
|
||||||
|
|
||||||
|
def _counters(result: CianBackfillResult) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"listings_processed": result.listings_processed,
|
||||||
|
"listings_succeeded": result.listings_succeeded,
|
||||||
|
"listings_failed": result.listings_failed_fetch + result.listings_failed_save,
|
||||||
|
"houses_processed": result.houses_processed,
|
||||||
|
"houses_succeeded": result.houses_succeeded,
|
||||||
|
"houses_failed": result.houses_failed_fetch + result.houses_failed_save,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _heartbeat(progress: CianBackfillResult) -> None:
|
||||||
|
"""Сигнал живости из середины батча. Best-effort: сбой heartbeat не должен
|
||||||
|
ронять уже идущую работу — прогон в худшем случае вернётся к прежнему
|
||||||
|
поведению (пометка 'zombie' на 6-м часу)."""
|
||||||
|
try:
|
||||||
|
runs_mod.update_heartbeat(db, run_id, _counters(progress))
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"scheduler: cian_history_backfill run_id=%d heartbeat failed (ignored)",
|
||||||
|
run_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
counters: dict[str, int] = {
|
counters: dict[str, int] = {
|
||||||
"listings_processed": 0,
|
"listings_processed": 0,
|
||||||
"listings_succeeded": 0,
|
"listings_succeeded": 0,
|
||||||
|
|
@ -145,17 +139,10 @@ async def _execute_cian_backfill(
|
||||||
do_listings=True,
|
do_listings=True,
|
||||||
do_houses=True,
|
do_houses=True,
|
||||||
do_valuations=False,
|
do_valuations=False,
|
||||||
|
on_progress=_heartbeat,
|
||||||
)
|
)
|
||||||
|
|
||||||
counters = {
|
counters = {**_counters(result), "duration_sec": int(result.duration_sec)}
|
||||||
"listings_processed": result.listings_processed,
|
|
||||||
"listings_succeeded": result.listings_succeeded,
|
|
||||||
"listings_failed": result.listings_failed_fetch + result.listings_failed_save,
|
|
||||||
"houses_processed": result.houses_processed,
|
|
||||||
"houses_succeeded": result.houses_succeeded,
|
|
||||||
"houses_failed": result.houses_failed_fetch + result.houses_failed_save,
|
|
||||||
"duration_sec": int(result.duration_sec),
|
|
||||||
}
|
|
||||||
runs_mod.mark_done(db, run_id, counters)
|
runs_mod.mark_done(db, run_id, counters)
|
||||||
logger.info(
|
logger.info(
|
||||||
"scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs",
|
"scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs",
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,39 @@
|
||||||
|
|
||||||
Таблица scrape_runs создана в 015_scrape_runs.sql.
|
Таблица scrape_runs создана в 015_scrape_runs.sql.
|
||||||
Расширена в 051_scrape_runs_extend.sql: params/counters/error/finished_at/cancelled.
|
Расширена в 051_scrape_runs_extend.sql: params/counters/error/finished_at/cancelled.
|
||||||
|
|
||||||
|
ВРЕМЯ ПИШЕТСЯ clock_timestamp(), А НЕ now() (#2702). `now()` в PostgreSQL —
|
||||||
|
синоним `transaction_timestamp()`: он замерзает на СТАРТЕ транзакции и не двигается,
|
||||||
|
сколько бы та ни жила. Финализаторы (mark_done/mark_failed/mark_banned) выполняются
|
||||||
|
ТОЙ ЖЕ сессией, что и работа задачи, — и если рабочая транзакция всё это время
|
||||||
|
оставалась открытой (задача ничего не коммитила: нечего было сохранять, батч читающий,
|
||||||
|
сохранение шло чужой сессией), их UPDATE попадал ВНУТРЬ неё, и `finished_at` получал
|
||||||
|
время НАЧАЛА работы, а не её конца.
|
||||||
|
|
||||||
|
Замер на проде 2026-08-06 (487 прогонов, у которых есть и finished_at, и счётчик
|
||||||
|
counters.duration_sec): у 153 заявленная длительность превышала собственное окно
|
||||||
|
finished_at − started_at более чем в 1.5 раза, у 133 окно было меньше секунды при
|
||||||
|
работе дольше 10 с. 126 из этих 133 окон лежат в диапазоне 9-64 мс — это не разброс,
|
||||||
|
а подпись механизма: столько проходит от коммита claim'а до первого запроса рабочей
|
||||||
|
транзакции. Крайний случай — прогон 346 (cian_history_backfill): 18230 с работы,
|
||||||
|
окно 32 мс.
|
||||||
|
|
||||||
|
Дефект был не сплошной ровно потому, что зависел от того, коммитила ли задача перед
|
||||||
|
финалом: cadastral_geo_match / house_imv_backfill / avito_detail_backfill коммитят
|
||||||
|
поштучно, у них окно совпадало с работой; yandex_address_backfill (45 из 50 прогонов),
|
||||||
|
newbuilding_enrich, cian_history_backfill — нет.
|
||||||
|
|
||||||
|
Побочно это чинит и `heartbeat_at`: он писался тем же `now()` и по той же причине
|
||||||
|
отставал от реальности на возраст открытой транзакции, а на нём стоит поиск зависших
|
||||||
|
прогонов (reap_zombies, порог 6 ч).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from functools import cache
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
|
|
@ -21,6 +48,130 @@ logger = logging.getLogger(__name__)
|
||||||
# (anti-spam: не на каждой последующей).
|
# (anti-spam: не на каждой последующей).
|
||||||
CONSECUTIVE_FAILURE_ALERT_THRESHOLD = 3
|
CONSECUTIVE_FAILURE_ALERT_THRESHOLD = 3
|
||||||
|
|
||||||
|
# #2625: количество последовательных 'done' запусков с нулевым бизнес-результатом
|
||||||
|
# (total_seen=0), при достижении которого отправляется Sentry alert. Статус 'done'
|
||||||
|
# формально успешен (errors_count=0), но капча/пустая выдача/смена вёрстки источника
|
||||||
|
# без детекта (см. providers/cian, providers/yandex) деградируют молча — этот класс
|
||||||
|
# невидим для CONSECUTIVE_FAILURE_ALERT_THRESHOLD (тот считает только failed/banned).
|
||||||
|
CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD = 3
|
||||||
|
|
||||||
|
# #2670: анти-спам «один раз на стрик» безопасен ТОЛЬКО там, где стрик прерывается
|
||||||
|
# не только в принципе, но и на практике. Оба сторожа ниже слали алерт ровно на N-й
|
||||||
|
# подряд неудаче и дальше молчали навсегда — а у постоянно сломанного источника
|
||||||
|
# «дальше» длится месяцами. Прод 2026-08-06: у avito_full_load 31 неудача подряд,
|
||||||
|
# последний успешный прогон 03.07 (34 дня без сбора), алерт был ровно один — на
|
||||||
|
# третьей; у avito_full_load_exhaustive 5 подряд. Тишина при этом неотличима от
|
||||||
|
# «всё хорошо» — ровно та ловушка, из-за которой #2574 месяц выглядела как норма.
|
||||||
|
#
|
||||||
|
# Вместо «ровно N» — разреженная лестница напоминаний: N, 2N, 4N, 8N…, а дальше не
|
||||||
|
# реже, чем раз в STREAK_ALERT_MAX_PERIOD×N прогонов. Лестница по ПРОГОНАМ, а не
|
||||||
|
# «раз в сутки», потому что источники идут разным тактом: domclick_city_sweep — раз
|
||||||
|
# в день, proxy_healthcheck — раз в полчаса; календарное разрежение для одного из
|
||||||
|
# них всегда будет либо спамом, либо молчанием.
|
||||||
|
STREAK_ALERT_MAX_PERIOD = 16
|
||||||
|
|
||||||
|
# Потолок сканирования истории источника при подсчёте стрика. Достигнутый потолок
|
||||||
|
# сам по себе повод для алерта (стрик заведомо огромен) — так «замолчать навсегда»
|
||||||
|
# невозможно по построению, а не по счастливому совпадению чисел.
|
||||||
|
STREAK_SCAN_LIMIT = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _streak_alert_due(streak: int, threshold: int) -> bool:
|
||||||
|
"""Достиг ли стрик очередной вехи напоминания (#2670).
|
||||||
|
|
||||||
|
True на threshold, 2×, 4×, 8×… и дальше на каждом кратном
|
||||||
|
STREAK_ALERT_MAX_PERIOD×threshold. Первый алерт приходит там же, где и раньше —
|
||||||
|
на N-й подряд неудаче; меняется только то, что он не последний.
|
||||||
|
"""
|
||||||
|
if streak < threshold or streak % threshold:
|
||||||
|
return False
|
||||||
|
mult = streak // threshold
|
||||||
|
if mult % STREAK_ALERT_MAX_PERIOD == 0:
|
||||||
|
return True
|
||||||
|
return mult & (mult - 1) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _leading_streak(rows: list[Any], is_bad: Callable[[Any], bool]) -> int:
|
||||||
|
"""Длина серии подряд идущих «плохих» строк с начала списка (свежие — первыми)."""
|
||||||
|
streak = 0
|
||||||
|
for row in rows:
|
||||||
|
if not is_bad(row):
|
||||||
|
break
|
||||||
|
streak += 1
|
||||||
|
return streak
|
||||||
|
|
||||||
|
|
||||||
|
# #2686: диагноз оборванного прогона. Пишется в scrape_runs.ban_kind (миграция 218)
|
||||||
|
# РЯДОМ со status='banned', а не ВМЕСТО него — сознательный выбор между «новый
|
||||||
|
# статус» и «явное поле причины»:
|
||||||
|
# 1. Побочная функция 'banned' — сохранение done_buckets-чекпоинта (mark_failed
|
||||||
|
# его теряет) — нужна ОБОИМ исходам. Оставив статус, получаем её даром; расщепив
|
||||||
|
# статус, пришлось бы дублировать её в каждом потребителе.
|
||||||
|
# 2. Новое значение статуса пришлось бы доучить пяти местам, каждое из которых
|
||||||
|
# молча даёт неверный ответ, если про него забыть: CHECK-констрейнт схемы,
|
||||||
|
# IN-списки обоих сторожей (_alert_if_consecutive_failures / _zero_results),
|
||||||
|
# Literal-фильтр admin API и хардкод-список статусов во фронте. Это ровно тот
|
||||||
|
# класс оборванной проводки, из-за которого задача и появилась.
|
||||||
|
# 3. Прогон в обоих случаях требует одного и того же обращения (оборвать, сохранить
|
||||||
|
# частичное); различается только ДИАГНОЗ — то есть метаданное, не состояние.
|
||||||
|
BAN_KIND_PLATFORM = "platform" # площадка показала firewall/403/captcha — внешнее
|
||||||
|
BAN_KIND_INFRA = "infra" # наш сайдкар/прокси не отдал страницу — внутреннее
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None:
|
||||||
|
"""Первое присутствующее из ``keys`` как int; None — ни одного ключа нет."""
|
||||||
|
for key in keys:
|
||||||
|
val = counters.get(key)
|
||||||
|
if val is not None:
|
||||||
|
try:
|
||||||
|
return int(val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# #2703: ключи, которыми задача сообщает СВОЙ бизнес-результат. Список намеренно
|
||||||
|
# короткий и состоит из синонимов ОДНОЙ величины — «сколько объявлений отдала выдача»:
|
||||||
|
# total_seen — если задача посчитала сама;
|
||||||
|
# lots_fetched — все city/newbuilding-sweep'ы (21 источник, 455 прогонов на проде);
|
||||||
|
# unique_fetched — full-load'ы avito/cian/yandex (4 источника, 133 прогона) — раньше
|
||||||
|
# сторож их не видел, хотя у cian_full_load 6 из 38 успешных прогонов
|
||||||
|
# реально дали ноль.
|
||||||
|
# Сводить сюда счётчики ОСТАЛЬНЫХ задач бессмысленно: на проде 28 источников (2650
|
||||||
|
# прогонов) не имеют общего результатного ключа вовсе — у каждого свой словарь
|
||||||
|
# (deactivated / rows_written / poi_loaded / snapshotted / upserted / listings_matched
|
||||||
|
# …), а у refresh_search_matview counters пусты буквально ({} во всех 55 строках) и у
|
||||||
|
# трёх мониторов результата нет по смыслу. Ноль у них — часто ЗДОРОВЫЙ ответ
|
||||||
|
# (deactivate_stale_* без протухших объявлений). Поэтому сторож не угадывает их
|
||||||
|
# словарь, а честно признаёт, что мерить нечем — см. _run_result_count.
|
||||||
|
_RESULT_COUNTER_KEYS = ("total_seen", "lots_fetched", "unique_fetched")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_result_count(counters: Mapping[str, Any] | None) -> int | None:
|
||||||
|
"""Бизнес-результат прогона; **None = прогон его не сообщил** (≠ ноль).
|
||||||
|
|
||||||
|
Ровно это различие и было потеряно: сторож читал колонку ``total_seen``, у
|
||||||
|
которой DEFAULT 0, поэтому «не измерено» и «измерено, ноль» выглядели одинаково.
|
||||||
|
"""
|
||||||
|
return _pick_int(counters or {}, *_RESULT_COUNTER_KEYS)
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def _warn_source_has_no_result_metric(source: str, keys: tuple[str, ...]) -> None:
|
||||||
|
"""Один раз на процесс: у источника нет ключа, по которому сторож судит (#2703).
|
||||||
|
|
||||||
|
Не алерт — алертить не о чем, судить не о чем тоже. Это делает слепую зону
|
||||||
|
ВИДИМОЙ: раньше её признаком был вечно молчащий сторож, выглядящий настроенным.
|
||||||
|
"""
|
||||||
|
logger.warning(
|
||||||
|
"zero-result watchdog неприменим к source=%s: counters не содержат ни одного "
|
||||||
|
"результатного ключа %s (есть: %s) — прогоны этого источника больше не считаются "
|
||||||
|
"нулевыми по умолчанию (#2703)",
|
||||||
|
source,
|
||||||
|
_RESULT_COUNTER_KEYS,
|
||||||
|
", ".join(keys) or "<пусто>",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
|
def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
|
||||||
"""Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.
|
"""Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.
|
||||||
|
|
@ -32,41 +183,37 @@ def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
|
||||||
показывала total_seen=0 при реально сохранённых строках (audit #1871/#1926).
|
показывала total_seen=0 при реально сохранённых строках (audit #1871/#1926).
|
||||||
|
|
||||||
Приоритет ключей:
|
Приоритет ключей:
|
||||||
- total_seen ← 'total_seen' (если уже есть в counters) иначе 'lots_fetched'
|
- total_seen ← _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched)
|
||||||
- new_count ← 'new_count' (если уже есть) иначе 'lots_inserted'
|
- new_count ← 'new_count' (если уже есть) иначе 'lots_inserted'
|
||||||
|
|
||||||
Возвращает (total_seen, new_count); None для ключа, которого нет в counters —
|
Возвращает (total_seen, new_count); None для ключа, которого нет в counters —
|
||||||
тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE).
|
тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE).
|
||||||
"""
|
"""
|
||||||
|
return _run_result_count(counters), _pick_int(counters, "new_count", "lots_inserted")
|
||||||
def _pick(*keys: str) -> int | None:
|
|
||||||
for key in keys:
|
|
||||||
val = counters.get(key)
|
|
||||||
if val is not None:
|
|
||||||
try:
|
|
||||||
return int(val)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
return _pick("total_seen", "lots_fetched"), _pick("new_count", "lots_inserted")
|
|
||||||
|
|
||||||
|
|
||||||
def _alert_if_consecutive_failures(db: Session, source: str) -> None:
|
def _alert_if_consecutive_failures(db: Session, source: str) -> None:
|
||||||
"""Отправить Sentry alert если последние CONSECUTIVE_FAILURE_ALERT_THRESHOLD
|
"""Sentry alert на серию из CONSECUTIVE_FAILURE_ALERT_THRESHOLD неудач подряд
|
||||||
завершённых запусков для данного source имеют статус 'failed' или 'banned'.
|
(статусы 'failed'/'banned') у данного source.
|
||||||
|
|
||||||
Anti-spam: алерт срабатывает ТОЛЬКО когда стрик РОВНО равен порогу — т.е. запрос
|
Anti-spam: не на каждой неудаче, а по разреженной лестнице вех (см.
|
||||||
возвращает ровно N последних (failed|banned) и (N+1)-й, если существует, НЕ является
|
_streak_alert_due). До #2670 алерт приходил РОВНО на N-й неудаче и дальше не
|
||||||
failed/banned. Это предотвращает повторный алерт на каждой ошибке сверх порога.
|
повторялся никогда: серия, ставшая длиннее порога, замолкала навсегда. На проде
|
||||||
|
это дало avito_full_load — 31 неудача подряд, 34 дня без единого успешного
|
||||||
|
прогона, один алерт за всё время.
|
||||||
|
|
||||||
|
Стрик прерывается любым завершением, кроме failed/banned, — по данным прода это
|
||||||
|
достижимо и достигается (у domclick_city_sweep текущий стрик равен 1 при 47
|
||||||
|
завершённых прогонах), поэтому лестница не вырождается в постоянный алерт.
|
||||||
|
|
||||||
Best-effort: весь блок обёрнут в try/except — сбой запроса или неинициализированный
|
Best-effort: весь блок обёрнут в try/except — сбой запроса или неинициализированный
|
||||||
Sentry НЕ должен нарушать вызывающий mark_* путь.
|
Sentry НЕ должен нарушать вызывающий mark_* путь.
|
||||||
"""
|
"""
|
||||||
|
if sentry_sdk is None:
|
||||||
|
return
|
||||||
n = CONSECUTIVE_FAILURE_ALERT_THRESHOLD
|
n = CONSECUTIVE_FAILURE_ALERT_THRESHOLD
|
||||||
try:
|
try:
|
||||||
# Берём последние N+1 завершённых (non-running) запусков по source.
|
# Завершённые (non-running) прогоны источника, самые свежие первыми.
|
||||||
# Сортируем по finished_at DESC чтобы самые свежие шли первыми.
|
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
|
|
@ -77,39 +224,116 @@ def _alert_if_consecutive_failures(db: Session, source: str) -> None:
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"source": source, "limit": n + 1},
|
{"source": source, "limit": STREAK_SCAN_LIMIT},
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
if len(rows) < n:
|
streak = _leading_streak(rows, lambda r: r.status in ("failed", "banned"))
|
||||||
# Ещё не набралось N завершённых запусков вообще — алерт не нужен.
|
capped = streak >= STREAK_SCAN_LIMIT
|
||||||
|
if not capped and not _streak_alert_due(streak, n):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Первые N должны быть все failed/banned.
|
|
||||||
first_n = rows[:n]
|
|
||||||
if not all(r.status in ("failed", "banned") for r in first_n):
|
|
||||||
return
|
|
||||||
|
|
||||||
# (N+1)-й запуск, если есть, тоже должен НЕ быть failed/banned — иначе мы уже
|
|
||||||
# должны были отправить алерт раньше и не стоит дублировать.
|
|
||||||
if len(rows) > n and rows[n].status in ("failed", "banned"):
|
|
||||||
return
|
|
||||||
|
|
||||||
# Стрик ровно достиг порога — отправляем алерт.
|
|
||||||
sentry_sdk.capture_message(
|
sentry_sdk.capture_message(
|
||||||
f"Scraper source '{source}' has {n} consecutive failed/banned runs — "
|
f"Scraper source '{source}' has {streak} consecutive failed/banned runs — "
|
||||||
"manual intervention may be required (expired cookies / ban / broken parser).",
|
"manual intervention may be required (expired cookies / ban / broken parser).",
|
||||||
level="error",
|
level="error",
|
||||||
)
|
)
|
||||||
logger.error(
|
logger.error(
|
||||||
"sentry alert sent: source=%s has %d consecutive failed/banned runs", source, n
|
"sentry alert sent: source=%s has %d consecutive failed/banned runs", source, streak
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # sentry_sdk not initialised in dev, or query failed — best-effort only
|
pass # sentry_sdk not initialised in dev, or query failed — best-effort only
|
||||||
|
|
||||||
|
|
||||||
def _alert_on_run_id(db: Session, run_id: int) -> None:
|
def _alert_if_consecutive_zero_results(db: Session, source: str) -> None:
|
||||||
"""Вспомогательная обёртка: извлекает source по run_id и вызывает
|
"""Отправить Sentry alert если последние CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD
|
||||||
_alert_if_consecutive_failures. Best-effort — не бросает исключений.
|
завершённых 'done' запусков для source дали ИЗМЕРЕННЫЙ нулевой результат (#2625).
|
||||||
|
|
||||||
|
Отличается от _alert_if_consecutive_failures: статус здесь формально 'done'
|
||||||
|
(errors_count=0) — деградация невидима существующему failed/banned алерту.
|
||||||
|
Причина обычно капча/пустая выдача источника, у которого нет (или не сработал)
|
||||||
|
детект блокировки (см. providers/cian/serp.py, providers/yandex/serp.py).
|
||||||
|
|
||||||
|
Anti-spam: та же разреженная лестница вех, что у _alert_if_consecutive_failures
|
||||||
|
(#2670) — N, 2N, 4N…, а не «ровно N и дальше тишина».
|
||||||
|
|
||||||
|
#2703: анти-спам «один раз на стрик» безопасен ТОЛЬКО там, где стрик может
|
||||||
|
прерваться. Сторож читал колонку total_seen (DEFAULT 0), которой у 28 из 53
|
||||||
|
источников не заполняет ничто — значит у них он читал 0 ВСЕГДА, в том числе у
|
||||||
|
полностью успешного прогона, стрик не прерывался никогда, и после первого
|
||||||
|
события сторож замолкал навсегда, продолжая выглядеть настроенным. Теперь
|
||||||
|
признак берётся из counters, а «не измерено» (None) стрик ПРЕРЫВАЕТ — ложный
|
||||||
|
вечный стрик стал невозможен по построению, а слепая зона логируется явно.
|
||||||
|
|
||||||
|
Best-effort: весь блок обёрнут в try/except — сбой запроса или неинициализированный
|
||||||
|
Sentry НЕ должен нарушать вызывающий mark_done путь.
|
||||||
|
"""
|
||||||
|
n = CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD
|
||||||
|
try:
|
||||||
|
# Те же non-running статусы, что у _alert_if_consecutive_failures — стрик
|
||||||
|
# 'done'-с-нулём прерывается ЛЮБЫМ другим завершением (failed/banned/done-
|
||||||
|
# с-результатом/cancelled/прогон без результатной метрики), не только успешным
|
||||||
|
# сбором. counters, а НЕ колонка total_seen: у колонки DEFAULT 0, по ней
|
||||||
|
# «не измерено» неотличимо от «ноль» (#2703).
|
||||||
|
rows = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT status, counters FROM scrape_runs
|
||||||
|
WHERE source = :source
|
||||||
|
AND status IN ('failed', 'banned', 'done', 'cancelled')
|
||||||
|
ORDER BY finished_at DESC NULLS LAST
|
||||||
|
LIMIT :limit
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"source": source, "limit": STREAK_SCAN_LIMIT},
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _is_zero_done(r: Any) -> bool:
|
||||||
|
"""Только ИЗМЕРЕННЫЙ ноль. Прогон без результатной метрики стрик ПРЕРЫВАЕТ.
|
||||||
|
|
||||||
|
Так недостижимое условие прерывания невозможно по построению: источник,
|
||||||
|
чей словарь счётчиков сторожу неизвестен, не копит ложный стрик и не
|
||||||
|
запирает анти-спам «один раз на стрик» в «один раз навсегда».
|
||||||
|
"""
|
||||||
|
return r.status == "done" and _run_result_count(r.counters) == 0
|
||||||
|
|
||||||
|
if _run_result_count(rows[0].counters) is None:
|
||||||
|
# Свежайший завершённый прогон не сообщил результата — судить нечем.
|
||||||
|
# Логируем (один раз на источник за процесс) вместо молчаливого нуля.
|
||||||
|
_warn_source_has_no_result_metric(source, tuple(sorted(rows[0].counters or {})))
|
||||||
|
return
|
||||||
|
|
||||||
|
streak = _leading_streak(rows, _is_zero_done)
|
||||||
|
capped = streak >= STREAK_SCAN_LIMIT
|
||||||
|
if not capped and not _streak_alert_due(streak, n):
|
||||||
|
return
|
||||||
|
|
||||||
|
sentry_sdk.capture_message(
|
||||||
|
f"Scraper source '{source}' has {streak} consecutive 'done' runs with zero "
|
||||||
|
"lots fetched — captcha/layout-change likely undetected "
|
||||||
|
"(manual check recommended).",
|
||||||
|
level="error",
|
||||||
|
)
|
||||||
|
logger.error(
|
||||||
|
"sentry alert sent: source=%s has %d consecutive zero-result 'done' runs",
|
||||||
|
source,
|
||||||
|
streak,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # sentry_sdk not initialised in dev, or query failed — best-effort only
|
||||||
|
|
||||||
|
|
||||||
|
def _alert_on_run_id(
|
||||||
|
db: Session,
|
||||||
|
run_id: int,
|
||||||
|
*,
|
||||||
|
checker: Callable[[Session, str], None] = _alert_if_consecutive_failures,
|
||||||
|
) -> None:
|
||||||
|
"""Вспомогательная обёртка: извлекает source по run_id и вызывает `checker`
|
||||||
|
(default _alert_if_consecutive_failures; mark_done передаёт
|
||||||
|
_alert_if_consecutive_zero_results — #2625). Best-effort — не бросает исключений.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
|
|
@ -118,22 +342,29 @@ def _alert_on_run_id(db: Session, run_id: int) -> None:
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
return
|
return
|
||||||
_alert_if_consecutive_failures(db, str(row.source))
|
checker(db, str(row.source))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int:
|
def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int:
|
||||||
"""INSERT scrape_runs(source, status='running', params, started_at=NOW()).
|
"""INSERT scrape_runs(source, status='running', params, started_at=clock_timestamp()).
|
||||||
|
|
||||||
run_type DEFAULT 'city_sweep' (из 051 миграции).
|
started_at пишется СВОЕЙ транзакцией (db.commit() ниже) — откат рабочей
|
||||||
|
транзакции задачи его уже не достаёт (#2702).
|
||||||
|
|
||||||
|
Вид прогона несёт сам `source` (avito_city_sweep / domclick_detail_backfill / …);
|
||||||
|
отдельной колонки run_type больше нет — она 3244 прогона подряд молчала
|
||||||
|
дефолтом 'city_sweep' и подписывала им, например, proxy_healthcheck (#2674).
|
||||||
Returns run_id (bigint).
|
Returns run_id (bigint).
|
||||||
"""
|
"""
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO scrape_runs (source, status, params, started_at, heartbeat_at)
|
INSERT INTO scrape_runs (source, status, params, started_at, heartbeat_at)
|
||||||
VALUES (:source, 'running', CAST(:params AS jsonb), NOW(), NOW())
|
VALUES (
|
||||||
|
:source, 'running', CAST(:params AS jsonb), clock_timestamp(), clock_timestamp()
|
||||||
|
)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -145,7 +376,7 @@ def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int:
|
||||||
|
|
||||||
|
|
||||||
def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None:
|
def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None:
|
||||||
"""UPDATE heartbeat_at=NOW(), counters=:counters + total_seen/new_count колонки.
|
"""UPDATE heartbeat_at + counters=:counters + total_seen/new_count колонки.
|
||||||
|
|
||||||
total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и
|
total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и
|
||||||
пишутся в выделенные колонки, чтобы observability не показывала 0 (audit #1926).
|
пишутся в выделенные колонки, чтобы observability не показывала 0 (audit #1926).
|
||||||
|
|
@ -156,7 +387,7 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
UPDATE scrape_runs
|
UPDATE scrape_runs
|
||||||
SET heartbeat_at = NOW(),
|
SET heartbeat_at = clock_timestamp(),
|
||||||
counters = CAST(:counters AS jsonb),
|
counters = CAST(:counters AS jsonb),
|
||||||
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
||||||
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
||||||
|
|
@ -173,6 +404,27 @@ def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# Источники, чей джоб РЕАЛЬНО опрашивает status='cancelled' в своём цикле.
|
||||||
|
# Всё остальное отменить нельзя: строка стала бы 'cancelled', а задача продолжила бы
|
||||||
|
# работать — это, во-первых, ещё один врущий статус, во-вторых (хуже) обход guard'а
|
||||||
|
# has_running_run: он перестанет видеть прогон как running и пустит второй свип на том
|
||||||
|
# же прокси-IP → бан (инцидент 2026-05-31, runs #26+#27).
|
||||||
|
# Состав проверен по call-site'ам runs.is_cancelled: kit pipeline (city-sweep'ы всех
|
||||||
|
# площадок и городов, full-load'ы, avito_newbuilding_sweep) + rosreestr_dkp_import
|
||||||
|
# (scheduler.py). yandex_newbuilding_sweep отмену НЕ опрашивает — поэтому правило не
|
||||||
|
# «любой *_sweep». Актуально с #2674: до починки фильтра таблица прогонов была пуста
|
||||||
|
# на всех вкладках, кнопка отмены не рендерилась ни разу и дыра не проявлялась.
|
||||||
|
_CANCEL_HONORING_EXACT = frozenset({"avito_newbuilding_sweep", "rosreestr_dkp_import"})
|
||||||
|
_CANCEL_HONORING_SUBSTRINGS = ("city_sweep", "full_load")
|
||||||
|
|
||||||
|
|
||||||
|
def honors_cancel(source: str) -> bool:
|
||||||
|
"""True, если джоб этого source опрашивает отмену и реально остановится."""
|
||||||
|
return source in _CANCEL_HONORING_EXACT or any(
|
||||||
|
key in source for key in _CANCEL_HONORING_SUBSTRINGS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_cancelled(db: Session, run_id: int) -> bool:
|
def is_cancelled(db: Session, run_id: int) -> bool:
|
||||||
"""Проверить status='cancelled' (cooperative cancel в long-running pipeline)."""
|
"""Проверить status='cancelled' (cooperative cancel в long-running pipeline)."""
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
|
|
@ -183,7 +435,7 @@ def is_cancelled(db: Session, run_id: int) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
|
def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
|
||||||
"""Финализация run: status='done', finished_at=NOW(), counters + total_seen/new_count.
|
"""Финализация run: status='done', finished_at + counters + total_seen/new_count.
|
||||||
|
|
||||||
total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся
|
total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся
|
||||||
в выделенные колонки — иначе admin/observability показывает 0 (audit #1926).
|
в выделенные колонки — иначе admin/observability показывает 0 (audit #1926).
|
||||||
|
|
@ -193,7 +445,8 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
UPDATE scrape_runs
|
UPDATE scrape_runs
|
||||||
SET status = 'done', finished_at = NOW(), heartbeat_at = NOW(),
|
SET status = 'done',
|
||||||
|
finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(),
|
||||||
counters = CAST(:counters AS jsonb),
|
counters = CAST(:counters AS jsonb),
|
||||||
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
||||||
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
||||||
|
|
@ -211,6 +464,10 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
|
||||||
if row is None:
|
if row is None:
|
||||||
logger.warning("mark_done no-op: run_id=%d not in 'running' state", run_id)
|
logger.warning("mark_done no-op: run_id=%d not in 'running' state", run_id)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# #2625: N подряд 'done' с нулевым бизнес-результатом — деградация, невидимая
|
||||||
|
# для failed/banned алерта (капча/пустая выдача под видом успеха). Best-effort,
|
||||||
|
# после коммита — статус уже персистирован в БД.
|
||||||
|
_alert_on_run_id(db, run_id, checker=_alert_if_consecutive_zero_results)
|
||||||
|
|
||||||
|
|
||||||
def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None:
|
def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None:
|
||||||
|
|
@ -228,7 +485,8 @@ def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int])
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
UPDATE scrape_runs
|
UPDATE scrape_runs
|
||||||
SET status = 'failed', finished_at = NOW(), heartbeat_at = NOW(),
|
SET status = 'failed',
|
||||||
|
finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(),
|
||||||
error = :error, counters = CAST(:counters AS jsonb),
|
error = :error, counters = CAST(:counters AS jsonb),
|
||||||
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
||||||
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
||||||
|
|
@ -251,11 +509,29 @@ def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int])
|
||||||
_alert_on_run_id(db, run_id)
|
_alert_on_run_id(db, run_id)
|
||||||
|
|
||||||
|
|
||||||
def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None:
|
def mark_banned(
|
||||||
"""Финализация run: status='banned' (IP заблокирован Avito — 403/captcha).
|
db: Session,
|
||||||
|
run_id: int,
|
||||||
|
error: str,
|
||||||
|
counters: dict[str, int],
|
||||||
|
*,
|
||||||
|
ban_kind: str = BAN_KIND_PLATFORM,
|
||||||
|
) -> None:
|
||||||
|
"""Финализация run: status='banned' + диагноз ban_kind (#2686).
|
||||||
|
|
||||||
Per migration 015 — 'banned' задокументирован как 'Avito вернул 403/captcha'.
|
Per migration 015 — 'banned' задокументирован как 'Avito вернул 403/captcha'.
|
||||||
Отличается от 'failed': это external constraint, не наш bug. Cooldown 2-4 часа.
|
Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим
|
||||||
|
багом, и — важно — СОХРАНЯЕТ done_buckets-чекпоинт в counters (mark_failed его
|
||||||
|
теряет). Cooldown 2-4 часа.
|
||||||
|
|
||||||
|
`ban_kind` разводит два исхода, которые раньше схлопывались в один статус:
|
||||||
|
- BAN_KIND_PLATFORM — площадка нас заблокировала (firewall/403/captcha);
|
||||||
|
- BAN_KIND_INFRA — упала НАША инфраструктура (браузерный сайдкар/прокси).
|
||||||
|
Значение приходит от места ПОРОЖДЕНИЯ отказа (тип исключения), а не из разбора
|
||||||
|
текста ошибки. Default 'platform' = историческая семантика статуса, поэтому
|
||||||
|
вызывающие, которым разводить нечего, не меняются.
|
||||||
|
|
||||||
|
Оба исхода одинаково сохраняют чекпоинт — они отличаются только диагнозом.
|
||||||
|
|
||||||
Defensive rollback: если до этого вызова в той же транзакции был ошибочный UPDATE,
|
Defensive rollback: если до этого вызова в той же транзакции был ошибочный UPDATE,
|
||||||
он мог оставить сессию в error state — rollback сбрасывает состояние.
|
он мог оставить сессию в error state — rollback сбрасывает состояние.
|
||||||
|
|
@ -269,8 +545,10 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int])
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
UPDATE scrape_runs
|
UPDATE scrape_runs
|
||||||
SET status = 'banned', finished_at = NOW(), heartbeat_at = NOW(),
|
SET status = 'banned',
|
||||||
|
finished_at = clock_timestamp(), heartbeat_at = clock_timestamp(),
|
||||||
error = :error, counters = CAST(:counters AS jsonb),
|
error = :error, counters = CAST(:counters AS jsonb),
|
||||||
|
ban_kind = :ban_kind,
|
||||||
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
||||||
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
||||||
WHERE id = :run_id AND status = 'running'
|
WHERE id = :run_id AND status = 'running'
|
||||||
|
|
@ -281,6 +559,7 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int])
|
||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
"error": error[:1000],
|
"error": error[:1000],
|
||||||
"counters": json.dumps(counters),
|
"counters": json.dumps(counters),
|
||||||
|
"ban_kind": ban_kind,
|
||||||
"total_seen": total_seen,
|
"total_seen": total_seen,
|
||||||
"new_count": new_count,
|
"new_count": new_count,
|
||||||
},
|
},
|
||||||
|
|
@ -292,13 +571,93 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int])
|
||||||
_alert_on_run_id(db, run_id)
|
_alert_on_run_id(db, run_id)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_backfill_finished(
|
||||||
|
db: Session,
|
||||||
|
run_id: int,
|
||||||
|
counters: dict[str, int],
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
aborted_by_blocks: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Честный финал detail-backfill'а (#2674): нулевой прогон ≠ 'done'.
|
||||||
|
|
||||||
|
Все три detail-backfill'а (avito/yandex/domclick) финализировались ОДНИМ
|
||||||
|
mark_done: прогон, который сделал N попыток и не обогатил НИ ОДНОГО объявления,
|
||||||
|
отчитывался успехом. На проде (2026-08-06) это 78 прогонов из 158 —
|
||||||
|
avito 23/76 (в т.ч. 5 прогонов по 1500-1600 попыток с нулём обогащений),
|
||||||
|
yandex 31/52 (все attempted=5 failed=5), domclick 24/30 (494 попытки → 0).
|
||||||
|
|
||||||
|
Существующие алерты этот класс не ловили: _alert_if_consecutive_failures
|
||||||
|
считает только failed/banned, а _alert_if_consecutive_zero_results смотрит
|
||||||
|
total_seen, которого в counters backfill'ов нет вовсе (всегда 0 → стрик не
|
||||||
|
прерывается никогда → анти-спам молчит после первого раза).
|
||||||
|
|
||||||
|
Правила (порядок важен), по образцу #2657 для domclick_city_sweep:
|
||||||
|
- попыток не было (attempted=0) → 'done', честная пустота: кандидатов нет;
|
||||||
|
- есть блоки источника И (прогон оборван брейкером ИЛИ ноль результата)
|
||||||
|
→ 'banned': external constraint, не наш баг (и триггер ротации IP #2611);
|
||||||
|
- ноль результата без блоков → 'failed': это наша поломка (парсер/сеть/БД);
|
||||||
|
- иначе (обогатили хоть что-то) → 'done', в т.ч. частичный прогон.
|
||||||
|
|
||||||
|
`gone` (404 у avito) считается результатом наравне с `enriched`: прогон,
|
||||||
|
который подтвердил снятие объявлений, работу сделал.
|
||||||
|
"""
|
||||||
|
attempted = int(counters.get("attempted") or 0)
|
||||||
|
enriched = int(counters.get("enriched") or 0)
|
||||||
|
blocked = int(counters.get("blocked") or 0)
|
||||||
|
produced = enriched + int(counters.get("gone") or 0)
|
||||||
|
|
||||||
|
if attempted == 0:
|
||||||
|
mark_done(db, run_id, counters)
|
||||||
|
return
|
||||||
|
|
||||||
|
if blocked and (aborted_by_blocks or produced == 0):
|
||||||
|
reason = (
|
||||||
|
f"backfill-honest-status: {source} остановлен блоками источника — "
|
||||||
|
f"blocked={blocked}, обогащено {enriched} из {attempted} попыток (#2674)"
|
||||||
|
)
|
||||||
|
logger.error("%s run_id=%d", reason, run_id)
|
||||||
|
mark_banned(db, run_id, reason, counters)
|
||||||
|
return
|
||||||
|
|
||||||
|
if produced == 0:
|
||||||
|
reason = (
|
||||||
|
f"backfill-honest-status: {source} без результата — 0 обогащено из "
|
||||||
|
f"{attempted} попыток (failed={counters.get('failed', 0)}, "
|
||||||
|
f"blocked={blocked}) (#2674)"
|
||||||
|
)
|
||||||
|
logger.error("%s run_id=%d", reason, run_id)
|
||||||
|
mark_failed(db, run_id, reason, counters)
|
||||||
|
return
|
||||||
|
|
||||||
|
mark_done(db, run_id, counters)
|
||||||
|
|
||||||
|
|
||||||
def mark_cancelled(db: Session, run_id: int) -> bool:
|
def mark_cancelled(db: Session, run_id: int) -> bool:
|
||||||
"""Set status='cancelled' если currently 'running'. Returns True если cancelled."""
|
"""Set status='cancelled' если currently 'running'. Returns True если cancelled.
|
||||||
|
|
||||||
|
Отказ (False) для source'ов, чей джоб отмену не опрашивает — см. honors_cancel:
|
||||||
|
там 'cancelled' был бы враньём в статусе и снял бы has_running_run-guard.
|
||||||
|
Ручки отмены source не проверяют (любая из пяти принимает любой run_id), поэтому
|
||||||
|
гейт стоит здесь — на общем узле всех пяти.
|
||||||
|
"""
|
||||||
|
row = db.execute(
|
||||||
|
text("SELECT source FROM scrape_runs WHERE id = :run_id"),
|
||||||
|
{"run_id": run_id},
|
||||||
|
).fetchone()
|
||||||
|
if row is not None and not honors_cancel(str(row.source)):
|
||||||
|
logger.warning(
|
||||||
|
"mark_cancelled отказ: run_id=%d source=%s не опрашивает отмену — "
|
||||||
|
"задача продолжила бы работать под статусом 'cancelled'",
|
||||||
|
run_id,
|
||||||
|
row.source,
|
||||||
|
)
|
||||||
|
return False
|
||||||
result = db.execute(
|
result = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
UPDATE scrape_runs
|
UPDATE scrape_runs
|
||||||
SET status = 'cancelled', finished_at = NOW()
|
SET status = 'cancelled', finished_at = clock_timestamp()
|
||||||
WHERE id = :run_id AND status = 'running'
|
WHERE id = :run_id AND status = 'running'
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"""
|
"""
|
||||||
|
|
@ -366,8 +725,8 @@ def list_all(
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
f"""
|
f"""
|
||||||
SELECT id AS run_id, source, run_type, status, params, counters,
|
SELECT id AS run_id, source, status, params, counters,
|
||||||
total_seen, new_count, started_at, finished_at,
|
ban_kind, total_seen, new_count, started_at, finished_at,
|
||||||
heartbeat_at, error AS error_text
|
heartbeat_at, error AS error_text
|
||||||
FROM scrape_runs
|
FROM scrape_runs
|
||||||
WHERE {where_sql}
|
WHERE {where_sql}
|
||||||
|
|
@ -381,3 +740,22 @@ def list_all(
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return total, [dict(r) for r in rows]
|
return total, [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def distinct_sources(db: Session) -> list[str]:
|
||||||
|
"""Все значения source, которые РЕАЛЬНО есть в scrape_runs (по алфавиту).
|
||||||
|
|
||||||
|
#2674: фильтр источников в админке был захардкожен тремя площадками
|
||||||
|
(avito/cian/yandex), а в таблице 53 разных source и ни одной строки с таким
|
||||||
|
точным значением — все три пункта фильтра давали пустую выдачу, а 76%
|
||||||
|
прогонов (включая всю площадку Домклик) отфильтровать было нечем.
|
||||||
|
Список обязан приходить из данных: новый source появляется в фильтре сам,
|
||||||
|
без правки кода.
|
||||||
|
|
||||||
|
Игнорирует фильтры /scrape/runs — иначе выбор источника вырезал бы из
|
||||||
|
выпадающего списка все остальные.
|
||||||
|
"""
|
||||||
|
rows = db.execute(
|
||||||
|
text("SELECT DISTINCT source FROM scrape_runs WHERE source IS NOT NULL ORDER BY source")
|
||||||
|
).fetchall()
|
||||||
|
return [str(r.source) for r in rows]
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,6 @@ class RealMatcherAdapter:
|
||||||
*,
|
*,
|
||||||
year_built: int | None = None,
|
year_built: int | None = None,
|
||||||
building_cadastral_number: str | None = None,
|
building_cadastral_number: str | None = None,
|
||||||
cadastral_number: str | None = None,
|
|
||||||
source_url: str | None = None,
|
source_url: str | None = None,
|
||||||
) -> tuple[int | None, float, str]:
|
) -> tuple[int | None, float, str]:
|
||||||
# house_id is None when the matcher refuses a numberless address without a
|
# house_id is None when the matcher refuses a numberless address without a
|
||||||
|
|
@ -80,7 +79,6 @@ class RealMatcherAdapter:
|
||||||
lon,
|
lon,
|
||||||
year_built=year_built,
|
year_built=year_built,
|
||||||
building_cadastral_number=building_cadastral_number,
|
building_cadastral_number=building_cadastral_number,
|
||||||
cadastral_number=cadastral_number,
|
|
||||||
source_url=source_url,
|
source_url=source_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -136,10 +134,6 @@ class RealScraperConfig:
|
||||||
def scraper_proxy_url(self) -> str | None:
|
def scraper_proxy_url(self) -> str | None:
|
||||||
return _settings.scraper_proxy_url
|
return _settings.scraper_proxy_url
|
||||||
|
|
||||||
@property
|
|
||||||
def avito_proxy_rotate_url(self) -> str | None:
|
|
||||||
return _settings.avito_proxy_rotate_url
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def avito_proxy_max_rotations(self) -> int:
|
def avito_proxy_max_rotations(self) -> int:
|
||||||
return _settings.avito_proxy_max_rotations
|
return _settings.avito_proxy_max_rotations
|
||||||
|
|
@ -148,10 +142,6 @@ class RealScraperConfig:
|
||||||
def avito_serp_ekb_only(self) -> bool:
|
def avito_serp_ekb_only(self) -> bool:
|
||||||
return _settings.avito_serp_ekb_only
|
return _settings.avito_serp_ekb_only
|
||||||
|
|
||||||
@property
|
|
||||||
def yandex_proxy_rotate_url(self) -> str | None:
|
|
||||||
return _settings.yandex_proxy_rotate_url
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cian_proxy_url(self) -> str | None:
|
def cian_proxy_url(self) -> str | None:
|
||||||
return _settings.cian_proxy_url
|
return _settings.cian_proxy_url
|
||||||
|
|
@ -189,10 +179,6 @@ class RealScraperConfig:
|
||||||
def proxy_rotate_attempt_timeout_s(self) -> float:
|
def proxy_rotate_attempt_timeout_s(self) -> float:
|
||||||
return _settings.proxy_rotate_attempt_timeout_s
|
return _settings.proxy_rotate_attempt_timeout_s
|
||||||
|
|
||||||
@property
|
|
||||||
def cian_proxy_rotate_url(self) -> str | None:
|
|
||||||
return _settings.cian_proxy_rotate_url
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cian_proxy_max_rotations(self) -> int:
|
def cian_proxy_max_rotations(self) -> int:
|
||||||
return _settings.cian_proxy_max_rotations
|
return _settings.cian_proxy_max_rotations
|
||||||
|
|
@ -219,6 +205,11 @@ class RealScraperConfig:
|
||||||
def use_proxy_pool_browser(self) -> bool:
|
def use_proxy_pool_browser(self) -> bool:
|
||||||
return _settings.use_proxy_pool_browser
|
return _settings.use_proxy_pool_browser
|
||||||
|
|
||||||
|
# ── #2616 шаг 1: признак окружения для отказа вместо мёртвого env-fallback ──
|
||||||
|
@property
|
||||||
|
def environment(self) -> str:
|
||||||
|
return _settings.environment
|
||||||
|
|
||||||
|
|
||||||
class RealProxyProvider:
|
class RealProxyProvider:
|
||||||
"""ProxyProvider-адаптер над `app.services.proxy_pool` (#2163).
|
"""ProxyProvider-адаптер над `app.services.proxy_pool` (#2163).
|
||||||
|
|
@ -267,6 +258,20 @@ class RealProxyProvider:
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
def touch(self, lease: ProxyLease) -> None:
|
||||||
|
db = _SessionLocal()
|
||||||
|
try:
|
||||||
|
_proxy_pool.touch(db, lease.id)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def mark_banned(self, lease: ProxyLease, *, source: str) -> None:
|
||||||
|
db = _SessionLocal()
|
||||||
|
try:
|
||||||
|
_proxy_pool.mark_banned(db, lease.id, source=source)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
class RealSessionFactory:
|
class RealSessionFactory:
|
||||||
"""SessionFactory-адаптер над `app.core.db.SessionLocal`."""
|
"""SessionFactory-адаптер над `app.core.db.SessionLocal`."""
|
||||||
|
|
|
||||||
|
|
@ -95,8 +95,12 @@ def build_search_query(params: SearchParams) -> tuple[str, dict[str, object]]:
|
||||||
where.append("total_floors <= CAST(:fl_total_max AS integer)")
|
where.append("total_floors <= CAST(:fl_total_max AS integer)")
|
||||||
args["fl_total_max"] = params.floors_total_max
|
args["fl_total_max"] = params.floors_total_max
|
||||||
|
|
||||||
if params.has_kadastr:
|
# Фильтр has_kadastr удалён (#2674): `listings.cadastral_number` (кадастр КВАРТИРЫ)
|
||||||
where.append("cadastral_number IS NOT NULL")
|
# пуст у всех 93 408 объявлений — площадки его не отдают (единственный писатель,
|
||||||
|
# парсер Циана, читает offer["cadastralNumber"], которого в ответе нет). Предикат
|
||||||
|
# `cadastral_number IS NOT NULL` мог вернуть только пустую выдачу, т.е. обещал
|
||||||
|
# качество данных, которого нет. Колонка и её писатель оставлены: если площадка
|
||||||
|
# начнёт отдавать кадастр, заполнение заработает само — тогда и вернём фильтр.
|
||||||
|
|
||||||
segment_clause = _SEGMENT_SQL[params.segment]
|
segment_clause = _SEGMENT_SQL[params.segment]
|
||||||
if segment_clause is not None:
|
if segment_clause is not None:
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,20 @@ snapshot_listing_sources / import_rosreestr_dkp.
|
||||||
Окно расписания 06:00-07:00 UTC — ПОСЛЕ rosreestr_dkp_import (04:00-06:00 UTC), чтобы
|
Окно расписания 06:00-07:00 UTC — ПОСЛЕ rosreestr_dkp_import (04:00-06:00 UTC), чтобы
|
||||||
refresh потреблял свежие ДКП-сделки того же дня.
|
refresh потреблял свежие ДКП-сделки того же дня.
|
||||||
|
|
||||||
SQL derivation ниже — БАЙТ-В-БАЙТ та же логика, что seed в data/sql/080_asking_to_sold_ratios.sql
|
SQL derivation ниже повторяет seed в data/sql/080_asking_to_sold_ratios.sql (deal_side /
|
||||||
(deal_side / ask_side / per_bucket + deal_global / ask_global / global_row: трейлинг-12мес
|
ask_side / per_bucket + deal_global / ask_global / global_row: трейлинг-12мес окно, ppm²-полоса
|
||||||
окно, ppm²-полоса [_PPM2_MIN, settings.asking_ratio_ppm2_max] (default [30000,1200000]),
|
[_PPM2_MIN, settings.asking_ratio_ppm2_max] (default [30000,1200000]), порог n_deals>=30 AND
|
||||||
бакет LEAST(GREATEST(rooms,0),4), порог n_deals>=30 AND n_listings>=30 для per_rooms,
|
n_listings>=30 для per_rooms, global -1 строка всегда). ON CONFLICT убран — DELETE идёт первым,
|
||||||
global -1 строка всегда). ON CONFLICT убран — DELETE идёт первым,
|
|
||||||
конфликтов нет (повторный прогон в одной tx невозможен, refresh = re-seed по семантике).
|
конфликтов нет (повторный прогон в одной tx невозможен, refresh = re-seed по семантике).
|
||||||
|
|
||||||
|
#2620 — ОДНО ПРЕДНАМЕРЕННОЕ РАСХОЖДЕНИЕ с 080: deal_side бакетится по
|
||||||
|
LEAST(GREATEST(rooms,0),4), а ask_side — по _AREA_ROOMS_BUCKET_SQL (площадь, та же формула,
|
||||||
|
что deals.rooms получает при импорте). Причина — deals.rooms НЕ настоящая комнатность
|
||||||
|
(Росреестр её не отдаёт), это синтетика из площади; сравнивать её с РЕАЛЬНЫМИ комнатами
|
||||||
|
listings значило сравнивать разные классификации. Замер на проде (2026-08, #2620) показал
|
||||||
|
миграцию 23-55% объявлений между бакетами при таком сравнении — не только в бакете «4+»
|
||||||
|
(который к тому же обрезан обрезкой ELSE 4, тогда как listings.rooms доходит до 10) — и
|
||||||
|
это и была причина ratio>1 в бакете 4+ (см. _AREA_ROOMS_BUCKET_SQL ниже).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -41,17 +49,56 @@ from app.services import scrape_runs as runs_mod
|
||||||
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
|
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
|
||||||
_PPM2_MIN: int = 30_000
|
_PPM2_MIN: int = 30_000
|
||||||
|
|
||||||
# #C2 — asking-сторона (listings) покрыта скрейпом ТОЛЬКО по ЕКБ (per-city scrape B1/B2
|
# #C2 — исторически asking-сторона (listings) была покрыта скрейпом ТОЛЬКО по ЕКБ, а
|
||||||
# ещё нет; в listings даже нет колонки city). Миграция 177 залила ДКП-сделки по всей
|
# миграция 177 залила ДКП-сделки по всей обл.66 (368 городов) → sold-медиана смешивала
|
||||||
# обл.66 (368 городов) → sold-медиана смешивала дешёвую область с ЕКБ-asking и обваливала
|
# дешёвую область с ЕКБ-asking и обваливала ratio (0.877→0.62, «выкупная» −29% системно).
|
||||||
# ratio (0.877→0.62, «выкупная» −29% системно). Скоупим SOLD-сторону (deal_side/deal_global)
|
# Скоупили SOLD-сторону (deal_side/deal_global) на ЕКБ, чтобы sold и asking считались по
|
||||||
# на ЕКБ, чтобы sold и asking считались по ОДНОМУ рынку. Когда появятся oblast-листинги —
|
# ОДНОМУ рынку.
|
||||||
# заменить на per-city ratio через зарезервированный столбец `district` (#647).
|
#
|
||||||
|
# #2583 H2 (аудит, 2026-08): oblast-развёртки заработали 12 июля — областные объявления
|
||||||
|
# попали в знаменатель (ask_side/ask_global) без городского скоупа, а sold-сторона
|
||||||
|
# осталась скоуплена на ЕКБ → асимметрия вернулась с другой стороны (дешёвая область
|
||||||
|
# занижает ask-медиану → ratio завышен на 2.5-5.3% по всем бакетам, выкупные цены
|
||||||
|
# системно переплачены). Теперь ask_side/ask_global ТОЖЕ скоупятся этим паттерном
|
||||||
|
# (предикат `city IS NULL OR city ILIKE :asking_city` — см. комментарий на месте в CTE
|
||||||
|
# ниже) — симметрично deal-стороне. Когда появится per-city ratio через зарезервированный
|
||||||
|
# столбец `district` (#647), эта константа станет per-city параметром для обеих сторон.
|
||||||
_ASKING_CITY_PATTERN: str = "%Екатеринбург%"
|
_ASKING_CITY_PATTERN: str = "%Екатеринбург%"
|
||||||
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
|
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
|
||||||
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
|
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
|
||||||
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
|
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
|
||||||
|
|
||||||
|
# #2620 — синтетический "бакет комнат по площади", ИСТОЧНИК ИСТИНЫ:
|
||||||
|
# tradein-mvp/deploy/import-rosreestr.sh (Росреестр не отдаёт комнатность — deals.rooms
|
||||||
|
# синтезируется из area_m2 при импорте ровно этим CASE). Три представления ОДНОЙ формулы —
|
||||||
|
# держи границы (30/44/62/85) в синхроне при правке: shell (import-rosreestr.sh) → SQL
|
||||||
|
# (эта константа, ask_side ниже) → Python (area_bucket() ниже, estimator.py rekey #2620-2).
|
||||||
|
_AREA_ROOMS_BUCKET_SQL = (
|
||||||
|
"CASE WHEN area_m2 < 30 THEN 0 WHEN area_m2 < 44 THEN 1 "
|
||||||
|
"WHEN area_m2 < 62 THEN 2 WHEN area_m2 < 85 THEN 3 ELSE 4 END"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def area_bucket(area_m2: float) -> int:
|
||||||
|
"""Python-двойник _AREA_ROOMS_BUCKET_SQL (границы ИДЕНТИЧНЫ, #2620).
|
||||||
|
|
||||||
|
Используется estimator.py при ПРИМЕНЕНИИ ratio (не только при расчёте здесь) —
|
||||||
|
ratio_resolver должен ключевать по ТОМУ ЖЕ area-бакету, что и ask_side при
|
||||||
|
деривации, иначе mismatch просто переезжает из расчёта в применение (прод-замер
|
||||||
|
ревьюера #2620: 310/1038 = 29.9% исторических запросов легли бы в другой бакет
|
||||||
|
при rooms-ключе vs area-ключе).
|
||||||
|
"""
|
||||||
|
if area_m2 < 30:
|
||||||
|
return 0
|
||||||
|
if area_m2 < 44:
|
||||||
|
return 1
|
||||||
|
if area_m2 < 62:
|
||||||
|
return 2
|
||||||
|
if area_m2 < 85:
|
||||||
|
return 3
|
||||||
|
return 4
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── True-mirror cleanup: drop all #648 rows before re-derivation ──────────────
|
# ── True-mirror cleanup: drop all #648 rows before re-derivation ──────────────
|
||||||
|
|
@ -69,15 +116,18 @@ _DELETE_SQL = text(
|
||||||
# deal_side / ask_side / per_bucket + deal_global / ask_global / global_row:
|
# deal_side / ask_side / per_bucket + deal_global / ask_global / global_row:
|
||||||
# sold_median = percentile_cont(0.5) по deals.price_per_m2 (source='rosreestr',
|
# sold_median = percentile_cont(0.5) по deals.price_per_m2 (source='rosreestr',
|
||||||
# ppm² ∈ [_PPM2_MIN, settings.asking_ratio_ppm2_max], deal_date >= CURRENT_DATE − 12 months),
|
# ppm² ∈ [_PPM2_MIN, settings.asking_ratio_ppm2_max], deal_date >= CURRENT_DATE − 12 months),
|
||||||
# бакет LEAST(GREATEST(rooms,0),4).
|
# бакет LEAST(GREATEST(rooms,0),4) (rooms уже синтетика-из-площади при импорте, см. #2620
|
||||||
|
# комментарий у _AREA_ROOMS_BUCKET_SQL выше).
|
||||||
# ask_median = percentile_cont(0.5) по listings.price_per_m2
|
# ask_median = percentile_cont(0.5) по listings.price_per_m2
|
||||||
# (is_active, та же ppm²-полоса [_PPM2_MIN, asking_ratio_ppm2_max]).
|
# (is_active, та же ppm²-полоса [_PPM2_MIN, asking_ratio_ppm2_max], тот же город что
|
||||||
|
# SOLD-сторона — city IS NULL OR city ILIKE :asking_city, #2583 H2). Бакет —
|
||||||
|
# _AREA_ROOMS_BUCKET_SQL (площадь, #2620), НЕ listings.rooms — см. комментарий там.
|
||||||
# per_rooms строки — только при n_deals>=30 AND n_listings>=30 AND ask>0 AND sold>0.
|
# per_rooms строки — только при n_deals>=30 AND n_listings>=30 AND ask>0 AND sold>0.
|
||||||
# global -1 строка (basis='global_fallback') — всегда (если ask>0 AND sold>0). window_months=12.
|
# global -1 строка (basis='global_fallback') — всегда (если ask>0 AND sold>0). window_months=12.
|
||||||
# Порог/окно — литералы; ppm²-полоса передаётся bind-параметрами :ppm2_min/:ppm2_max
|
# Порог/окно — литералы; ppm²-полоса передаётся bind-параметрами :ppm2_min/:ppm2_max
|
||||||
# (безопасно от SQL-инъекций; CAST не нужен — psycopg v3 передаёт int напрямую).
|
# (безопасно от SQL-инъекций; CAST не нужен — psycopg v3 передаёт int напрямую).
|
||||||
_REDERIVE_SQL = text(
|
_REDERIVE_SQL = text(
|
||||||
"""
|
f"""
|
||||||
WITH
|
WITH
|
||||||
-- SOLD медианы по бакетам комнат за трейлинг-12мес (ДКП Росреестра).
|
-- SOLD медианы по бакетам комнат за трейлинг-12мес (ДКП Росреестра).
|
||||||
deal_side AS (
|
deal_side AS (
|
||||||
|
|
@ -93,19 +143,37 @@ _REDERIVE_SQL = text(
|
||||||
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
||||||
GROUP BY LEAST(GREATEST(rooms, 0), 4)
|
GROUP BY LEAST(GREATEST(rooms, 0), 4)
|
||||||
),
|
),
|
||||||
-- ASKING медианы по бакетам комнат среди ТЕКУЩИХ активных объявлений.
|
-- ASKING медианы по ТОМУ ЖЕ area-бакету, что deal_side (#2620) — НЕ по listings.rooms.
|
||||||
|
-- deals.rooms — синтетика из площади (Росреестр её не отдаёт), listings.rooms — реальная
|
||||||
|
-- комнатность; сравнение area-бакета с area-бакетом (не area-бакета с real-rooms-бакетом)
|
||||||
|
-- убирает миграцию объявлений между бакетами (23-55% строк на проде, 2026-08, #2620) —
|
||||||
|
-- включая инверсию ratio>1 в бакете «4+» (deals.rooms обрезан ELSE 4, а listings.rooms
|
||||||
|
-- нет: 110/782 пяти- и более комнатных объявлений раньше схлопывались в бакет 4).
|
||||||
ask_side AS (
|
ask_side AS (
|
||||||
SELECT
|
SELECT
|
||||||
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket,
|
{_AREA_ROOMS_BUCKET_SQL} AS rooms_bucket,
|
||||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
|
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
|
||||||
COUNT(*) AS n_listings
|
COUNT(*) AS n_listings
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE is_active
|
WHERE is_active
|
||||||
AND rooms IS NOT NULL
|
AND rooms IS NOT NULL
|
||||||
|
-- #2620 hardening: area_m2 IS NULL falls into the CASE ELSE branch (bucket 4)
|
||||||
|
-- of _AREA_ROOMS_BUCKET_SQL — a latent "everything unmeasured looks like a big
|
||||||
|
-- flat" trap. Excluded explicitly instead of relying on ELSE-as-junk-drawer.
|
||||||
|
AND area_m2 IS NOT NULL
|
||||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||||||
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
||||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||||
GROUP BY LEAST(GREATEST(rooms, 0), 4)
|
-- #2583 H2: скоупим ASKING-сторону на тот же город, что и SOLD-сторона
|
||||||
|
-- (симметрично deal_side выше) — иначе дешёвые oblast-объявления (развёртки
|
||||||
|
-- с 12 июля) занижают ask-медиану и завышают ratio. city IS NULL считается
|
||||||
|
-- "своим" (не отбрасывается) НАМЕРЕННО: listings.city заполнена пока только у
|
||||||
|
-- Авито (Циан/Домклик/Яндекс — NULL, #2598/#2606), симметричный
|
||||||
|
-- `city ILIKE :asking_city` без IS NULL выбросил бы ~70% выборки. По мере
|
||||||
|
-- роста покрытия колонки этот предикат сам ужесточается без правок кода; когда
|
||||||
|
-- покрытие станет полным — заменить на строго симметричный `city ILIKE :asking_city`.
|
||||||
|
AND (city IS NULL OR city ILIKE :asking_city)
|
||||||
|
GROUP BY {_AREA_ROOMS_BUCKET_SQL}
|
||||||
),
|
),
|
||||||
-- Per-rooms строки: только бакеты с обеими сторонами, прошедшие порог 30/30 и ask>0.
|
-- Per-rooms строки: только бакеты с обеими сторонами, прошедшие порог 30/30 и ask>0.
|
||||||
-- Тонкие бакеты (n<30) сюда НЕ попадают → estimator делает fallback на -1.
|
-- Тонкие бакеты (n<30) сюда НЕ попадают → estimator делает fallback на -1.
|
||||||
|
|
@ -149,9 +217,15 @@ _REDERIVE_SQL = text(
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE is_active
|
WHERE is_active
|
||||||
AND rooms IS NOT NULL
|
AND rooms IS NOT NULL
|
||||||
|
-- #2620 hardening: same area_m2 IS NOT NULL as ask_side — keeps the global-row
|
||||||
|
-- population consistent with the per-bucket rows it's a fallback for.
|
||||||
|
AND area_m2 IS NOT NULL
|
||||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||||||
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
||||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||||
|
-- #2583 H2: тот же городской скоуп, что и ask_side выше (см. комментарий там
|
||||||
|
-- про причину city IS NULL == "свой" и #2598/#2606).
|
||||||
|
AND (city IS NULL OR city ILIKE :asking_city)
|
||||||
),
|
),
|
||||||
-- Global fallback строка rooms_bucket=-1 (пишется всегда, если ask>0).
|
-- Global fallback строка rooms_bucket=-1 (пишется всегда, если ask>0).
|
||||||
global_row AS (
|
global_row AS (
|
||||||
|
|
@ -210,6 +284,18 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
|
||||||
|
|
||||||
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
|
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
|
||||||
|
|
||||||
|
LIMITATION (#2620, честно задокументировано — не гард, а факт данных): sold-сторона
|
||||||
|
(deals) НЕ имеет маркера новостройка/вторичка — Росреестр таким свойством ДКП не
|
||||||
|
делится, а listing_segment (гард #1186) существует только у listings. ask_side/ask_global
|
||||||
|
отфильтрованы на вторичку, deal_side/deal_global — нет. Замер на проде (2026-08, #2620):
|
||||||
|
доля сделок с year_built >= 2020 (грубый прокси новостройки) — 44.1% в бакете «4+» против
|
||||||
|
23.1% в бакетах 1-3 — заметный перекос, но year_built НЕ идентифицирует первичку/вторичку
|
||||||
|
(продажа квартиры 2021 года постройки в 2026м — легитимная вторичка), поэтому фильтр по
|
||||||
|
году НЕ добавлен (создал бы новую, столь же спекулятивную асимметрию). Area-бакет-фикс
|
||||||
|
ниже (см. _AREA_ROOMS_BUCKET_SQL) сам по себе убрал инверсию ratio>1 в бакете «4+»
|
||||||
|
(0.8315 на замере прод-данных 2026-08, было 1.0257) — снятие миграции между бакетами было
|
||||||
|
root cause, а не новостройки.
|
||||||
|
|
||||||
Returns {"rows_written": N, "per_rooms_rows": M, "used_global_fallback": 0|1}.
|
Returns {"rows_written": N, "per_rooms_rows": M, "used_global_fallback": 0|1}.
|
||||||
"""
|
"""
|
||||||
counters: dict[str, int] = {
|
counters: dict[str, int] = {
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,9 @@ Legacy listings (older than 2h or outside radius) are never enriched.
|
||||||
Solution: single snapshot SELECT at start (guarantees termination), same proxy
|
Solution: single snapshot SELECT at start (guarantees termination), same proxy
|
||||||
session path as the detail-phase of `run_avito_city_sweep`
|
session path as the detail-phase of `run_avito_city_sweep`
|
||||||
(scraper_kit.orchestration.pipeline). Block handling mirrors that phase:
|
(scraper_kit.orchestration.pipeline). Block handling mirrors that phase:
|
||||||
rotate IP on every block, abort after max_consecutive_blocks (mark_done not
|
rotate IP on every block, abort after max_consecutive_blocks. Статус оборванного
|
||||||
mark_failed -- block is temporary, retry next night via NULL detail_enriched_at).
|
блоками прогона — 'banned' (#2674, runs.mark_backfill_finished): работу он не
|
||||||
|
доделал, остаток снапшота уедет в следующую ночь через NULL detail_enriched_at.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -30,6 +31,7 @@ from scraper_kit.avito_exceptions import (
|
||||||
AvitoRateLimitedError,
|
AvitoRateLimitedError,
|
||||||
)
|
)
|
||||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||||
|
from scraper_kit.orchestration.pipeline import CITY_LOCATIONS
|
||||||
|
|
||||||
# #2397 slice B (эпик #2277 decommission scrape_pipeline.py, Part E): раньше
|
# #2397 slice B (эпик #2277 decommission scrape_pipeline.py, Part E): раньше
|
||||||
# _CHROME_HEADERS/_avito_proxies() импортировались из app.services.scrape_pipeline.
|
# _CHROME_HEADERS/_avito_proxies() импортировались из app.services.scrape_pipeline.
|
||||||
|
|
@ -46,6 +48,7 @@ from scraper_kit.providers.avito.detail import (
|
||||||
save_detail_enrichment,
|
save_detail_enrichment,
|
||||||
)
|
)
|
||||||
from scraper_kit.providers.avito.serp import AvitoScraper
|
from scraper_kit.providers.avito.serp import AvitoScraper
|
||||||
|
from scraper_kit.snapshot_writer import upsert_listing_snapshot
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -70,6 +73,31 @@ __all__ = [
|
||||||
"run_avito_detail_backfill",
|
"run_avito_detail_backfill",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# #2576 этап B: oblast-города (region 66, вне ЕКБ) уже дают листинги (Каменск-
|
||||||
|
# Уральский), но snapshot-SELECT ниже раньше фильтровал ЖЁСТКО '%/ekaterinburg/%' —
|
||||||
|
# у всех остальных detail_enriched_at оставался NULL навсегда (без detail-страницы
|
||||||
|
# нет lat/lon -> листинг молча выпадает из подбора аналогов по радиусу).
|
||||||
|
# CITY_LOCATIONS.avito_slug — единственный источник правды для avito URL-слага
|
||||||
|
# города (может отличаться от нашего city_slug: kamensk-uralskiy через дефис,
|
||||||
|
# verhnyaya_pyshma без "kh") -- дублировать список тут вместо импорта было бы
|
||||||
|
# risk дрейфа при добавлении новых oblast-городов.
|
||||||
|
#
|
||||||
|
# #2578 review: Postgres LIKE трактует '_' как wildcard "один любой символ" (не
|
||||||
|
# литерал) и '%' как wildcard "любая последовательность" -- два слага из пяти
|
||||||
|
# (nizhniy_tagil, verhnyaya_pyshma) содержат '_', без экранирования это латентная
|
||||||
|
# дыра: город с похожим слагом (напр. nizhniyXtagil) молча совпал бы. Сегодня
|
||||||
|
# коллизий нет (проверено на проде: raw vs escaped паттерны дают одинаковые 776
|
||||||
|
# совпадений), но экранируем сейчас, а не когда появится реальная коллизия.
|
||||||
|
# LIKE по умолчанию использует '\' как escape-символ (без явного ESCAPE) —
|
||||||
|
# подтверждено на живом Postgres 16.4 (см. коммит #2578-fixup): 'nizhniyXtagil'
|
||||||
|
# матчит неэкранированный '%/nizhniy_tagil/%' (LIKE default '_'=wildcard) и НЕ
|
||||||
|
# матчит экранированный '%/nizhniy\_tagil/%' (LIKE '\_' = литерал '_'); точный
|
||||||
|
# слаг 'nizhniy_tagil' матчит оба варианта -- позитивный кейс не сломан.
|
||||||
|
_OBLAST_AVITO_URL_PATTERNS = tuple(
|
||||||
|
"%/" + loc.avito_slug.replace("\\", "\\\\").replace("_", "\\_").replace("%", "\\%") + "/%"
|
||||||
|
for loc in CITY_LOCATIONS.values()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AvitoDetailBackfillResult:
|
class AvitoDetailBackfillResult:
|
||||||
|
|
@ -102,15 +130,22 @@ async def run_avito_detail_backfill(
|
||||||
"""Backfill detail_enriched_at for legacy avito listings via mobile proxy.
|
"""Backfill detail_enriched_at for legacy avito listings via mobile proxy.
|
||||||
|
|
||||||
Params (from default_params jsonb in scrape_schedules):
|
Params (from default_params jsonb in scrape_schedules):
|
||||||
batch_size: int -- snapshot size (SELECT LIMIT), default 800.
|
batch_size: int -- ЕКБ snapshot size (SELECT LIMIT), default 800 (unchanged,
|
||||||
|
#2576 -- volume/order for ЕКБ stay byte-identical to pre-oblast behaviour).
|
||||||
|
oblast_batch_size: int -- ДОПОЛНИТЕЛЬНАЯ reserved-квота для листингов
|
||||||
|
области (#2576), default 100. Отдельный LIMIT, НЕ отъедает от batch_size
|
||||||
|
ЕКБ -- гарантирует области честную обработку и одновременно не даёт
|
||||||
|
всплеску свежих oblast-листингов вытеснить ЕКБ из top-N по scraped_at.
|
||||||
budget_sec: float -- wall-clock budget per run, default 3600s.
|
budget_sec: float -- wall-clock budget per run, default 3600s.
|
||||||
request_delay_sec: float -- delay between listings, default 6.0s.
|
request_delay_sec: float -- delay between listings, default 6.0s.
|
||||||
max_consecutive_blocks: int -- abort threshold, default 5.
|
max_consecutive_blocks: int -- abort threshold, default 5.
|
||||||
|
|
||||||
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
|
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
|
||||||
mark_done (incl. partial/block-abort) / mark_failed (exception only).
|
mark_backfill_finished (done / banned при блоках / failed при нуле, #2674);
|
||||||
|
mark_failed напрямую — только при исключении.
|
||||||
"""
|
"""
|
||||||
batch_size = int(params.get("batch_size", 800))
|
batch_size = int(params.get("batch_size", 800))
|
||||||
|
oblast_batch_size = int(params.get("oblast_batch_size", 100))
|
||||||
budget_sec = float(params.get("budget_sec", 3600))
|
budget_sec = float(params.get("budget_sec", 3600))
|
||||||
request_delay_sec = float(params.get("request_delay_sec", 6.0))
|
request_delay_sec = float(params.get("request_delay_sec", 6.0))
|
||||||
max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5))
|
max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5))
|
||||||
|
|
@ -141,7 +176,7 @@ async def run_avito_detail_backfill(
|
||||||
|
|
||||||
# kit AvitoScraper требует ScraperConfig позиционно (Strangler-инжекция #2133) —
|
# kit AvitoScraper требует ScraperConfig позиционно (Strangler-инжекция #2133) —
|
||||||
# RealScraperConfig проксирует settings.* так же, как читал legacy-конструктор без
|
# RealScraperConfig проксирует settings.* так же, как читал legacy-конструктор без
|
||||||
# аргументов (avito_proxy_rotate_url и т.д. для _rotate_ip()).
|
# аргументов (scraper_proxy_url и т.д. для _build_cffi_session()/_rotate_ip()).
|
||||||
scraper = AvitoScraper(RealScraperConfig())
|
scraper = AvitoScraper(RealScraperConfig())
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
|
|
||||||
|
|
@ -189,30 +224,59 @@ async def run_avito_detail_backfill(
|
||||||
runs_mod.update_heartbeat(db, run_id, current_counters)
|
runs_mod.update_heartbeat(db, run_id, current_counters)
|
||||||
|
|
||||||
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
|
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
|
||||||
# Scope (#1814): только активные ЕКБ-листинги. region_code на insert
|
# Scope (#1814, расширено #2576): активные листинги ЕКБ + известных oblast-
|
||||||
# хардкодится в 66 (base.py) → НЕ дискриминирует legacy не-ЕКБ; реальный
|
# городов (region 66). region_code на insert хардкодится в 66 (base.py) →
|
||||||
# признак региона у Avito — путь URL (/ekaterinburg/ для ЕКБ; legacy
|
# НЕ дискриминирует город; реальный признак города у Avito — путь URL
|
||||||
# Москва/СПб/Тюмень — /moskva//sankt-peterburg//tyumen/). browser-fetch
|
# (/ekaterinburg/ для ЕКБ; legacy Москва/СПб/Тюмень — /moskva//sankt-
|
||||||
# на legacy не-ЕКБ спотыкается → curl-fallback → 429-бан curl-фингерпринта.
|
# peterburg//tyumen/ — те по-прежнему вне scope, НЕ входят ни в ekb, ни в
|
||||||
# Не тратим фетчи на мёртвые (is_active) и не-ЕКБ.
|
# oblast CTE). browser-fetch на legacy не-ЕКБ/не-oblast спотыкается →
|
||||||
|
# curl-fallback → 429-бан curl-фингерпринта. Не тратим фетчи на мёртвые
|
||||||
|
# (is_active) и на регионы вне scope.
|
||||||
|
#
|
||||||
|
# Два CTE вместо одного WHERE ... OR ...: ekb сохраняет ТОЧНО прежний
|
||||||
|
# LIMIT/ORDER (#2576 требование "ЕКБ не деградирует") -- oblast НЕ может
|
||||||
|
# вытеснить ЕКБ из batch_size ни при каком всплеске свежих oblast-строк
|
||||||
|
# (ORDER BY ... scraped_at DESC в общем WHERE отдал бы приоритет самым
|
||||||
|
# свежим независимо от города). oblast получает отдельную честную квоту
|
||||||
|
# oblast_batch_size, добавленную ПОСЛЕ ekb-квоты (не вычтенную из неё).
|
||||||
snapshot = (
|
snapshot = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT id, source_url
|
WITH ekb AS (
|
||||||
|
SELECT id, source_url, price_rub, 'ekb' AS city_scope
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE source = 'avito'
|
WHERE source = 'avito'
|
||||||
AND detail_enriched_at IS NULL
|
AND detail_enriched_at IS NULL
|
||||||
AND source_url IS NOT NULL
|
AND source_url IS NOT NULL
|
||||||
AND is_active = TRUE
|
AND is_active = TRUE
|
||||||
AND source_url LIKE '%/ekaterinburg/%'
|
AND source_url LIKE '%/ekaterinburg/%'
|
||||||
-- сперва листинги без координат (#1967 — detail-страница даёт
|
-- сперва листинги без координат (#1967 — detail-страница
|
||||||
-- координаты здания), затем по свежести
|
-- даёт координаты здания), затем по свежести
|
||||||
ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST
|
ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST
|
||||||
LIMIT CAST(:batch_size AS int)
|
LIMIT CAST(:batch_size AS int)
|
||||||
|
),
|
||||||
|
oblast AS (
|
||||||
|
SELECT id, source_url, price_rub, 'oblast' AS city_scope
|
||||||
|
FROM listings
|
||||||
|
WHERE source = 'avito'
|
||||||
|
AND detail_enriched_at IS NULL
|
||||||
|
AND source_url IS NOT NULL
|
||||||
|
AND is_active = TRUE
|
||||||
|
AND source_url LIKE ANY(CAST(:oblast_patterns AS text[]))
|
||||||
|
ORDER BY (lat IS NULL) DESC, scraped_at DESC NULLS LAST
|
||||||
|
LIMIT CAST(:oblast_batch_size AS int)
|
||||||
|
)
|
||||||
|
SELECT id, source_url, price_rub, city_scope FROM ekb
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, source_url, price_rub, city_scope FROM oblast
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"batch_size": batch_size},
|
{
|
||||||
|
"batch_size": batch_size,
|
||||||
|
"oblast_patterns": list(_OBLAST_AVITO_URL_PATTERNS),
|
||||||
|
"oblast_batch_size": oblast_batch_size,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
|
|
@ -227,11 +291,16 @@ async def run_avito_detail_backfill(
|
||||||
runs_mod.mark_done(db, run_id, current_counters)
|
runs_mod.mark_done(db, run_id, current_counters)
|
||||||
return counters
|
return counters
|
||||||
|
|
||||||
|
# #2576: разбивка ekb/oblast только для наблюдаемости -- .get() консервативен
|
||||||
|
# (city_scope нет в mock-снапшотах старых тестов, дефолт "ekb" их не ломает).
|
||||||
|
oblast_count = sum(1 for row in snapshot if row.get("city_scope") == "oblast")
|
||||||
logger.info(
|
logger.info(
|
||||||
"avito_detail_backfill: run_id=%d snapshot=%d (budget=%.0fs "
|
"avito_detail_backfill: run_id=%d snapshot=%d (ekb=%d oblast=%d, "
|
||||||
"delay=%.1fs max_blocks=%d mode=%s)",
|
"budget=%.0fs delay=%.1fs max_blocks=%d mode=%s)",
|
||||||
run_id,
|
run_id,
|
||||||
len(snapshot),
|
len(snapshot),
|
||||||
|
len(snapshot) - oblast_count,
|
||||||
|
oblast_count,
|
||||||
budget_sec,
|
budget_sec,
|
||||||
request_delay_sec,
|
request_delay_sec,
|
||||||
max_consecutive_blocks,
|
max_consecutive_blocks,
|
||||||
|
|
@ -239,6 +308,7 @@ async def run_avito_detail_backfill(
|
||||||
)
|
)
|
||||||
|
|
||||||
consecutive_blocks = 0
|
consecutive_blocks = 0
|
||||||
|
aborted_by_blocks = False
|
||||||
do_sleep = False
|
do_sleep = False
|
||||||
items_since_warm = 0
|
items_since_warm = 0
|
||||||
|
|
||||||
|
|
@ -376,6 +446,21 @@ async def run_avito_detail_backfill(
|
||||||
text("UPDATE listings SET is_active = FALSE WHERE id = :id"),
|
text("UPDATE listings SET is_active = FALSE WHERE id = :id"),
|
||||||
{"id": row["id"]},
|
{"id": row["id"]},
|
||||||
)
|
)
|
||||||
|
# #2674: 404 с площадки — самый достоверный сигнал снятия,
|
||||||
|
# фиксируем его в дневной истории (listings_snapshots.status
|
||||||
|
# был константой 'active' у всех строк, 394 299). Тот же
|
||||||
|
# SAVEPOINT, что и UPDATE флага: снимок без флага (или
|
||||||
|
# наоборот) невозможен. price_rub из snapshot-SELECT —
|
||||||
|
# .get() консервативен ради mock-снапшотов старых тестов.
|
||||||
|
gone_price = row.get("price_rub")
|
||||||
|
if gone_price is not None:
|
||||||
|
upsert_listing_snapshot(
|
||||||
|
db,
|
||||||
|
listing_id=row["id"],
|
||||||
|
price_rub=gone_price,
|
||||||
|
run_id=run_id,
|
||||||
|
status="closed",
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"avito_detail_backfill: run_id=%d failed to mark listing %s "
|
"avito_detail_backfill: run_id=%d failed to mark listing %s "
|
||||||
|
|
@ -416,6 +501,7 @@ async def run_avito_detail_backfill(
|
||||||
counters.enriched,
|
counters.enriched,
|
||||||
counters.attempted,
|
counters.attempted,
|
||||||
)
|
)
|
||||||
|
aborted_by_blocks = True
|
||||||
break
|
break
|
||||||
# МГТС sticky-IP: один фикс. exit-IP, per-connection ротации нет (проверено:
|
# МГТС sticky-IP: один фикс. exit-IP, per-connection ротации нет (проверено:
|
||||||
# 6/6 свежих сессий = тот же IP 109.252.125.80; ротация только вручную
|
# 6/6 свежих сессий = тот же IP 109.252.125.80; ротация только вручную
|
||||||
|
|
@ -488,9 +574,15 @@ async def run_avito_detail_backfill(
|
||||||
|
|
||||||
counters.duration_sec = time.monotonic() - start
|
counters.duration_sec = time.monotonic() - start
|
||||||
current_counters = counters.to_dict()
|
current_counters = counters.to_dict()
|
||||||
runs_mod.mark_done(db, run_id, current_counters)
|
runs_mod.mark_backfill_finished(
|
||||||
|
db,
|
||||||
|
run_id,
|
||||||
|
current_counters,
|
||||||
|
source="avito_detail_backfill",
|
||||||
|
aborted_by_blocks=aborted_by_blocks,
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"avito_detail_backfill: run_id=%d DONE -- attempted=%d enriched=%d "
|
"avito_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "
|
||||||
"blocked=%d gone=%d failed=%d duration=%.1fs",
|
"blocked=%d gone=%d failed=%d duration=%.1fs",
|
||||||
run_id,
|
run_id,
|
||||||
counters.attempted,
|
counters.attempted,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,26 @@
|
||||||
Парсинг адреса — _parse_street_house из app.services.geocoder (готовый парсер),
|
Парсинг адреса — _parse_street_house из app.services.geocoder (готовый парсер),
|
||||||
работающий с формами «г. Екатеринбург, ул. Малышева, 30, кв. 28».
|
работающий с формами «г. Екатеринбург, ул. Малышева, 30, кв. 28».
|
||||||
|
|
||||||
|
Городской гейт (#2583, находка H3; расширен #2594 шаг 2/3): `ekb_geoportal_buildings` —
|
||||||
|
EKB-only реестр: улица+дом могут буквально совпасть между Екатеринбургом и другим городом
|
||||||
|
области (например, «проспект Ленина 1» есть и в ЕКБ, и в Нижнем Тагиле). Без проверки
|
||||||
|
города такой листинг получает екатеринбургские координаты, хотя находится в другом городе.
|
||||||
|
Гейт — ДВЕ проверки перед вызовом _geoportal_house_match:
|
||||||
|
1. Колонка `listings.city` (#2594, миграция 196) — если проставлена НЕ-Екатеринбургом,
|
||||||
|
листинг пропускается сразу, без обращения к тексту адреса. Это надёжный сигнал из
|
||||||
|
контекста развёртки (скрапер знает город явно), тогда как текстовый гейт полагается
|
||||||
|
на то, что город явно упомянут в самом тексте адреса.
|
||||||
|
2. _names_non_ekb_city(address) (та же функция, что гейтит EKB-only тиры внутри
|
||||||
|
geocoder.geocode()) — СОХРАНЕНА как fallback для листингов, у которых city IS NULL
|
||||||
|
(записаны до миграции 196 или путём, ещё не проставляющим город, например admin
|
||||||
|
ad-hoc /admin/scrape) — там единственный сигнал о городе — текст адреса.
|
||||||
|
Оба пути пропуска считаются в skipped_non_ekb (адрес остаётся lat IS NULL для
|
||||||
|
geocode_missing_listings, oblast-aware Nominatim/Yandex, окно 06:00-09:00 UTC).
|
||||||
|
Прямой вызов _geoportal_house_match (а не полноценный geocode()) оставлен намеренно —
|
||||||
|
это pure local-DB матч без единого внешнего HTTP-запроса; полноценный geocode() на каждый
|
||||||
|
non-EKB адрес добавил бы Nominatim/Yandex вызов на весь backlog (сотни-тысячи строк за
|
||||||
|
ночь) — лишняя нагрузка на и так ограниченный Nominatim (Yandex сейчас 403, #2585).
|
||||||
|
|
||||||
Запуск:
|
Запуск:
|
||||||
python -m app.tasks.backfill_listings_coords_geoportal
|
python -m app.tasks.backfill_listings_coords_geoportal
|
||||||
python -m app.tasks.backfill_listings_coords_geoportal --limit 5000 --batch-size 200
|
python -m app.tasks.backfill_listings_coords_geoportal --limit 5000 --batch-size 200
|
||||||
|
|
@ -19,7 +39,17 @@ migration 171) — run_geoportal_coords_backfill(). Local exact match, ника
|
||||||
HTTP/rate-limit, поэтому окно ставится ПЕРЕД geocode_missing_listings (Nominatim/Yandex,
|
HTTP/rate-limit, поэтому окно ставится ПЕРЕД geocode_missing_listings (Nominatim/Yandex,
|
||||||
coarse city-centroid fallback): точный house-level матч должен получить шанс первым,
|
coarse city-centroid fallback): точный house-level матч должен получить шанс первым,
|
||||||
иначе Nominatim успевает проставить грубые coords и адрес выпадает из WHERE lat IS NULL
|
иначе Nominatim успевает проставить грубые coords и адрес выпадает из WHERE lat IS NULL
|
||||||
(#1967 — было единичным manual-прогоном #1841, здесь становится recurring).
|
(#1967 — было единичным manual-прогоном #1841, здесь становится recurring). С городским
|
||||||
|
гейтом (#2583) порядок окон остаётся корректным: не-ЕКБ адреса больше не матчатся здесь
|
||||||
|
вообще, поэтому «победа в гонке» больше не портит их координаты — они просто ждут
|
||||||
|
geocode_missing_listings в следующем окне, как и раньше для адресов без geoportal-матча.
|
||||||
|
|
||||||
|
geo_precision: этот тир всегда даёт house-level точный матч (не city-centroid), поэтому
|
||||||
|
_update_listing_coords НЕ проставляет geo_precision — он остаётся NULL, что в текущей
|
||||||
|
конвенции (089_listings_geo_precision.sql, geocode_missing.py) означает «не coarse»
|
||||||
|
(тот же смысл, что и geo_precision=None для precise-адресов в geocode_missing_listings).
|
||||||
|
Downstream-фильтры (`geo_precision IS DISTINCT FROM 'city'`) корректно НЕ исключают такие
|
||||||
|
строки — исключать нужно только 'city'-fallback, а не «пока не размечено».
|
||||||
|
|
||||||
Идемпотентность: UPDATE применяется только к строкам с lat IS NULL (WHERE id=:id AND lat IS NULL).
|
Идемпотентность: UPDATE применяется только к строкам с lat IS NULL (WHERE id=:id AND lat IS NULL).
|
||||||
Повторный прогон не затирает уже проставленные координаты.
|
Повторный прогон не затирает уже проставленные координаты.
|
||||||
|
|
@ -37,7 +67,7 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
from app.services import scrape_runs as runs_mod
|
from app.services import scrape_runs as runs_mod
|
||||||
from app.services.geocoder import _geoportal_house_match, _parse_street_house
|
from app.services.geocoder import _geoportal_house_match, _names_non_ekb_city, _parse_street_house
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -55,6 +85,17 @@ class BackfillCoordsResult:
|
||||||
updated: int = 0 # реально обновлено (UPDATE rowcount)
|
updated: int = 0 # реально обновлено (UPDATE rowcount)
|
||||||
no_address: int = 0 # listing.address IS NULL / не распарсился
|
no_address: int = 0 # listing.address IS NULL / не распарсился
|
||||||
no_match: int = 0 # адрес распарсился, но в реестре здания нет
|
no_match: int = 0 # адрес распарсился, но в реестре здания нет
|
||||||
|
skipped_non_ekb: int = 0 # non-ЕКБ гейт ВСЕГО: колонка city (#2594) ИЛИ текст (#2583)
|
||||||
|
# Подмножество skipped_non_ekb — только те, кого отсёк гейт по КОЛОНКЕ city
|
||||||
|
# (#2603). Зачем отдельный счётчик: колоночный гейт стоит ПЕРЕД парсером
|
||||||
|
# адреса, поэтому по мере раскатки областных развёрток (#2598) строки, которые
|
||||||
|
# сейчас падают в no_address (город неизвестен, адрес не парсится), начнут
|
||||||
|
# перетекать в skipped_non_ekb — и общий счётчик поменяет смысл ровно тогда,
|
||||||
|
# когда по нему хотят валидировать раскатку. Разность
|
||||||
|
# skipped_non_ekb - skipped_non_ekb_by_column = вклад ТЕКСТОВОГО гейта, т.е.
|
||||||
|
# старая метрика #2583 остаётся вычислимой. Обратная совместимость:
|
||||||
|
# skipped_non_ekb продолжает означать то же, что и раньше (гейт целиком).
|
||||||
|
skipped_non_ekb_by_column: int = 0
|
||||||
errors: int = 0 # исключения при обработке отдельной записи
|
errors: int = 0 # исключения при обработке отдельной записи
|
||||||
duration_sec: float = field(default=0.0)
|
duration_sec: float = field(default=0.0)
|
||||||
|
|
||||||
|
|
@ -65,6 +106,8 @@ class BackfillCoordsResult:
|
||||||
"updated": self.updated,
|
"updated": self.updated,
|
||||||
"no_address": self.no_address,
|
"no_address": self.no_address,
|
||||||
"no_match": self.no_match,
|
"no_match": self.no_match,
|
||||||
|
"skipped_non_ekb": self.skipped_non_ekb,
|
||||||
|
"skipped_non_ekb_by_column": self.skipped_non_ekb_by_column,
|
||||||
"errors": self.errors,
|
"errors": self.errors,
|
||||||
"duration_sec": int(self.duration_sec),
|
"duration_sec": int(self.duration_sec),
|
||||||
}
|
}
|
||||||
|
|
@ -153,7 +196,7 @@ def backfill_coords_from_geoportal(
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(f"""
|
text(f"""
|
||||||
SELECT id, address
|
SELECT id, address, city
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE lat IS NULL
|
WHERE lat IS NULL
|
||||||
AND geom IS NULL
|
AND geom IS NULL
|
||||||
|
|
@ -184,6 +227,32 @@ def backfill_coords_from_geoportal(
|
||||||
res.no_address += 1
|
res.no_address += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Городской гейт по колонке (#2594 шаг 2/3) — ПЕРЕД матчем и ПЕРЕД
|
||||||
|
# текстовым гейтом. listings.city (миграция 196) проставляется из
|
||||||
|
# контекста развёртки скрапером — надёжнее текста адреса. Голый
|
||||||
|
# тагильский адрес без города в тексте ("ул. Победы, 30") раньше
|
||||||
|
# проходил только текстовый гейт и мог ложно сматчиться с
|
||||||
|
# одноимённым екатеринбургским домом в EKB-only реестре. Это окно
|
||||||
|
# идёт ПЕРЕД geocode_missing_listings — без гейта по колонке оно
|
||||||
|
# успевает испортить координаты первым.
|
||||||
|
city: str | None = row.get("city")
|
||||||
|
if city is not None and city != "Екатеринбург":
|
||||||
|
res.skipped_non_ekb += 1
|
||||||
|
# Отдельный срез (#2603) — общий skipped_non_ekb смешивает
|
||||||
|
# колоночный и текстовый гейты, а по мере раскатки #2598
|
||||||
|
# колоночный будет забирать строки из no_address.
|
||||||
|
res.skipped_non_ekb_by_column += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Текстовый гейт (#2583, H3) — fallback для листингов, у которых
|
||||||
|
# колонка city пуста (записаны до миграции 196 либо путём, ещё не
|
||||||
|
# проставляющим город, напр. admin ad-hoc /admin/scrape). Адрес,
|
||||||
|
# явно называющий другой город региона, пропускаем — остаётся
|
||||||
|
# lat IS NULL для oblast-aware geocode_missing_listings.
|
||||||
|
if _names_non_ekb_city(address):
|
||||||
|
res.skipped_non_ekb += 1
|
||||||
|
continue
|
||||||
|
|
||||||
# Парсинг адреса — переиспользуем парсер geocoder'а
|
# Парсинг адреса — переиспользуем парсер geocoder'а
|
||||||
parsed = _parse_street_house(address)
|
parsed = _parse_street_house(address)
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
|
|
@ -259,12 +328,15 @@ def backfill_coords_from_geoportal(
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"backfill_coords: DONE — candidates=%d matched=%d updated=%d "
|
"backfill_coords: DONE — candidates=%d matched=%d updated=%d "
|
||||||
"no_address=%d no_match=%d errors=%d duration=%.1fs",
|
"no_address=%d no_match=%d skipped_non_ekb=%d (by_column=%d) "
|
||||||
|
"errors=%d duration=%.1fs",
|
||||||
res.candidates,
|
res.candidates,
|
||||||
res.matched,
|
res.matched,
|
||||||
res.updated,
|
res.updated,
|
||||||
res.no_address,
|
res.no_address,
|
||||||
res.no_match,
|
res.no_match,
|
||||||
|
res.skipped_non_ekb,
|
||||||
|
res.skipped_non_ekb_by_column,
|
||||||
res.errors,
|
res.errors,
|
||||||
res.duration_sec,
|
res.duration_sec,
|
||||||
)
|
)
|
||||||
|
|
@ -314,13 +386,16 @@ def run_geoportal_coords_backfill(
|
||||||
runs_mod.mark_done(db, run_id, counters)
|
runs_mod.mark_done(db, run_id, counters)
|
||||||
logger.info(
|
logger.info(
|
||||||
"run_geoportal_coords_backfill: run_id=%d DONE candidates=%d matched=%d "
|
"run_geoportal_coords_backfill: run_id=%d DONE candidates=%d matched=%d "
|
||||||
"updated=%d no_address=%d no_match=%d errors=%d duration=%.1fs",
|
"updated=%d no_address=%d no_match=%d skipped_non_ekb=%d (by_column=%d) "
|
||||||
|
"errors=%d duration=%.1fs",
|
||||||
run_id,
|
run_id,
|
||||||
res.candidates,
|
res.candidates,
|
||||||
res.matched,
|
res.matched,
|
||||||
res.updated,
|
res.updated,
|
||||||
res.no_address,
|
res.no_address,
|
||||||
res.no_match,
|
res.no_match,
|
||||||
|
res.skipped_non_ekb,
|
||||||
|
res.skipped_non_ekb_by_column,
|
||||||
res.errors,
|
res.errors,
|
||||||
res.duration_sec,
|
res.duration_sec,
|
||||||
)
|
)
|
||||||
|
|
@ -376,12 +451,13 @@ def main() -> None:
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Готово: кандидатов=%d сматчено=%d обновлено=%d "
|
"Готово: кандидатов=%d сматчено=%d обновлено=%d "
|
||||||
"без_адреса=%d без_матча=%d ошибок=%d время=%.1fs",
|
"без_адреса=%d без_матча=%d не_ЕКБ=%d ошибок=%d время=%.1fs",
|
||||||
result.candidates,
|
result.candidates,
|
||||||
result.matched,
|
result.matched,
|
||||||
result.updated,
|
result.updated,
|
||||||
result.no_address,
|
result.no_address,
|
||||||
result.no_match,
|
result.no_match,
|
||||||
|
result.skipped_non_ekb,
|
||||||
result.errors,
|
result.errors,
|
||||||
result.duration_sec,
|
result.duration_sec,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,18 @@ APPROXIMATION (deliberate first increment):
|
||||||
This is a GEO-NEAREST match — a street-level-geocoded listing is matched to the nearest
|
This is a GEO-NEAREST match — a street-level-geocoded listing is matched to the nearest
|
||||||
cadastral building within `threshold_m`, NOT necessarily its exact cadastral building.
|
cadastral building within `threshold_m`, NOT necessarily its exact cadastral building.
|
||||||
The threshold is always logged. Exact cadastral resolution + parcel-containment are
|
The threshold is always logged. Exact cadastral resolution + parcel-containment are
|
||||||
deferred (cad_parcels FDW not exposed). Tier-0 house matching in the estimator already
|
deferred (cad_parcels FDW not exposed).
|
||||||
treats building_cadastral_number as a hint, not ground truth, so an approximate fill is
|
|
||||||
a net win over 0% coverage.
|
ЭТО HINT, И ТОЛЬКО HINT (#2674 — правка прежнего утверждения в этой шапке).
|
||||||
|
Раньше здесь было написано, что Tier-0 матчинга домов «уже трактует
|
||||||
|
building_cadastral_number как подсказку»; это неверно — Tier 0 в
|
||||||
|
`app/services/matching/houses.py` отдаёт confidence 1.0, т.е. точное совпадение.
|
||||||
|
Замер на проде 2026-08-05: 656 из 3 260 заполненных здесь значений накрывают более
|
||||||
|
одного здания ГАР (20.1%), а 751 из 2 864 зданий ГАР получают более одного значения
|
||||||
|
(26.2%) — ключ не инъективен ни в одну сторону. Поэтому подавать эту колонку в Tier 0
|
||||||
|
(ни на пере-скрейпе, ни бэкфиллом в houses.cadastral_number) НЕЛЬЗЯ: это склеит разные
|
||||||
|
здания с максимальной уверенностью. Колонка годится как признак/подсказка, не как
|
||||||
|
идентичность здания.
|
||||||
|
|
||||||
Pipeline (one combined run, scheduler source='cadastral_geo_match'):
|
Pipeline (one combined run, scheduler source='cadastral_geo_match'):
|
||||||
1. refresh_cad_buildings_local(db) — TRUNCATE + bulk INSERT from FDW (one scan).
|
1. refresh_cad_buildings_local(db) — TRUNCATE + bulk INSERT from FDW (one scan).
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,14 @@
|
||||||
Requires migration 071_houses_cian_zhk_url.sql (cian_zhk_url column).
|
Requires migration 071_houses_cian_zhk_url.sql (cian_zhk_url column).
|
||||||
|
|
||||||
Rate limit: scraper_settings.get_scraper_delay('cian') between requests.
|
Rate limit: scraper_settings.get_scraper_delay('cian') between requests.
|
||||||
|
|
||||||
|
Сигнал живости (#2725): батч дёргает `on_progress` на КАЖДОЙ сущности — caller
|
||||||
|
переливает это в scrape_runs.heartbeat_at. Пока колбэка не было, планировщик слал
|
||||||
|
heartbeat один раз ДО батча, а `reap_zombies` меряет ровно heartbeat с порогом 6 ч —
|
||||||
|
и добивал живые прогоны строго на 6-м часу (6 прод-прогонов, у пятерых внутри окна
|
||||||
|
писались строки offer_price_history, у одного — до 5.4 ч после старта). Ослаблять
|
||||||
|
критерий нельзя: пометка 'zombie' снимает running-блокировку источника
|
||||||
|
(`has_running_run`), без неё зависший прогон запер бы источник навсегда.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -25,6 +33,7 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||||
|
|
@ -68,6 +77,7 @@ async def backfill_cian_history(
|
||||||
do_houses: bool = True,
|
do_houses: bool = True,
|
||||||
do_valuations: bool = False,
|
do_valuations: bool = False,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
|
on_progress: Callable[[CianBackfillResult], None] | None = None,
|
||||||
) -> CianBackfillResult:
|
) -> CianBackfillResult:
|
||||||
"""Iterate Cian listings + houses with missing history, fetch+save.
|
"""Iterate Cian listings + houses with missing history, fetch+save.
|
||||||
|
|
||||||
|
|
@ -82,6 +92,10 @@ async def backfill_cian_history(
|
||||||
do_valuations: process Cian Valuation Calculator batch (external_valuations backfill).
|
do_valuations: process Cian Valuation Calculator batch (external_valuations backfill).
|
||||||
Default False — opt-in because each call hits Cian auth-gated API.
|
Default False — opt-in because each call hits Cian auth-gated API.
|
||||||
dry_run: skip all fetch+save; only count and log pending rows.
|
dry_run: skip all fetch+save; only count and log pending rows.
|
||||||
|
on_progress: колбэк живости (#2725) — вызывается на каждой сущности ЛЮБОГО из
|
||||||
|
трёх блоков, до её обработки, с текущим (мутируемым) result. Caller пишет
|
||||||
|
heartbeat; исключения колбэка — на его совести (планировщик глушит их сам),
|
||||||
|
здесь они прервали бы батч.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
CianBackfillResult with per-domain counters + total wall-clock duration.
|
CianBackfillResult with per-domain counters + total wall-clock duration.
|
||||||
|
|
@ -120,6 +134,8 @@ async def backfill_cian_history(
|
||||||
listing_id: int = row["id"]
|
listing_id: int = row["id"]
|
||||||
source_url: str = row["source_url"]
|
source_url: str = row["source_url"]
|
||||||
result.listings_processed += 1
|
result.listings_processed += 1
|
||||||
|
if on_progress is not None:
|
||||||
|
on_progress(result)
|
||||||
|
|
||||||
enrichment = None
|
enrichment = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -209,6 +225,8 @@ async def backfill_cian_history(
|
||||||
house_id: int = hrow["id"]
|
house_id: int = hrow["id"]
|
||||||
zhk_url: str = hrow["cian_zhk_url"]
|
zhk_url: str = hrow["cian_zhk_url"]
|
||||||
result.houses_processed += 1
|
result.houses_processed += 1
|
||||||
|
if on_progress is not None:
|
||||||
|
on_progress(result)
|
||||||
|
|
||||||
enrichment = None
|
enrichment = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -291,6 +309,8 @@ async def backfill_cian_history(
|
||||||
else:
|
else:
|
||||||
for row in rows:
|
for row in rows:
|
||||||
result.valuations_processed += 1
|
result.valuations_processed += 1
|
||||||
|
if on_progress is not None:
|
||||||
|
on_progress(result)
|
||||||
try:
|
try:
|
||||||
cval = await estimate_via_cian_valuation(
|
cval = await estimate_via_cian_valuation(
|
||||||
db,
|
db,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@
|
||||||
TTL=30. novostroyki (9659 активных первичных строк) и NULL-сегмент не трогаем.
|
TTL=30. novostroyki (9659 активных первичных строк) и NULL-сегмент не трогаем.
|
||||||
- avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений.
|
- avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений.
|
||||||
- Строки НЕ удаляются -- история нужна для бэктеста (#667).
|
- Строки НЕ удаляются -- история нужна для бэктеста (#667).
|
||||||
|
- #2674: деактивация в той же транзакции пишет снимок listings_snapshots со статусом
|
||||||
|
'stale' за текущую дату -- «мы N суток не видели». Жёсткое 'closed' (площадка
|
||||||
|
ответила 404) пишет только avito_detail_backfill: смешивать факт с догадкой дорого.
|
||||||
|
|
||||||
Задача синхронная (DB-only, никаких внешних HTTP-вызовов) -- запускается kit-scheduler'ом
|
Задача синхронная (DB-only, никаких внешних HTTP-вызовов) -- запускается kit-scheduler'ом
|
||||||
через product_handlers._job_deactivate_stale (wildcard-handler deactivate_stale_*),
|
через product_handlers._job_deactivate_stale (wildcard-handler deactivate_stale_*),
|
||||||
|
|
@ -41,21 +44,126 @@ logger = logging.getLogger(__name__)
|
||||||
# только реальный скрейп).
|
# только реальный скрейп).
|
||||||
_ALLOWED_STALENESS_COLUMNS = frozenset({"last_seen_at", "scraped_at"})
|
_ALLOWED_STALENESS_COLUMNS = frozenset({"last_seen_at", "scraped_at"})
|
||||||
|
|
||||||
|
# ── Снимок «протухло» в дневной истории (#2674) ───────────────────────────────
|
||||||
|
# listings_snapshots.status до этого фикса был константой 'active' у всех строк
|
||||||
|
# (394 299 на момент находки) — оба места вызова upsert_listing_snapshot передавали
|
||||||
|
# литерал 'active', и это честно: там объявление ДЕЙСТВИТЕЛЬНО видели. А деактивация
|
||||||
|
# TTL-задачей не оставляла в истории вообще никакого следа. Из-за этого дата снятия
|
||||||
|
# объявления (лучший доступный сигнал «скорее всего продано») не запрашивалась из
|
||||||
|
# истории, а восстанавливалась на глаз: последний показ + предполагаемый срок жизни.
|
||||||
|
#
|
||||||
|
# ПОЧЕМУ 'stale', А НЕ 'closed'. Эта задача НЕ знает, что объявление снято, — она
|
||||||
|
# знает только, что МЫ его N суток не видели, а это разные факты, когда TTL короче
|
||||||
|
# простоя обхода. Замер: прогон по домклику 02.08 снял 6131 объявление за раз (TTL
|
||||||
|
# 14 суток против 12 суток простоя обхода) — с общим статусом это были бы 6131
|
||||||
|
# фальшивая «дата продажи» одной датой. Продукт про цены, смешивать факт с догадкой
|
||||||
|
# дорого. Поэтому:
|
||||||
|
# 'closed' — только путь 404: площадка ответила «нет» (avito_detail_backfill);
|
||||||
|
# 'stale' — этот путь: «мы N суток не смотрели».
|
||||||
|
# Дата всё равно фиксируется, но читатель отличает одно от другого. Ограничения
|
||||||
|
# CHECK на колонке нет (проверено на проде), миграция не нужна — только COMMENT.
|
||||||
|
#
|
||||||
|
# Пишем снимок в ТОЙ ЖЕ транзакции, что и UPDATE флага: деактивация без снимка (или
|
||||||
|
# наоборот) невозможна по построению — один statement, data-modifying CTE.
|
||||||
|
# 1:1 по строкам: `stale` возвращает уникальные listings.id (PK), каждая даёт ровно
|
||||||
|
# одну затронутую строку listings_snapshots (INSERT либо DO UPDATE — оба считаются
|
||||||
|
# в rowcount), поэтому rowcount statement'а по-прежнему равен числу деактивированных.
|
||||||
|
# price_rub берём из listings (NOT NULL в схеме) — это последняя известная цена.
|
||||||
|
# ON CONFLICT: если снимок за сегодня уже есть (объявление видели активным утром,
|
||||||
|
# а вечером сработал TTL) — только переводим статус в 'stale', цену не переписываем.
|
||||||
|
_STALE_SNAPSHOT_TAIL = """
|
||||||
|
INSERT INTO listings_snapshots
|
||||||
|
(listing_id, snapshot_date, run_id, price_rub, status, observed_at)
|
||||||
|
SELECT id, CURRENT_DATE, CAST(:run_id AS bigint), price_rub, 'stale', NOW()
|
||||||
|
FROM stale
|
||||||
|
ON CONFLICT (listing_id, snapshot_date) DO UPDATE SET
|
||||||
|
status = 'stale',
|
||||||
|
observed_at = EXCLUDED.observed_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Гейт по здоровью сбора (#2659) ────────────────────────────────────────────
|
||||||
|
# TTL отвечает на вопрос «объявление сняли?», а меряет «мы его давно не видели».
|
||||||
|
# Пока обход здоров, разница мала. Когда обход лёг — разница равна всему инвентарю.
|
||||||
|
#
|
||||||
|
# Замер на проде, из-за которого этот гейт существует. Авито 10.07-26.07.2026:
|
||||||
|
# 17 суток подряд без единой успешно собранной страницы, TTL=10 снял за этот отрезок
|
||||||
|
# 9 033 строки; 1 270 из них потом доказанно вернулись живыми (снимки
|
||||||
|
# listing_source_snapshots + текущий last_seen_at) — сбор восстановился, и объявления
|
||||||
|
# оказались на месте. То есть «мы не смогли зайти» было прочитано как «объявление снято».
|
||||||
|
#
|
||||||
|
# ПОЧЕМУ НЕ ПО СТАТУСУ ban. Соблазн взять scrape_runs.status='banned' — ловушка:
|
||||||
|
# Яндекс 18.07-30.07 — 5 прогонов в сутки, ВСЕ 'done', НОЛЬ 'banned', total_seen=0
|
||||||
|
# 13 суток подряд (снято ~839 строк vtorichka);
|
||||||
|
# Домклик 20.07-30.07 — то же самое, 11 суток 'done' с total_seen=0, а 02.08 TTL
|
||||||
|
# снял 6 131 строку разом (см. комментарий про 'stale' выше).
|
||||||
|
# Оба провала для ban-детектора невидимы. Поэтому здоровье меряем НЕ статусом прогона,
|
||||||
|
# а результатом: сколько строк источник реально подтвердил свежими за последние сутки.
|
||||||
|
#
|
||||||
|
# МЕТРИКА: count(*) по той же колонке свежести, что и сам TTL (last_seen_at или
|
||||||
|
# scraped_at) и по тому же срезу source+segment, что и UPDATE. Одна колонка на обе
|
||||||
|
# стороны — гейт нельзя обмануть bulk-touch'ем, который не двигает scraped_at (#2204).
|
||||||
|
#
|
||||||
|
# ПОРОГ. Ряд «подтверждений за 3 суток» по дням (восстановлен из listing_source_snapshots):
|
||||||
|
# avito здоровые сутки 3542..6079, провал 10.07-26.07 — 0..970 → порог 1500;
|
||||||
|
# yandex vtorichka здоровые 897..2206, провал — 0 → порог 500;
|
||||||
|
# cian vtorichka 748..4329, провала не было → порог 500;
|
||||||
|
# domklik по scraped_at сейчас 62/3 суток (сбор фактически стоит) → порог 200.
|
||||||
|
# Пороги живут в default_params расписания (миграция 219), здесь только страховка
|
||||||
|
# на случай незасеянного расписания. Асимметрия цены ошибки намеренная: пропущенная
|
||||||
|
# деактивация чинится следующим прогоном, ложная — только повторным сбором, которого
|
||||||
|
# может не быть. Поэтому при сомнении — пропускаем прогон.
|
||||||
|
#
|
||||||
|
# ПОТОЛОК: окно 3 суток годится, пока свипы источника ходят не реже чем раз в 3 дня.
|
||||||
|
# Источник с более редкой каденцией будет блокироваться всегда — тогда окно нужно
|
||||||
|
# растить до каденции, а не понижать порог.
|
||||||
|
_HEALTH_WINDOW_DAYS = 3
|
||||||
|
|
||||||
|
# Страховка для расписаний без явного min_confirmations в default_params: ловит
|
||||||
|
# полный ноль и близкое к нулю, но НЕ ловит частичный провал вроде avito 936-970 —
|
||||||
|
# для этого нужен посчитанный по источнику порог из миграции 219.
|
||||||
|
DEFAULT_MIN_CONFIRMATIONS = 500
|
||||||
|
|
||||||
|
_CONFIRMATIONS_SEGMENT_FILTER = "\n AND listing_segment = ANY(CAST(:segments AS text[]))"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_confirmations_sql(staleness_column: str, *, with_segments: bool) -> Any:
|
||||||
|
"""SELECT count(*) подтверждённых за окно строк — тот же срез, что и у UPDATE.
|
||||||
|
|
||||||
|
staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings.
|
||||||
|
Значения (:listing_source, :health_window_days, :segments) — param-binding,
|
||||||
|
psycopg v3 safe (CAST(... AS ...), никаких :param::type).
|
||||||
|
"""
|
||||||
|
segment_filter = _CONFIRMATIONS_SEGMENT_FILTER if with_segments else ""
|
||||||
|
return text(
|
||||||
|
f"""
|
||||||
|
SELECT count(*)
|
||||||
|
FROM listings
|
||||||
|
WHERE source = :listing_source
|
||||||
|
AND {staleness_column}
|
||||||
|
> NOW() - CAST(:health_window_days || ' days' AS interval){segment_filter}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_all_segments_sql(staleness_column: str) -> Any:
|
def _build_all_segments_sql(staleness_column: str) -> Any:
|
||||||
"""UPDATE без фильтра по сегменту: все сегменты для данного source.
|
"""UPDATE без фильтра по сегменту: все сегменты для данного source.
|
||||||
|
|
||||||
staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings,
|
staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings,
|
||||||
поэтому f-string-подстановка имени колонки безопасна. Значения (:listing_source,
|
поэтому f-string-подстановка имени колонки безопасна. Значения (:listing_source,
|
||||||
:ttl_days) остаются param-binding — psycopg v3 safe (никаких :param::type).
|
:ttl_days, :run_id) остаются param-binding — psycopg v3 safe (никаких :param::type).
|
||||||
"""
|
"""
|
||||||
return text(
|
return text(
|
||||||
f"""
|
f"""
|
||||||
|
WITH stale AS (
|
||||||
UPDATE listings
|
UPDATE listings
|
||||||
SET is_active = false
|
SET is_active = false
|
||||||
WHERE source = :listing_source
|
WHERE source = :listing_source
|
||||||
AND is_active = true
|
AND is_active = true
|
||||||
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
||||||
|
RETURNING id, price_rub
|
||||||
|
)
|
||||||
|
{_STALE_SNAPSHOT_TAIL}
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -68,12 +176,16 @@ def _build_segments_sql(staleness_column: str) -> Any:
|
||||||
"""
|
"""
|
||||||
return text(
|
return text(
|
||||||
f"""
|
f"""
|
||||||
|
WITH stale AS (
|
||||||
UPDATE listings
|
UPDATE listings
|
||||||
SET is_active = false
|
SET is_active = false
|
||||||
WHERE source = :listing_source
|
WHERE source = :listing_source
|
||||||
AND is_active = true
|
AND is_active = true
|
||||||
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
||||||
AND listing_segment = ANY(CAST(:segments AS text[]))
|
AND listing_segment = ANY(CAST(:segments AS text[]))
|
||||||
|
RETURNING id, price_rub
|
||||||
|
)
|
||||||
|
{_STALE_SNAPSHOT_TAIL}
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -95,6 +207,8 @@ def deactivate_stale_listings(
|
||||||
ttl_days: int,
|
ttl_days: int,
|
||||||
segments: list[str] | None = None,
|
segments: list[str] | None = None,
|
||||||
staleness_column: str = "last_seen_at",
|
staleness_column: str = "last_seen_at",
|
||||||
|
min_confirmations: int = 0,
|
||||||
|
health_window_days: int = _HEALTH_WINDOW_DAYS,
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
"""Пометить is_active=false объявления, чья свежесть старше ttl_days дней.
|
"""Пометить is_active=false объявления, чья свежесть старше ttl_days дней.
|
||||||
|
|
||||||
|
|
@ -109,11 +223,20 @@ def deactivate_stale_listings(
|
||||||
last_seen_at. Для domklik (#2204) — scraped_at: нетрекаемый bulk-touch
|
last_seen_at. Для domklik (#2204) — scraped_at: нетрекаемый bulk-touch
|
||||||
двигает last_seen_at всем строкам одним timestamp, поэтому честная
|
двигает last_seen_at всем строкам одним timestamp, поэтому честная
|
||||||
свежесть = scraped_at (двигается только реальным скрейпом).
|
свежесть = scraped_at (двигается только реальным скрейпом).
|
||||||
|
min_confirmations: гейт по здоровью сбора (#2659). Сколько строк источник
|
||||||
|
должен был подтвердить свежими за health_window_days суток, чтобы
|
||||||
|
деактивации вообще разрешалось исполниться. 0 -> гейт выключен (так
|
||||||
|
вызывают старые тесты и совместимая обёртка); реальные значения приходят
|
||||||
|
из default_params расписания, см. миграцию 219 и комментарий выше.
|
||||||
|
health_window_days: окно подтверждений для гейта, суток. Дефолт 3.
|
||||||
|
|
||||||
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
|
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
|
||||||
Один UPDATE в транзакции. Финализирует scrape_runs (mark_done / mark_failed).
|
Один statement в транзакции: UPDATE флага + снимок 'stale' в listings_snapshots
|
||||||
|
(data-modifying CTE, #2674). Финализирует scrape_runs (mark_done / mark_failed).
|
||||||
|
|
||||||
Returns {"deactivated": N} -- количество обновлённых строк.
|
Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками).
|
||||||
|
Если гейт не пропустил прогон: {"deactivated": 0, "confirmations": N,
|
||||||
|
"skipped_unhealthy": 1} и НИ ОДНА строка не тронута.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: если staleness_column не входит в whitelist (проверка ДО SQL,
|
ValueError: если staleness_column не входит в whitelist (проверка ДО SQL,
|
||||||
|
|
@ -131,6 +254,44 @@ def deactivate_stale_listings(
|
||||||
f"allowed: {sorted(_ALLOWED_STALENESS_COLUMNS)}"
|
f"allowed: {sorted(_ALLOWED_STALENESS_COLUMNS)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Гейт по здоровью сбора (#2659) — ДО любого UPDATE. Деактивация необратима
|
||||||
|
# на практике (вернуть «живость» может только повторный сбор), поэтому
|
||||||
|
# проверяем ПЕРЕД записью, а не откатываем после.
|
||||||
|
if min_confirmations > 0:
|
||||||
|
health_params: dict[str, Any] = {
|
||||||
|
"listing_source": listing_source,
|
||||||
|
"health_window_days": health_window_days,
|
||||||
|
}
|
||||||
|
if segments is not None:
|
||||||
|
health_params["segments"] = segments
|
||||||
|
confirmations = (
|
||||||
|
db.execute(
|
||||||
|
_build_confirmations_sql(staleness_column, with_segments=segments is not None),
|
||||||
|
health_params,
|
||||||
|
).scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
counters["confirmations"] = int(confirmations)
|
||||||
|
if confirmations < min_confirmations:
|
||||||
|
counters["skipped_unhealthy"] = 1
|
||||||
|
# Ничего не писали (был только SELECT) — rollback закрывает транзакцию
|
||||||
|
# чисто, чтобы mark_done стартовал со своей.
|
||||||
|
db.rollback()
|
||||||
|
runs_mod.mark_done(db, run_id, counters)
|
||||||
|
logger.warning(
|
||||||
|
"deactivate_stale source=%s run_id=%d SKIPPED: сбор нездоров — "
|
||||||
|
"подтверждений за %d сут %d < порога %d "
|
||||||
|
"(segments=%r, staleness_column=%s); ни одна строка не тронута",
|
||||||
|
listing_source,
|
||||||
|
run_id,
|
||||||
|
health_window_days,
|
||||||
|
confirmations,
|
||||||
|
min_confirmations,
|
||||||
|
segments,
|
||||||
|
staleness_column,
|
||||||
|
)
|
||||||
|
return counters
|
||||||
|
|
||||||
# segments is None -> все сегменты (поведение avito). segments=[...] -> только
|
# segments is None -> все сегменты (поведение avito). segments=[...] -> только
|
||||||
# перечисленные сегменты. Используем `is not None` (НЕ truthy): пустой список []
|
# перечисленные сегменты. Используем `is not None` (НЕ truthy): пустой список []
|
||||||
# означает "ни один сегмент" (= ANY(ARRAY[]) ничего не матчит, деактивирует 0),
|
# означает "ни один сегмент" (= ANY(ARRAY[]) ничего не матчит, деактивирует 0),
|
||||||
|
|
@ -140,10 +301,15 @@ def deactivate_stale_listings(
|
||||||
"listing_source": listing_source,
|
"listing_source": listing_source,
|
||||||
"ttl_days": ttl_days,
|
"ttl_days": ttl_days,
|
||||||
"segments": segments,
|
"segments": segments,
|
||||||
|
"run_id": run_id,
|
||||||
}
|
}
|
||||||
result = db.execute(_build_segments_sql(staleness_column), params)
|
result = db.execute(_build_segments_sql(staleness_column), params)
|
||||||
else:
|
else:
|
||||||
params = {"listing_source": listing_source, "ttl_days": ttl_days}
|
params = {
|
||||||
|
"listing_source": listing_source,
|
||||||
|
"ttl_days": ttl_days,
|
||||||
|
"run_id": run_id,
|
||||||
|
}
|
||||||
result = db.execute(_build_all_segments_sql(staleness_column), params)
|
result = db.execute(_build_all_segments_sql(staleness_column), params)
|
||||||
|
|
||||||
counters["deactivated"] = result.rowcount or 0
|
counters["deactivated"] = result.rowcount or 0
|
||||||
|
|
|
||||||
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()
|
row = db.execute(_LATEST_DEAL_DATE_SQL).first()
|
||||||
latest: date | None = row.latest if row is not None else None
|
latest: date | None = row.latest if row is not None else None
|
||||||
if latest is None:
|
if latest is None:
|
||||||
logger.warning(
|
# ERROR (#2674): монитор не может выполнить работу — сбой, а не наблюдение.
|
||||||
|
# Соседняя ветка (overdue) писала ERROR с самого начала; эта расходилась.
|
||||||
|
logger.error(
|
||||||
"deals freshness: таблица deals пуста/недоступна — оценить свежесть нельзя"
|
"deals freshness: таблица deals пуста/недоступна — оценить свежесть нельзя"
|
||||||
)
|
)
|
||||||
runs_mod.mark_failed(db, run_id, "deals empty or unavailable", counters)
|
runs_mod.mark_failed(db, run_id, "deals empty or unavailable", counters)
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,11 @@ one BrowserFetcher is constructed per run.
|
||||||
|
|
||||||
Exception triad differs from Avito:
|
Exception triad differs from Avito:
|
||||||
- DomClickBlockedError (QRATOR challenge page OR any browser-fetch failure) --
|
- DomClickBlockedError (QRATOR challenge page OR any browser-fetch failure) --
|
||||||
increments consecutive_blocks, abort via mark_done (NOT mark_failed) once
|
increments consecutive_blocks, abort once max_consecutive_blocks is hit.
|
||||||
max_consecutive_blocks is hit -- a block-abort is an expected operational
|
Статус такого прогона — 'banned' (#2674, см. runs.mark_backfill_finished):
|
||||||
outcome (QRATOR reputation burn), not a task failure. Mirrors Avito's
|
блок это external constraint, не наш баг, но и НЕ успех — раньше здесь стоял
|
||||||
AvitoBlockedError handling. No IP-rotation/cooldown recovery step exists here
|
mark_done, и 24 из 30 прогонов с нулём обогащений назывались успешными.
|
||||||
|
No IP-rotation/cooldown recovery step exists here
|
||||||
(DomClick uses one dedicated residential proxy, not a rotating pool) -- an
|
(DomClick uses one dedicated residential proxy, not a rotating pool) -- an
|
||||||
aborted run simply retries the remaining backlog next window.
|
aborted run simply retries the remaining backlog next window.
|
||||||
- DomClickParseError (__SSR_STATE__ missing/malformed -- schema drift, NOT a
|
- DomClickParseError (__SSR_STATE__ missing/malformed -- schema drift, NOT a
|
||||||
|
|
@ -52,10 +53,16 @@ Exception triad differs from Avito:
|
||||||
Cookie injection is mandatory wiring, not optional: cookies are loaded ONCE per run.
|
Cookie injection is mandatory wiring, not optional: cookies are loaded ONCE per run.
|
||||||
If None (no valid session uploaded / expired) -- the run still proceeds (cookie-
|
If None (no valid session uploaded / expired) -- the run still proceeds (cookie-
|
||||||
injection is a QRATOR-defeat mechanism, not a hard requirement; organic SERP-origin
|
injection is a QRATOR-defeat mechanism, not a hard requirement; organic SERP-origin
|
||||||
navigation from PR #2430 still applies) but a warning is logged once at run start so
|
navigation from PR #2430 still applies) but an ERROR is logged once at run start so
|
||||||
operators notice the test-account session needs refreshing via
|
operators notice the test-account session needs refreshing via
|
||||||
`POST /scrape/domclick/upload-cookies` (no auto-login -- documented MVP limitation,
|
`POST /scrape/domclick/upload-cookies` (no auto-login -- documented MVP limitation,
|
||||||
see app/services/domclick_session.py module docstring).
|
see app/services/domclick_session.py module docstring).
|
||||||
|
|
||||||
|
#2674: раньше это был WARNING, который в скрапер-контейнере событием не становится
|
||||||
|
(LoggingIntegration event_level=ERROR) — куки протухли 2026-08-03 и об этом никто не
|
||||||
|
узнал. Теперь два сигнала вместо одного: ERROR по факту (_alert_domclick_cookies) и
|
||||||
|
ERROR ЗАРАНЕЕ, пока куки ещё живы (_warn_before_domclick_cookies_expire) — по образцу
|
||||||
|
#2658 для Циана, ручное обновление кук требует запаса времени.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -65,6 +72,7 @@ import logging
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||||
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
|
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
|
||||||
|
|
@ -85,6 +93,60 @@ __all__ = [
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _alert_domclick_cookies(db: Session, run_id: int) -> None:
|
||||||
|
"""Громкий сигнал «обогащение идёт без кук» — logger.error, не warning (#2674).
|
||||||
|
|
||||||
|
В контейнере скрапера GlitchTip поднят с LoggingIntegration(event_level=ERROR)
|
||||||
|
(scheduler_main.py), поэтому прежний WARNING событием не становился: куки протухли
|
||||||
|
на проде 2026-08-03, и единственным следом была строка в docker-логе, которая
|
||||||
|
теряется при редеплое. Прогон при этом НЕ прерываем — cookie-инъекция это
|
||||||
|
механизм обхода QRATOR, а не жёсткое требование (см. докстринг модуля), — но
|
||||||
|
состояние требует ручного действия человека, значит должно быть событием.
|
||||||
|
|
||||||
|
Причину различаем так же, как #2658 у Циана: «кук нет вовсе» и «протухли N дней
|
||||||
|
назад» лечатся одинаково, но диагностируются по-разному.
|
||||||
|
"""
|
||||||
|
expires_at = domclick_session_svc.session_expires_at(db)
|
||||||
|
now = datetime.now(tz=UTC)
|
||||||
|
if expires_at is None:
|
||||||
|
detail = "кук DomClick нет в БД"
|
||||||
|
elif expires_at <= now:
|
||||||
|
detail = (
|
||||||
|
f"куки DomClick протухли {expires_at:%Y-%m-%d} "
|
||||||
|
f"({(now - expires_at).days} дн. назад)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
detail = "куки DomClick помечены невалидными (last_invalid_at)"
|
||||||
|
logger.error(
|
||||||
|
"domclick_detail_backfill: run_id=%d — %s; обогащение идёт БЕЗ cookie-инъекции "
|
||||||
|
"(QRATOR-обход деградировал до organic SERP-origin навигации, PR #2430). "
|
||||||
|
"Перезалейте сессию test-аккаунта: POST /scrape/domclick/upload-cookies",
|
||||||
|
run_id,
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_before_domclick_cookies_expire(db: Session, run_id: int) -> None:
|
||||||
|
"""Предупредить ЗАРАНЕЕ, пока куки ещё рабочие (#2674, образец — #2658 для Циана).
|
||||||
|
|
||||||
|
Сигнал по факту протухания приходит, когда обогащение уже встало; обновление кук
|
||||||
|
ручное, человеку нужен запас. valid_only=True — срок ИМЕННО той записи, которую
|
||||||
|
взял load_session (при нескольких аккаунтах свежайшая-любая может быть чужой).
|
||||||
|
"""
|
||||||
|
expires_at = domclick_session_svc.session_expires_at(db, valid_only=True)
|
||||||
|
if expires_at is None:
|
||||||
|
return
|
||||||
|
left = expires_at - datetime.now(tz=UTC)
|
||||||
|
if left <= timedelta(days=domclick_session_svc.COOKIE_EXPIRY_WARN_DAYS):
|
||||||
|
logger.error(
|
||||||
|
"domclick_detail_backfill: run_id=%d — куки DomClick протухнут %s "
|
||||||
|
"(осталось %.1f дн.); обновите заранее, иначе обогащение деградирует молча",
|
||||||
|
run_id,
|
||||||
|
expires_at.date().isoformat(),
|
||||||
|
left.total_seconds() / 86400,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DomClickDetailBackfillResult:
|
class DomClickDetailBackfillResult:
|
||||||
"""Counters for one backfill run."""
|
"""Counters for one backfill run."""
|
||||||
|
|
@ -120,7 +182,8 @@ async def run_domclick_detail_backfill(
|
||||||
max_consecutive_blocks: int -- abort threshold, default 3.
|
max_consecutive_blocks: int -- abort threshold, default 3.
|
||||||
|
|
||||||
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
|
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
|
||||||
mark_done (incl. partial/block-abort) / mark_failed (exception only).
|
mark_backfill_finished (done / banned при блоках / failed при нуле, #2674);
|
||||||
|
mark_failed напрямую — только при исключении.
|
||||||
"""
|
"""
|
||||||
batch_size = int(params.get("batch_size", 200))
|
batch_size = int(params.get("batch_size", 200))
|
||||||
budget_sec = float(params.get("budget_sec", 3600))
|
budget_sec = float(params.get("budget_sec", 3600))
|
||||||
|
|
@ -135,16 +198,13 @@ async def run_domclick_detail_backfill(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Cookie injection (#2000 PR #2433) -- loaded ONCE per run, threaded into every
|
# Cookie injection (#2000 PR #2433) -- loaded ONCE per run, threaded into every
|
||||||
# fetch_detail() call below. None is a valid (degraded) state, not an error.
|
# fetch_detail() call below. Прогон продолжается и без кук (см. докстринг), но
|
||||||
|
# это состояние требует ЧЕЛОВЕКА: обновление сессии — ручная операция.
|
||||||
cookies = domclick_session_svc.load_session(db)
|
cookies = domclick_session_svc.load_session(db)
|
||||||
if cookies is None:
|
if cookies is None:
|
||||||
logger.warning(
|
_alert_domclick_cookies(db, run_id)
|
||||||
"domclick_detail_backfill: run_id=%d -- no valid DomClick session cookies "
|
else:
|
||||||
"in DB; proceeding WITHOUT cookie-injection (QRATOR-defeat degraded to "
|
_warn_before_domclick_cookies_expire(db, run_id)
|
||||||
"organic SERP-origin navigation only, PR #2430). Refresh test-account "
|
|
||||||
"session via POST /scrape/domclick/upload-cookies.",
|
|
||||||
run_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
runs_mod.update_heartbeat(db, run_id, current_counters)
|
runs_mod.update_heartbeat(db, run_id, current_counters)
|
||||||
|
|
||||||
|
|
@ -193,6 +253,7 @@ async def run_domclick_detail_backfill(
|
||||||
)
|
)
|
||||||
|
|
||||||
consecutive_blocks = 0
|
consecutive_blocks = 0
|
||||||
|
aborted_by_blocks = False
|
||||||
do_sleep = False
|
do_sleep = False
|
||||||
|
|
||||||
# Exactly ONE BrowserFetcher per run (no curl fallback for DomClick, see
|
# Exactly ONE BrowserFetcher per run (no curl fallback for DomClick, see
|
||||||
|
|
@ -275,6 +336,7 @@ async def run_domclick_detail_backfill(
|
||||||
counters.enriched,
|
counters.enriched,
|
||||||
counters.attempted,
|
counters.attempted,
|
||||||
)
|
)
|
||||||
|
aborted_by_blocks = True
|
||||||
break
|
break
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -296,9 +358,15 @@ async def run_domclick_detail_backfill(
|
||||||
|
|
||||||
counters.duration_sec = time.monotonic() - start
|
counters.duration_sec = time.monotonic() - start
|
||||||
current_counters = counters.to_dict()
|
current_counters = counters.to_dict()
|
||||||
runs_mod.mark_done(db, run_id, current_counters)
|
runs_mod.mark_backfill_finished(
|
||||||
|
db,
|
||||||
|
run_id,
|
||||||
|
current_counters,
|
||||||
|
source="domclick_detail_backfill",
|
||||||
|
aborted_by_blocks=aborted_by_blocks,
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"domclick_detail_backfill: run_id=%d DONE -- attempted=%d enriched=%d "
|
"domclick_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "
|
||||||
"blocked=%d failed=%d duration=%.1fs",
|
"blocked=%d failed=%d duration=%.1fs",
|
||||||
run_id,
|
run_id,
|
||||||
counters.attempted,
|
counters.attempted,
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,29 @@
|
||||||
- Scheduled: nightly via scrape_schedules (source='geocode_missing_listings', migration 110)
|
- Scheduled: nightly via scrape_schedules (source='geocode_missing_listings', migration 110)
|
||||||
— wired into in-app scheduler, window 06:00-09:00 UTC.
|
— wired into in-app scheduler, window 06:00-09:00 UTC.
|
||||||
|
|
||||||
Pattern: dedup по address (1 unique address → 1 geocode call → UPDATE all listings).
|
Pattern: dedup по паре (address, city) — 1 уникальная пара → 1 geocode call → UPDATE
|
||||||
Rate limit: Nominatim 1 req/sec. Yandex 25K/day если YANDEX_GEOCODER_API_KEY set.
|
всех listings с этим address+city (#2594 шаг 2/3: listings.city теперь заполняется
|
||||||
|
скрапером из контекста развёртки — один и тот же текст адреса в разных городах
|
||||||
|
(«ул. Победы, 30» в ЕКБ и в Нижнем Тагиле) должен получать РАЗНЫЕ координаты, а
|
||||||
|
не схлопываться в один geocode-вызов и один UPDATE по тексту адреса).
|
||||||
|
Rate limit: Nominatim 1 req/sec (#2593: Yandex Geocoder tier удалён из geocoder).
|
||||||
|
|
||||||
|
SELECT фильтрует `is_active` (#2604 п.1): на проде очередь была на 98.5% забита
|
||||||
|
мёртвыми объявлениями чужих регионов (Новосибирск/Казань/Челябинск/…) без is_active —
|
||||||
|
`ORDER BY listings_count DESC` ставил их В НАЧАЛО (у мусорного адреса вида
|
||||||
|
«Новосибирская обл.,Новосибирск» — сотни listings, у реального адреса — 1-2), поэтому
|
||||||
|
весь batch-бюджет (Nominatim 1 req/sec) съедался мусором и до настоящих адресов дело
|
||||||
|
не доходило (8 ночных прогонов подряд: saved=0). UPDATE после успешного/неуспешного
|
||||||
|
geocode НЕ фильтрует is_active — см. комментарии у соответствующих UPDATE ниже.
|
||||||
|
|
||||||
Отличие от /admin/geocode-missing (per-ID):
|
Отличие от /admin/geocode-missing (per-ID):
|
||||||
- Этот модуль группирует по address → меньше API calls (dedup).
|
- Этот модуль группирует по (address, city) → меньше API calls (dedup), но не
|
||||||
|
схлопывает разные города с одинаковым текстом адреса.
|
||||||
- Поддерживает all sources включая Avito (после PR #487 убрали jitter).
|
- Поддерживает all sources включая Avito (после PR #487 убрали jitter).
|
||||||
- Возвращает GeocodeBackfillResult с детальными counters.
|
- Возвращает GeocodeBackfillResult с детальными counters.
|
||||||
- Loop-safe: SELECT фильтрует geocode_tried_at IS NULL OR tried_at < 7 days;
|
- Loop-safe: SELECT фильтрует geocode_tried_at IS NULL OR tried_at < 7 days;
|
||||||
при geocode failure помечает tried_at=NOW() → адрес не переотбирается в этом же run.
|
при geocode failure помечает tried_at=NOW() → пара (address, city) не
|
||||||
|
переотбирается в этом же run.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -27,7 +41,7 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.services import scrape_runs as runs_mod
|
from app.services import scrape_runs as runs_mod
|
||||||
from app.services.estimator import _geocode_is_coarse
|
from app.services.estimator import _geocode_is_coarse
|
||||||
from app.services.geocoder import geocode
|
from app.services.geocoder import geocode, known_city_hint
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -53,13 +67,24 @@ async def geocode_missing_listings(
|
||||||
"""Geocode listings с NULL coords (любой source).
|
"""Geocode listings с NULL coords (любой source).
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. SELECT DISTINCT address FROM listings WHERE lat IS NULL AND address IS NOT NULL
|
1. SELECT address, city FROM listings WHERE lat IS NULL AND is_active
|
||||||
GROUP BY address ORDER BY COUNT(*) DESC LIMIT batch_size
|
AND address IS NOT NULL GROUP BY address, city ORDER BY COUNT(*) DESC
|
||||||
(приоритет адресам с большим числом listings — больший ROI per geocode call)
|
LIMIT batch_size
|
||||||
|
(приоритет парам address+city с большим числом listings — больший ROI per
|
||||||
|
geocode call; группировка по паре, НЕ только по address — #2594 шаг 2/3:
|
||||||
|
один и тот же текст адреса в разных городах — разные записи. `is_active` —
|
||||||
|
#2604 п.1: не тратим Nominatim-бюджет на мёртвые объявления, которые никогда
|
||||||
|
не попадут в выдачу пользователю)
|
||||||
|
|
||||||
2. Для каждого address:
|
2. Для каждой пары (address, city):
|
||||||
- geocode(address, db) — auto-cache (hit или miss)
|
- geocode(address, db, city_hint=known_city_hint(city)) — auto-cache
|
||||||
- Если есть результат: UPDATE listings SET lat, lon WHERE address = :addr AND lat IS NULL
|
(hit или miss); хинт гейтится словарём городов области (#2603)
|
||||||
|
- Если есть результат: UPDATE listings SET lat, lon
|
||||||
|
WHERE address = :addr AND city IS NOT DISTINCT FROM :city AND lat IS NULL
|
||||||
|
(IS NOT DISTINCT FROM, а не `=` — стандартная SQL NULL-семантика: `city = NULL`
|
||||||
|
никогда не true, поэтому обычным `=` группа с city IS NULL не обновилась бы
|
||||||
|
вообще ни для одной строки; `IS NOT DISTINCT FROM` трактует NULL=NULL как
|
||||||
|
совпадение, оставаясь строгим при непустом city — нужная нам симметрия)
|
||||||
- PostGIS trigger (listings_set_geom_trg) автоматически обновит geom
|
- PostGIS trigger (listings_set_geom_trg) автоматически обновит geom
|
||||||
|
|
||||||
3. Log progress каждые 50 addresses.
|
3. Log progress каждые 50 addresses.
|
||||||
|
|
@ -74,25 +99,39 @@ async def geocode_missing_listings(
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
result = GeocodeBackfillResult()
|
result = GeocodeBackfillResult()
|
||||||
|
|
||||||
# 1. Найти top-N адресов с NULL coords (DESC by occurrence count).
|
# 1. Найти top-N пар (address, city) с NULL coords (DESC by occurrence count).
|
||||||
# Фильтруем адреса, по которым геокодер уже пробовал и не нашёл — они помечены
|
# Группировка по паре, а не только по address (#2594 шаг 2/3) — один и тот же
|
||||||
|
# текст адреса в разных городах (напр. «ул. Победы, 30» в ЕКБ и в Нижнем Тагиле)
|
||||||
|
# это разные записи с разными координатами, их нельзя схлопывать в один
|
||||||
|
# geocode-вызов. GROUP BY address, city трактует NULL city как отдельную
|
||||||
|
# группу (стандартная SQL-семантика группировки NULL как равных друг другу).
|
||||||
|
# Фильтруем пары, по которым геокодер уже пробовал и не нашёл — они помечены
|
||||||
# geocode_tried_at. Повторяем попытку только если tried_at старше 7 дней (возможен
|
# geocode_tried_at. Повторяем попытку только если tried_at старше 7 дней (возможен
|
||||||
# переезд адреса в кэше или смена провайдера), либо tried_at IS NULL (ещё не пробовали).
|
# переезд адреса в кэше или смена провайдера), либо tried_at IS NULL (ещё не пробовали).
|
||||||
# Это делает функцию loop-safe: при вызове несколько раз в одном прогоне
|
# Это делает функцию loop-safe: при вызове несколько раз в одном прогоне
|
||||||
# failed-адреса не переотбираются бесконечно.
|
# failed-пары не переотбираются бесконечно.
|
||||||
|
#
|
||||||
|
# AND is_active (#2604 п.1) — очередь без этого фильтра на 98.5% состояла из
|
||||||
|
# is_active=false объявлений чужих регионов (Новосибирск/Казань/Челябинск/…),
|
||||||
|
# а ORDER BY listings_count DESC ставил самый мусорный адрес («Новосибирская
|
||||||
|
# обл.,Новосибирск», сотни listings) В НАЧАЛО — весь batch съедался мусором,
|
||||||
|
# который пользователь никогда не увидит (is_active=false), 8 ночных прогонов
|
||||||
|
# подряд saved=0. Активные объявления с валидным адресом почти всегда попадают
|
||||||
|
# в topN только теперь, когда мусор не конкурирует за место в LIMIT.
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT address, COUNT(*) AS listings_count
|
SELECT address, city, COUNT(*) AS listings_count
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE lat IS NULL
|
WHERE lat IS NULL
|
||||||
|
AND is_active
|
||||||
AND address IS NOT NULL
|
AND address IS NOT NULL
|
||||||
AND length(trim(address)) >= 5
|
AND length(trim(address)) >= 5
|
||||||
AND (geocode_tried_at IS NULL
|
AND (geocode_tried_at IS NULL
|
||||||
OR geocode_tried_at < NOW() - INTERVAL '7 days')
|
OR geocode_tried_at < NOW() - INTERVAL '7 days')
|
||||||
GROUP BY address
|
GROUP BY address, city
|
||||||
ORDER BY listings_count DESC, address ASC
|
ORDER BY listings_count DESC, address ASC, city ASC NULLS FIRST
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -117,23 +156,43 @@ async def geocode_missing_listings(
|
||||||
|
|
||||||
for idx, row in enumerate(rows):
|
for idx, row in enumerate(rows):
|
||||||
address: str = row["address"]
|
address: str = row["address"]
|
||||||
|
city: str | None = row.get("city")
|
||||||
listings_count: int = row["listings_count"]
|
listings_count: int = row["listings_count"]
|
||||||
result.addresses_processed += 1
|
result.addresses_processed += 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
geo = await geocode(address, db)
|
# known_city_hint (#2603) — общий гейт по словарю городов области для
|
||||||
|
# всех DB-колоночных callers. Для listings.city он сегодня no-op
|
||||||
|
# (скрапер пишет только шесть кураторских имён из
|
||||||
|
# scraper_kit CITY_DISPLAY_NAMES, все они есть в словаре), но держит
|
||||||
|
# инвариант единым с deals-путями, где колонка росреестровая и в
|
||||||
|
# хвосте лежит мусор. Сырой `city` ниже остаётся ключом группы для
|
||||||
|
# UPDATE — гейт влияет только на подсказку геокодеру.
|
||||||
|
geo = await geocode(address, db, city_hint=known_city_hint(city))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("geocode_missing: geocode raised for '%s': %s", address[:60], exc)
|
logger.warning("geocode_missing: geocode raised for '%s': %s", address[:60], exc)
|
||||||
result.addresses_failed += 1
|
result.addresses_failed += 1
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
# Пометить tried_at чтобы адрес не переотбирался в следующих batch'ах
|
# Пометить tried_at чтобы пара (address, city) не переотбиралась
|
||||||
# этого же прогона (loop-safe backoff 7 дней).
|
# в следующих batch'ах этого же прогона (loop-safe backoff 7 дней).
|
||||||
|
# IS NOT DISTINCT FROM — city=NULL это отдельная группа, обычное
|
||||||
|
# `=` не поймает NULL-город и не должно задеть другой город с тем
|
||||||
|
# же текстом адреса.
|
||||||
|
# Намеренно БЕЗ `AND is_active` (#2604 п.2): tried_at — backoff-метка
|
||||||
|
# для (address, city) КАК ТЕКСТА, а не для конкретного listing.
|
||||||
|
# is_active=false дубликат этой пары и так никогда не будет выбран
|
||||||
|
# SELECT'ом заново (is_active=false исключён там навсегда) — фильтр
|
||||||
|
# здесь был бы no-op для неактивных строк. Единственный случай когда
|
||||||
|
# это имеет значение — если строка позже реактивируется (is_active
|
||||||
|
# → true): тогда tried_at уже стоит и backoff корректно защищает от
|
||||||
|
# немедленного повторного запроса того же заведомо неудачного адреса.
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"UPDATE listings SET geocode_tried_at = NOW()"
|
"UPDATE listings SET geocode_tried_at = NOW()"
|
||||||
" WHERE address = :addr AND lat IS NULL"
|
" WHERE address = :addr AND city IS NOT DISTINCT FROM :city"
|
||||||
|
" AND lat IS NULL"
|
||||||
),
|
),
|
||||||
{"addr": address},
|
{"addr": address, "city": city},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
continue
|
continue
|
||||||
|
|
@ -141,18 +200,25 @@ async def geocode_missing_listings(
|
||||||
if geo is None:
|
if geo is None:
|
||||||
result.addresses_failed += 1
|
result.addresses_failed += 1
|
||||||
logger.info(
|
logger.info(
|
||||||
"geocode_missing: NOT FOUND '%s' (used in %d listings)",
|
"geocode_missing: NOT FOUND '%s' city=%r (used in %d listings)",
|
||||||
address[:60],
|
address[:60],
|
||||||
|
city,
|
||||||
listings_count,
|
listings_count,
|
||||||
)
|
)
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
# Пометить tried_at — geocoder не нашёл адрес, backoff 7 дней.
|
# Пометить tried_at — geocoder не нашёл адрес, backoff 7 дней.
|
||||||
|
# Намеренно БЕЗ `AND is_active` (#2604 п.2) — то же обоснование, что
|
||||||
|
# и в except-ветке выше: backoff привязан к тексту (address, city),
|
||||||
|
# не к конкретному listing, is_active=false строка и так не выбирается
|
||||||
|
# SELECT'ом заново; при реактивации backoff корректно защитит от
|
||||||
|
# немедленного повтора заведомо неудачного запроса.
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"UPDATE listings SET geocode_tried_at = NOW()"
|
"UPDATE listings SET geocode_tried_at = NOW()"
|
||||||
" WHERE address = :addr AND lat IS NULL"
|
" WHERE address = :addr AND city IS NOT DISTINCT FROM :city"
|
||||||
|
" AND lat IS NULL"
|
||||||
),
|
),
|
||||||
{"addr": address},
|
{"addr": address, "city": city},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
continue
|
continue
|
||||||
|
|
@ -165,9 +231,13 @@ async def geocode_missing_listings(
|
||||||
result.addresses_geocoded += 1
|
result.addresses_geocoded += 1
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
|
# city в логе (#2603) — с #2594 это часть ключа группы: без него две
|
||||||
|
# строки dry-run с одинаковым текстом адреса неотличимы друг от друга.
|
||||||
logger.info(
|
logger.info(
|
||||||
"geocode_missing[dry]: '%s' → (%.5f, %.5f) provider=%s would update %d listings",
|
"geocode_missing[dry]: '%s' city=%r → (%.5f, %.5f) provider=%s "
|
||||||
|
"would update %d listings",
|
||||||
address[:60],
|
address[:60],
|
||||||
|
city,
|
||||||
geo.lat,
|
geo.lat,
|
||||||
geo.lon,
|
geo.lon,
|
||||||
geo.provider,
|
geo.provider,
|
||||||
|
|
@ -183,16 +253,37 @@ async def geocode_missing_listings(
|
||||||
|
|
||||||
# UPDATE listings — PostGIS trigger (listings_set_geom_trg) обновит geom автоматически.
|
# UPDATE listings — PostGIS trigger (listings_set_geom_trg) обновит geom автоматически.
|
||||||
# geo_precision и geocode_tried_at проставляются одновременно с координатами.
|
# geo_precision и geocode_tried_at проставляются одновременно с координатами.
|
||||||
|
# city IS NOT DISTINCT FROM :city — обновляем ТОЛЬКО пару (address, city), из
|
||||||
|
# которой был geocode-запрос; иначе тот же текст адреса в другом городе
|
||||||
|
# (city IS NULL или другой явный город) перезаписался бы чужими координатами.
|
||||||
|
#
|
||||||
|
# Намеренно БЕЗ `AND is_active` (#2604 п.1): координаты — свойство физического
|
||||||
|
# адреса, а не свойство конкретного объявления. Если у этой же пары
|
||||||
|
# (address, city) есть is_active=false дубликат с lat IS NULL, он получит те же
|
||||||
|
# координаты бесплатно — Nominatim-вызов уже оплачен геокодом активного
|
||||||
|
# листинга, доп. запроса не будет. SELECT выше и так навсегда исключает
|
||||||
|
# is_active=false строки из очереди — без этого UPDATE такой дубликат остался
|
||||||
|
# бы с NULL lat/lon НАВСЕГДА (переезд в EKB-only локальные реестры/analytics по
|
||||||
|
# координатам сломан для него), хотя ответ уже есть в руках. Единственный
|
||||||
|
# довод «за» фильтр — консистентность с SELECT — не перевешивает: это не
|
||||||
|
# ошибка данных (координаты адреса объективны и не зависят от активности),
|
||||||
|
# а чистый выигрыш (та же строка при реактивации уже готова, доп. cost = 0).
|
||||||
update_result = db.execute(
|
update_result = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
UPDATE listings
|
UPDATE listings
|
||||||
SET lat = :lat, lon = :lon, geo_precision = :precision,
|
SET lat = :lat, lon = :lon, geo_precision = :precision,
|
||||||
geocode_tried_at = NOW()
|
geocode_tried_at = NOW()
|
||||||
WHERE address = :addr AND lat IS NULL
|
WHERE address = :addr AND city IS NOT DISTINCT FROM :city AND lat IS NULL
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"lat": geo.lat, "lon": geo.lon, "precision": precision, "addr": address},
|
{
|
||||||
|
"lat": geo.lat,
|
||||||
|
"lon": geo.lon,
|
||||||
|
"precision": precision,
|
||||||
|
"addr": address,
|
||||||
|
"city": city,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
result.listings_updated += update_result.rowcount
|
result.listings_updated += update_result.rowcount
|
||||||
|
|
@ -293,6 +384,13 @@ async def run_geocode_missing_listings(
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
if res.addresses_total < batch_size:
|
if res.addresses_total < batch_size:
|
||||||
|
# #2604 п.3: с is_active-фильтром в SELECT очередь резко уже (была
|
||||||
|
# 14294 строк/98.5% мёртвых, стало ~220 активных → десятки уникальных
|
||||||
|
# пар address+city после GROUP BY) — этот дренаж почти всегда сработает
|
||||||
|
# уже на первой итерации (addresses_total < default batch_size=200), и
|
||||||
|
# это ПРАВИЛЬНОЕ поведение: разгребли всё что было, ждём следующего
|
||||||
|
# прогона. Никакого деления тут нет (только сравнение int), пустая
|
||||||
|
# очередь (addresses_total=0) ловится веткой выше, а не этой.
|
||||||
logger.info(
|
logger.info(
|
||||||
"run_geocode_missing_listings: run_id=%d — дренаж "
|
"run_geocode_missing_listings: run_id=%d — дренаж "
|
||||||
"(addresses_total=%d < batch_size=%d), завершаем",
|
"(addresses_total=%d < batch_size=%d), завершаем",
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,34 @@ listing_source_events. Так история per-source цены копится
|
||||||
через product_handlers._job_listing_source_snapshot,
|
через product_handlers._job_listing_source_snapshot,
|
||||||
по образцу import_rosreestr_dkp (sync task в run_in_executor).
|
по образцу import_rosreestr_dkp (sync task в run_in_executor).
|
||||||
|
|
||||||
Вся работа — два set-based SQL statement'а (snapshot upsert + event-diff CTE),
|
Вся работа — два set-based SQL statement'а (snapshot upsert + event-diff), никакого
|
||||||
никакого row-by-row Python: 18 355 строк обслуживаются одним INSERT … SELECT каждый.
|
row-by-row Python.
|
||||||
|
|
||||||
|
#2607 — root cause висящих прогонов (ежедневный zombie с минимум 19 июля, всегда ровно 6h
|
||||||
|
до zombie-порога): event-diff раньше писал "prior" как CTE `DISTINCT ON (listing_source_id)
|
||||||
|
... ORDER BY listing_source_id, snapshot_date DESC` по ВСЕЙ listing_source_snapshots (~2.6-2.8M
|
||||||
|
строк) и джойнил её с "today" через обычный JOIN. Планировщик оценивает `snapshot_date =
|
||||||
|
CURRENT_DATE` в 1 строку (статистика ANALYZE ещё не видела свежевставленные в этой же
|
||||||
|
транзакции строки today — CURRENT_DATE всегда за пределами гистограммы), выбирает Nested
|
||||||
|
Loop БЕЗ Materialize на внутренней стороне и на КАЖДУЮ реальную строку today (~80-140k)
|
||||||
|
заново пересчитывает DISTINCT ON по всей таблице (Unique + Index Scan ~2.7M строк) —
|
||||||
|
EXPLAIN на проде показал 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
@ -27,6 +48,30 @@ logger = logging.getLogger(__name__)
|
||||||
# Окно свежести: источник считается активным, если last_seen_at не старше N дней.
|
# Окно свежести: источник считается активным, если last_seen_at не старше N дней.
|
||||||
FRESHNESS_WINDOW_DAYS = 7
|
FRESHNESS_WINDOW_DAYS = 7
|
||||||
|
|
||||||
|
# ── Wall-clock budget (#2607 п.4) ─────────────────────────────────────────────
|
||||||
|
# Задача не батчится Python-циклом (два set-based statement'а) — единственный способ
|
||||||
|
# гарантированно оборвать зависший statement это Postgres-нативный statement_timeout,
|
||||||
|
# выставленный SET LOCAL (per-transaction scope, НЕ трогает server/role-level timeout —
|
||||||
|
# это issue #2607 п.2, отдельное решение с согласованием). По образцу budget_sec из
|
||||||
|
# app/tasks/geocode_missing.py (run_geocode_missing_listings), только здесь это не Python
|
||||||
|
# loop-budget, а SQL statement_timeout.
|
||||||
|
# Default/clamp: см. data/sql/202_listing_source_snapshot_budget_sec.sql (default_params
|
||||||
|
# budget_sec=900 — 15 мин, с большим запасом над ожидаемым временем выполнения после
|
||||||
|
# LATERAL-фикса (секунды) и далеко от 6h zombie-порога).
|
||||||
|
DEFAULT_BUDGET_SEC = 900.0
|
||||||
|
_MIN_BUDGET_SEC = 30.0
|
||||||
|
_MAX_BUDGET_SEC = 3600.0 # hard ceiling — не даём budget_sec случайно воссоздать "висит вечно"
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_budget_sec(raw: Any) -> float:
|
||||||
|
"""Валидировать/зажать budget_sec из default_params — защита от 0/отрицательного/мусора."""
|
||||||
|
try:
|
||||||
|
val = float(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
val = DEFAULT_BUDGET_SEC
|
||||||
|
return max(_MIN_BUDGET_SEC, min(val, _MAX_BUDGET_SEC))
|
||||||
|
|
||||||
|
|
||||||
# ── Daily snapshot upsert ─────────────────────────────────────────────────────
|
# ── Daily snapshot upsert ─────────────────────────────────────────────────────
|
||||||
# Снимок на (listing_source_id, CURRENT_DATE). ON CONFLICT → last-write-wins за день
|
# Снимок на (listing_source_id, CURRENT_DATE). ON CONFLICT → last-write-wins за день
|
||||||
# (повторный прогон в те же сутки перезаписывает снимок свежими значениями).
|
# (повторный прогон в те же сутки перезаписывает снимок свежими значениями).
|
||||||
|
|
@ -59,83 +104,209 @@ _SNAPSHOT_SQL = text(
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Event diff: price_change ──────────────────────────────────────────────────
|
# ── Event diff: три выводимых типа событий из пяти в схеме ────────────────────
|
||||||
# Для каждого источника сравниваем сегодняшнюю цену (snapshot_date = CURRENT_DATE) с
|
# Для каждого источника сравниваем сегодняшний снимок (snapshot_date = CURRENT_DATE) с
|
||||||
# самым свежим ПРЕДЫДУЩИМ снимком (snapshot_date < CURRENT_DATE). Если цена изменилась
|
# самым свежим ПРЕДЫДУЩИМ (snapshot_date < CURRENT_DATE).
|
||||||
# (обе NOT NULL, old <> 0) — пишем price_change.
|
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции).
|
||||||
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL).
|
# p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607).
|
||||||
# prior — последний снимок строго ДО сегодня (DISTINCT ON … ORDER BY date DESC).
|
#
|
||||||
# Полностью set-based: один INSERT … SELECT по всем источникам, без Python-цикла.
|
# #2674: схема (079) знает пять типов событий, писатель умел один — price_change,
|
||||||
# change_time = now() детерминирует UNIQUE(listing_source_id, change_time, event_type)
|
# 8288 строк. Дописаны два:
|
||||||
# в пределах прогона → ON CONFLICT DO NOTHING делает писатель идемпотентным.
|
# edited — payload_hash изменился, а цена нет (изменение цены уже описано
|
||||||
|
# отдельным событием price_change — дублировать его как «редактирование»
|
||||||
|
# значило бы считать одно изменение дважды). Прошлый хеш обязан быть
|
||||||
|
# непустым: md5(NULL) = NULL, и «payload появился впервые» — это не
|
||||||
|
# правка, а первое наблюдение;
|
||||||
|
# first_seen — предыдущего снимка нет вовсе (LEFT JOIN LATERAL даёт p.* = NULL).
|
||||||
|
#
|
||||||
|
# delisted и relisted НЕ ПИШУТСЯ НАМЕРЕННО — они НЕ ВЫВОДИМЫ из наших данных.
|
||||||
|
# is_active в снимке — derived-признак «last_seen_at свежее FRESHNESS_WINDOW_DAYS»,
|
||||||
|
# то есть «мы видели», а не «объявление есть на площадке». При покрытии обхода 10-35%
|
||||||
|
# такой переход рождается тем, что скрейпер СНОВА ДОШЁЛ до источника, а не тем, что
|
||||||
|
# объявление вернулось/ушло. Контрольная группа в наших же данных (14-18.07):
|
||||||
|
# domklik, покрытие 99.9-100%: снятий 1/2/0/2/4 в сутки, возвратов — РОВНО 0 все дни;
|
||||||
|
# yandex, покрытие 34-43%: снятий 343-433 в сутки, возвратов до 155.
|
||||||
|
# Тот же обход, тот же день — разница только в покрытии. Отсюда же всплески:
|
||||||
|
# avito 13.07 (день остановки обхода) — 3023 «снятия» за сутки против контрольной
|
||||||
|
# ставки 1-4, точность события ≈4%; 4705 «возвратов» из 5493 за 12 дней (86%) — это
|
||||||
|
# два дня после возобновления обхода 2-3.08.
|
||||||
|
# Сузить окно свежести НЕ поможет — станет хуже (больше флапаний); окно шире
|
||||||
|
# максимального интервала повторного визита обессмысливает само событие.
|
||||||
|
# Честный ответ схеме — не писать эти два типа, а не наполнять журнал догадками.
|
||||||
|
# Единственный жёсткий сигнал снятия — 404 при поштучном обходе, он пишется в
|
||||||
|
# listings_snapshots.status='closed' (avito_detail_backfill).
|
||||||
|
#
|
||||||
|
# Оставшиеся три события утверждают факты о НАШИХ СОБСТВЕННЫХ строках («появился новый
|
||||||
|
# источник», «хеш изменился при той же цене», «цена другая»), а не о поведении площадки.
|
||||||
|
#
|
||||||
|
# JOIN → LEFT JOIN LATERAL: без LEFT источники без предыдущего снимка отбрасывались
|
||||||
|
# join'ом, поэтому first_seen был недостижим по построению. План #2607 не меняется —
|
||||||
|
# LEFT JOIN LATERAL так же форсирует per-row индексный point-lookup по
|
||||||
|
# idx_lss_source_date, просто не отбрасывает строку при отсутствии предыдущей.
|
||||||
|
#
|
||||||
|
# Ветки разворачиваются CROSS JOIN LATERAL (VALUES ...) — одна строка сравнения даёт
|
||||||
|
# до трёх строк-кандидатов, из которых WHERE e.fires оставляет сработавшие. Это
|
||||||
|
# по-прежнему ОДИН set-based statement (никакого Python-цикла), просто три предиката
|
||||||
|
# вместо одного.
|
||||||
|
#
|
||||||
|
# #2607: раньше `p` был отдельным CTE `DISTINCT ON (listing_source_id) ... FROM
|
||||||
|
# listing_source_snapshots WHERE snapshot_date < CURRENT_DATE` и джойнился обычным JOIN.
|
||||||
|
# Планировщик оценивает `today` в 1 строку (свежевставленные в этой же транзакции строки
|
||||||
|
# ANALYZE ещё не видел) → Nested Loop БЕЗ Materialize на внутренней стороне → DISTINCT ON
|
||||||
|
# по ВСЕЙ таблице (~2.6-2.8M строк, Index Scan + Unique) пересчитывался ЗАНОВО на каждую
|
||||||
|
# из ~80-140k реальных строк today — на проде EXPLAIN показал cost≈300k на этом шаге,
|
||||||
|
# запрос не укладывался ни в 6h zombie-порог, ни в сутки. LATERAL форсирует per-row
|
||||||
|
# индексный lookup через idx_lss_source_date (listing_source_id, snapshot_date DESC) —
|
||||||
|
# `ORDER BY s.snapshot_date DESC LIMIT 1` даёт тот же единственный "последний снимок до
|
||||||
|
# сегодня" на listing_source_id, что и старый DISTINCT ON (PK (listing_source_id,
|
||||||
|
# snapshot_date) исключает дубликаты snapshot_date на одном источнике — семантика
|
||||||
|
# идентична), но за O(log n) на строку вместо полного скана таблицы. EXPLAIN на проде:
|
||||||
|
# cost внутреннего подзапроса упал с ~298 627 до ~4.4 за строку today.
|
||||||
|
#
|
||||||
|
# Полностью set-based: один INSERT … SELECT по всем источникам, без Python-цикла (LATERAL
|
||||||
|
# — это внутренний план Postgres, не Python-итерация).
|
||||||
|
#
|
||||||
|
# change_time = date_trunc('day', now()), а НЕ now() (#2674): с now() уникальность
|
||||||
|
# UNIQUE(listing_source_id, change_time, event_type) работала только ВНУТРИ прогона —
|
||||||
|
# второй прогон в те же сутки перезаписывал сегодняшний снимок, предикаты срабатывали
|
||||||
|
# заново с другим временем и давали дубли (2 августа таких прогонов было два).
|
||||||
|
# Суточная гранулярность честнее для суточного же сравнения и включает заявленную
|
||||||
|
# идемпотентность: ON CONFLICT DO NOTHING теперь действительно гасит повтор за день.
|
||||||
|
#
|
||||||
|
# NULLIF(p.price_rub, 0) в diff_percent обязателен: выражения VALUES вычисляются ДО
|
||||||
|
# фильтра `WHERE e.fires`, поэтому предикат "p.price_rub <> 0" от деления на ноль уже
|
||||||
|
# не спасает — без NULLIF первый же источник с нулевой прошлой ценой уронил бы весь
|
||||||
|
# прогон. Результат при этом тот же: строка с NULL-диффом не проходит e.fires.
|
||||||
|
#
|
||||||
|
# Внешний SELECT над data-modifying CTE считает вставленное ПО ТИПАМ (RETURNING отдаёт
|
||||||
|
# только реально вставленные строки, не съеденные ON CONFLICT), сразу в виде ключей
|
||||||
|
# счётчиков `<event_type>_events` — писатель получает готовый dict без Python-агрегации.
|
||||||
|
# Ровно этот счётчик и показал бы четыре нуля из пяти, если бы существовал раньше.
|
||||||
_EVENT_DIFF_SQL = text(
|
_EVENT_DIFF_SQL = text(
|
||||||
"""
|
"""
|
||||||
WITH today AS (
|
WITH today AS (
|
||||||
SELECT listing_source_id, price_rub
|
SELECT listing_source_id, price_rub, payload_hash
|
||||||
FROM listing_source_snapshots
|
FROM listing_source_snapshots
|
||||||
WHERE snapshot_date = CURRENT_DATE
|
WHERE snapshot_date = CURRENT_DATE
|
||||||
),
|
),
|
||||||
prior AS (
|
inserted AS (
|
||||||
SELECT DISTINCT ON (listing_source_id)
|
|
||||||
listing_source_id, price_rub
|
|
||||||
FROM listing_source_snapshots
|
|
||||||
WHERE snapshot_date < CURRENT_DATE
|
|
||||||
ORDER BY listing_source_id, snapshot_date DESC
|
|
||||||
)
|
|
||||||
INSERT INTO listing_source_events (
|
INSERT INTO listing_source_events (
|
||||||
listing_source_id, change_time, event_type, price_rub, diff_percent
|
listing_source_id, change_time, event_type, price_rub, diff_percent
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
t.listing_source_id,
|
t.listing_source_id,
|
||||||
now(),
|
date_trunc('day', now()),
|
||||||
'price_change',
|
e.event_type,
|
||||||
t.price_rub,
|
t.price_rub,
|
||||||
round((t.price_rub - p.price_rub)::numeric / p.price_rub * 100, 4)
|
e.diff_percent
|
||||||
FROM today t
|
FROM today t
|
||||||
JOIN prior p ON p.listing_source_id = t.listing_source_id
|
LEFT JOIN LATERAL (
|
||||||
WHERE t.price_rub IS NOT NULL
|
SELECT s.snapshot_date, s.price_rub, s.payload_hash
|
||||||
|
FROM listing_source_snapshots s
|
||||||
|
WHERE s.listing_source_id = t.listing_source_id
|
||||||
|
AND s.snapshot_date < CURRENT_DATE
|
||||||
|
ORDER BY s.snapshot_date DESC
|
||||||
|
LIMIT 1
|
||||||
|
) p ON true
|
||||||
|
CROSS JOIN LATERAL (VALUES
|
||||||
|
(
|
||||||
|
'first_seen',
|
||||||
|
NULL::numeric,
|
||||||
|
p.snapshot_date IS NULL
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'price_change',
|
||||||
|
round((t.price_rub - p.price_rub)::numeric
|
||||||
|
/ NULLIF(p.price_rub, 0) * 100, 4),
|
||||||
|
t.price_rub IS NOT NULL
|
||||||
AND p.price_rub IS NOT NULL
|
AND p.price_rub IS NOT NULL
|
||||||
AND p.price_rub <> 0
|
AND p.price_rub <> 0
|
||||||
AND t.price_rub <> p.price_rub
|
AND t.price_rub <> p.price_rub
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'edited',
|
||||||
|
NULL::numeric,
|
||||||
|
p.payload_hash IS NOT NULL
|
||||||
|
AND t.payload_hash IS DISTINCT FROM p.payload_hash
|
||||||
|
AND t.price_rub IS NOT DISTINCT FROM p.price_rub
|
||||||
|
)
|
||||||
|
) AS e(event_type, diff_percent, fires)
|
||||||
|
WHERE e.fires
|
||||||
ON CONFLICT (listing_source_id, change_time, event_type) DO NOTHING
|
ON CONFLICT (listing_source_id, change_time, event_type) DO NOTHING
|
||||||
|
RETURNING event_type
|
||||||
|
)
|
||||||
|
SELECT event_type || '_events' AS counter_key, count(*) AS n
|
||||||
|
FROM inserted
|
||||||
|
GROUP BY 1
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def snapshot_listing_sources(db: Session, run_id: int) -> dict[str, int]:
|
def snapshot_listing_sources(
|
||||||
"""Записать дневной снимок listing_sources + price_change-события.
|
db: Session, run_id: int, params: dict[str, Any] | None = None
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Записать дневной снимок listing_sources + события изменений.
|
||||||
|
|
||||||
Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp).
|
Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp).
|
||||||
Два set-based statement'а в одной транзакции:
|
Два set-based statement'а в одной транзакции:
|
||||||
1. upsert снимка на (listing_source_id, CURRENT_DATE) — last-write-wins.
|
1. upsert снимка на (listing_source_id, CURRENT_DATE) — last-write-wins.
|
||||||
2. diff сегодняшней цены против последнего предыдущего снимка → price_change-события.
|
2. diff сегодняшнего снимка против последнего предыдущего → три события,
|
||||||
|
выводимые из наших данных (#2674). delisted/relisted схема разрешает, но
|
||||||
|
они НЕ выводимы при покрытии обхода 10-35% — см. _EVENT_DIFF_SQL.
|
||||||
|
|
||||||
|
Params (из default_params jsonb в scrape_schedules, #2607):
|
||||||
|
budget_sec: float — SET LOCAL statement_timeout на транзакцию (default 900,
|
||||||
|
clamp [30, 3600]). Единственный способ гарантированно оборвать зависший
|
||||||
|
statement у не-батчащейся (два statement'а, не Python-цикл) задачи — если
|
||||||
|
план снова разрегрессирует, прогон честно упадёт в mark_failed вместо того
|
||||||
|
чтобы висеть часами/сутками (root cause #2607 — см. шапку файла и
|
||||||
|
_EVENT_DIFF_SQL).
|
||||||
|
|
||||||
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
|
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
|
||||||
|
|
||||||
Returns {"snapshotted": N, "price_change_events": M}.
|
Returns {"snapshotted": N, "<event_type>_events": M} — по счётчику на каждый из
|
||||||
|
трёх пишущихся типов, всегда все три ключа (тип, который за прогон не сработал
|
||||||
|
ни разу, честно показывает 0, а не пропадает из counters).
|
||||||
"""
|
"""
|
||||||
counters: dict[str, int] = {"snapshotted": 0, "price_change_events": 0}
|
params = params or {}
|
||||||
|
budget_sec = _clamp_budget_sec(params.get("budget_sec", DEFAULT_BUDGET_SEC))
|
||||||
|
counters: dict[str, int] = {
|
||||||
|
"snapshotted": 0,
|
||||||
|
"price_change_events": 0,
|
||||||
|
"edited_events": 0,
|
||||||
|
"first_seen_events": 0,
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
|
# statement_timeout НЕ принимает bind-параметр ($1/:name) — синтаксис Postgres SET
|
||||||
|
# запрещает placeholder на этом месте (проверено вживую на проде: "syntax error at
|
||||||
|
# or near \"$1\""). budget_sec провалидирован/clamp'нут в _clamp_budget_sec выше
|
||||||
|
# (источник — scrape_schedules.default_params, не user input) — f-string здесь
|
||||||
|
# безопасен (единственный практический способ выставить эту GUC динамически).
|
||||||
|
# SET LOCAL — per-transaction scope, сбрасывается на COMMIT/ROLLBACK, НЕ трогает
|
||||||
|
# server/role-level statement_timeout (issue #2607 п.2 — отдельное решение).
|
||||||
|
timeout_ms = int(budget_sec * 1000)
|
||||||
|
db.execute(text(f"SET LOCAL statement_timeout = {timeout_ms}"))
|
||||||
|
|
||||||
snap_result = db.execute(
|
snap_result = db.execute(
|
||||||
_SNAPSHOT_SQL,
|
_SNAPSHOT_SQL,
|
||||||
{"freshness_days": FRESHNESS_WINDOW_DAYS, "run_id": run_id},
|
{"freshness_days": FRESHNESS_WINDOW_DAYS, "run_id": run_id},
|
||||||
)
|
)
|
||||||
counters["snapshotted"] = snap_result.rowcount or 0
|
counters["snapshotted"] = snap_result.rowcount or 0
|
||||||
|
|
||||||
event_result = db.execute(_EVENT_DIFF_SQL)
|
# Statement возвращает уже готовые пары (counter_key, n) по типам событий —
|
||||||
counters["price_change_events"] = event_result.rowcount or 0
|
# dict(...) без Python-агрегации, набор ключей задан инициализацией counters
|
||||||
|
# выше, так что не сработавшие типы остаются нулями, а не исчезают.
|
||||||
|
event_rows = db.execute(_EVENT_DIFF_SQL).fetchall()
|
||||||
|
counters.update(dict(event_rows))
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
runs_mod.mark_done(db, run_id, counters)
|
runs_mod.mark_done(db, run_id, counters)
|
||||||
logger.info(
|
logger.info("snapshot_listing_sources run_id=%d done: %s", run_id, counters)
|
||||||
"snapshot_listing_sources run_id=%d done: snapshotted=%d price_change_events=%d",
|
|
||||||
run_id,
|
|
||||||
counters["snapshotted"],
|
|
||||||
counters["price_change_events"],
|
|
||||||
)
|
|
||||||
return counters
|
return counters
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("snapshot_listing_sources run_id=%d failed", run_id)
|
logger.exception(
|
||||||
|
"snapshot_listing_sources run_id=%d failed (budget_sec=%.0f)", run_id, budget_sec
|
||||||
|
)
|
||||||
db.rollback()
|
db.rollback()
|
||||||
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field, fields
|
from dataclasses import dataclass, field, fields
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
@ -306,8 +307,7 @@ def _house_enrichment_counts(db: Session, house_id: int) -> tuple[int, int, int]
|
||||||
rc = int(
|
rc = int(
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"SELECT COUNT(*) FROM house_reliability_checks "
|
"SELECT COUNT(*) FROM house_reliability_checks WHERE house_id = CAST(:h AS bigint)"
|
||||||
"WHERE house_id = CAST(:h AS bigint)"
|
|
||||||
),
|
),
|
||||||
{"h": house_id},
|
{"h": house_id},
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
@ -328,6 +328,7 @@ async def backfill_newbuilding_enrichment(
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
request_delay_sec: float | None = None,
|
request_delay_sec: float | None = None,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
|
on_progress: Callable[[NewbuildingEnrichBackfillResult], None] | None = None,
|
||||||
) -> NewbuildingEnrichBackfillResult:
|
) -> NewbuildingEnrichBackfillResult:
|
||||||
"""Backfill the 3 newbuilding-enrichment tables over cian_newbuilding houses.
|
"""Backfill the 3 newbuilding-enrichment tables over cian_newbuilding houses.
|
||||||
|
|
||||||
|
|
@ -347,6 +348,11 @@ async def backfill_newbuilding_enrichment(
|
||||||
(default 5s). Applied with ±20% jitter; anti-bot politeness. A house needing
|
(default 5s). Applied with ±20% jitter; anti-bot politeness. A house needing
|
||||||
a resolve incurs TWO delays (resolve fetch + enrich fetch).
|
a resolve incurs TWO delays (resolve fetch + enrich fetch).
|
||||||
dry_run: count the population + log the pending list, fetch nothing, write nothing.
|
dry_run: count the population + log the pending list, fetch nothing, write nothing.
|
||||||
|
on_progress: колбэк живости (#2725) — вызывается на каждом доме с текущим
|
||||||
|
(мутируемым) result; caller пишет scrape_runs.heartbeat_at. Без него
|
||||||
|
heartbeat уходил один раз до цикла, а `reap_zombies` меряет именно его:
|
||||||
|
дом обходится за ~2.6 мин, и на limit'е порядка 140 (полный прогон — 318
|
||||||
|
домов, см. выше) прогон переваливал бы 6-часовой порог живым.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
NewbuildingEnrichBackfillResult with population sizing, per-house outcome
|
NewbuildingEnrichBackfillResult with population sizing, per-house outcome
|
||||||
|
|
@ -415,6 +421,8 @@ async def backfill_newbuilding_enrichment(
|
||||||
zhk_url: str | None = row["cian_zhk_url"]
|
zhk_url: str | None = row["cian_zhk_url"]
|
||||||
ext_id: str | None = row["ext_id"]
|
ext_id: str | None = row["ext_id"]
|
||||||
result.processed += 1
|
result.processed += 1
|
||||||
|
if on_progress is not None:
|
||||||
|
on_progress(result)
|
||||||
|
|
||||||
# Idempotency fast-path: with force=False the SELECT already excludes enriched
|
# Idempotency fast-path: with force=False the SELECT already excludes enriched
|
||||||
# houses (price_dynamics + reliability present), so this branch is a belt-and-
|
# houses (price_dynamics + reliability present), so this branch is a belt-and-
|
||||||
|
|
@ -696,6 +704,18 @@ async def run_newbuilding_enrich(
|
||||||
"failed_save": 0,
|
"failed_save": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _heartbeat(progress: NewbuildingEnrichBackfillResult) -> None:
|
||||||
|
"""Сигнал живости из середины цикла (#2725). Best-effort — сбой heartbeat не
|
||||||
|
должен ронять уже идущий обход."""
|
||||||
|
try:
|
||||||
|
runs_mod.update_heartbeat(db, run_id, progress.to_dict())
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"scheduler: newbuilding_enrich run_id=%d heartbeat failed (ignored)",
|
||||||
|
run_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
runs_mod.update_heartbeat(db, run_id, counters)
|
runs_mod.update_heartbeat(db, run_id, counters)
|
||||||
|
|
||||||
|
|
@ -704,6 +724,7 @@ async def run_newbuilding_enrich(
|
||||||
limit=limit,
|
limit=limit,
|
||||||
force=force,
|
force=force,
|
||||||
request_delay_sec=request_delay_sec,
|
request_delay_sec=request_delay_sec,
|
||||||
|
on_progress=_heartbeat,
|
||||||
)
|
)
|
||||||
|
|
||||||
counters = result.to_dict()
|
counters = result.to_dict()
|
||||||
|
|
|
||||||
|
|
@ -9,26 +9,47 @@
|
||||||
видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок.
|
видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок.
|
||||||
|
|
||||||
Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и
|
Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и
|
||||||
поднимает per-day WARNING-алерт, когда данные устарели СВЕРХ допустимого лага
|
поднимает per-day ERROR-алерт, когда данные устарели СВЕРХ допустимого лага
|
||||||
публикации — так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах.
|
публикации — так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах.
|
||||||
|
|
||||||
|
#2674 — почему ERROR, а не WARNING. В контейнере скрапера GlitchTip поднят с
|
||||||
|
LoggingIntegration(event_level=ERROR) (scheduler_main.py), поэтому WARNING
|
||||||
|
событием НЕ становится вообще. Бенчмарк цен участвует в сверке наших медиан, его
|
||||||
|
застой — сбой, а не наблюдение. Сосед по конструкции (deals_freshness_monitor)
|
||||||
|
писал ERROR с самого начала — расходилась только эта джоба.
|
||||||
|
|
||||||
|
ВАЖНО про «9 срабатываний» из #2674 (ревью PR #2681, прод-разбор всех 24 прогонов
|
||||||
|
монитора 2026-08-06). Эти девять НЕ были застоем бенчмарка — это была ПИЛА нашего
|
||||||
|
собственного такта загрузки:
|
||||||
|
13-16.07 alert=1 age 73..76 latest=май 01-05.08 alert=1 age 61..65
|
||||||
|
17.07 alert=0 age 46 latest=июнь (день загрузки)
|
||||||
|
Загрузка ходила раз в 28 дней и приносила период на месяц новее, возраст же
|
||||||
|
считается от ПЕРВОГО числа покрытого месяца → пол ~46 в момент загрузки, потолок
|
||||||
|
46+28=74, порог 60 ВНУТРИ диапазона, тревога 14 суток из 28 каждый цикл. Поднимать
|
||||||
|
такое до ERROR без починки такта значило бы завести ежедневное ложное событие на
|
||||||
|
две недели в месяц. Поэтому миграция 212 перевела sber_index_pull на НЕДЕЛЬНЫЙ
|
||||||
|
такт: потолок возраста ≈ пол+7 ≈ 53 при пороге 60, тревога снова означает
|
||||||
|
«источник/загрузка встали», а не «мы давно не ходили».
|
||||||
|
|
||||||
Порог алерта (документирование выбора):
|
Порог алерта (документирование выбора):
|
||||||
Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д).
|
Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д).
|
||||||
Монитор: age > sber_index_max_age_days + lag_allowance.
|
Монитор: age > sber_index_max_age_days + lag_allowance.
|
||||||
lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) — запас на ИНХЕРЕНТНЫЙ лаг
|
lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) — запас на ИНХЕРЕНТНЫЙ лаг
|
||||||
публикации СберИндекса: источник отстаёт на 1-2 месяца, period_month — лейбл
|
публикации СберИндекса: источник отстаёт на 1-2 месяца, а period_month — лейбл
|
||||||
ПЕРВОГО числа месяца, а месячный pull ещё не подтянул новейший период. Итог:
|
ПЕРВОГО числа месяца, поэтому даже свежайшая загрузка даёт возраст ~46 суток.
|
||||||
35 + 25 = 60д. Ниже 60д latest считается «нормально отстающим» → алерта нет
|
Итог: 35 + 25 = 60д. При недельном такте (миграция 212) рабочий диапазон возраста
|
||||||
(иначе daily-шум на штатном лаге). Выше 60д данные застряли сверх ~2 месяцев
|
~46..53 — до порога остаётся ~7 суток запаса: один пропущенный недельный цикл
|
||||||
→ алерт. Проверено на проде 2026-07-12: max=2026-05-01, age=72д > 60 → alert=1.
|
поглощается, два подряд дают тревогу. Порог НЕ должен снова оказаться внутри
|
||||||
|
рабочего диапазона — если такт загрузки будут менять, пересчитай потолок
|
||||||
|
(пол + interval_days) и сверь с 60.
|
||||||
|
|
||||||
Задача синхронная (DB-only, один SELECT max(period_month)) — запускается
|
Задача синхронная (DB-only, один SELECT max(period_month)) — запускается
|
||||||
kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в
|
kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в
|
||||||
run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ
|
run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ
|
||||||
функция evaluate_sber_freshness() (frozen-now тестируется без БД).
|
функция evaluate_sber_freshness() (frozen-now тестируется без БД).
|
||||||
|
|
||||||
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — WARNING
|
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — ERROR-записи
|
||||||
достаточен. mark_failed только если sber_price_index недоступна/пуста (нечего
|
достаточно. mark_failed только если sber_price_index недоступна/пуста (нечего
|
||||||
оценивать).
|
оценивать).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -136,7 +157,10 @@ def check_sber_freshness(
|
||||||
row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first()
|
row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first()
|
||||||
latest: date | None = row.latest if row is not None else None
|
latest: date | None = row.latest if row is not None else None
|
||||||
if latest is None:
|
if latest is None:
|
||||||
logger.warning(
|
# ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой,
|
||||||
|
# а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд),
|
||||||
|
# а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке.
|
||||||
|
logger.error(
|
||||||
"sber freshness: sber_price_index пуст/недоступен для region=%s "
|
"sber freshness: sber_price_index пуст/недоступен для region=%s "
|
||||||
"(вторичка) — оценить свежесть нельзя",
|
"(вторичка) — оценить свежесть нельзя",
|
||||||
SBER_MONITOR_CITY,
|
SBER_MONITOR_CITY,
|
||||||
|
|
@ -156,7 +180,9 @@ def check_sber_freshness(
|
||||||
}
|
}
|
||||||
|
|
||||||
if verdict.stale:
|
if verdict.stale:
|
||||||
logger.warning(
|
# ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR) —
|
||||||
|
# 9 срабатываний на проде дали ноль событий. См. докстринг модуля.
|
||||||
|
logger.error(
|
||||||
"sber freshness: max(period_month)=%s устарел на %d дней "
|
"sber freshness: max(period_month)=%s устарел на %d дней "
|
||||||
"(> порога %d = sber_index_max_age_days %d + lag %d); "
|
"(> порога %d = sber_index_max_age_days %d + lag %d); "
|
||||||
"СберИндекс time-adjustment ДКП-сделок мог отстать — "
|
"СберИндекс time-adjustment ДКП-сделок мог отстать — "
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,10 @@ offer detail page via curl_cffi AsyncSession (chrome120 + proxy) — mirrors
|
||||||
yandex_address_backfill.py which already gets full HTML from Yandex on prod.
|
yandex_address_backfill.py which already gets full HTML from Yandex on prod.
|
||||||
Parse HTML via YandexDetailScraper.parse (pure, no network). Persist via
|
Parse HTML via YandexDetailScraper.parse (pure, no network). Persist via
|
||||||
save_detail_enrichment. Track consecutive parse→None results; abort after
|
save_detail_enrichment. Track consecutive parse→None results; abort after
|
||||||
max_consecutive_blocks (mark_done, not mark_failed — retry next night via
|
max_consecutive_blocks. Прогон с нулём обогащений теперь 'failed', не 'done'
|
||||||
NULL detail_enriched_at).
|
(#2674, runs.mark_backfill_finished): на проде 31 прогон из 52 упирался ровно в
|
||||||
|
этот брейкер (attempted=5 failed=5) и все 31 назывались успешными. Остаток
|
||||||
|
снапшота уедет в следующую ночь через NULL detail_enriched_at.
|
||||||
|
|
||||||
Why curl_cffi and not YandexDetailScraper.fetch_detail:
|
Why curl_cffi and not YandexDetailScraper.fetch_detail:
|
||||||
fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS
|
fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS
|
||||||
|
|
@ -85,7 +87,8 @@ async def run_yandex_detail_backfill(
|
||||||
(possible captcha wall); consecutive None → abort after max_consecutive_blocks.
|
(possible captcha wall); consecutive None → abort after max_consecutive_blocks.
|
||||||
|
|
||||||
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
|
Lifecycle: update_heartbeat -> snapshot -> loop with budget guard ->
|
||||||
mark_done (incl. partial / consecutive-None abort) / mark_failed (exception only).
|
mark_backfill_finished (done / failed при нуле обогащений, #2674);
|
||||||
|
mark_failed напрямую — только при исключении.
|
||||||
"""
|
"""
|
||||||
batch_size = int(params.get("batch_size", 800))
|
batch_size = int(params.get("batch_size", 800))
|
||||||
budget_sec = float(params.get("budget_sec", 3600))
|
budget_sec = float(params.get("budget_sec", 3600))
|
||||||
|
|
@ -278,9 +281,11 @@ async def run_yandex_detail_backfill(
|
||||||
|
|
||||||
counters.duration_sec = time.monotonic() - start
|
counters.duration_sec = time.monotonic() - start
|
||||||
current_counters = counters.to_dict()
|
current_counters = counters.to_dict()
|
||||||
runs_mod.mark_done(db, run_id, current_counters)
|
runs_mod.mark_backfill_finished(
|
||||||
|
db, run_id, current_counters, source="yandex_detail_backfill"
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"yandex_detail_backfill: run_id=%d DONE -- attempted=%d enriched=%d "
|
"yandex_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "
|
||||||
"failed=%d duration=%.1fs",
|
"failed=%d duration=%.1fs",
|
||||||
run_id,
|
run_id,
|
||||||
counters.attempted,
|
counters.attempted,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,14 @@
|
||||||
--
|
--
|
||||||
-- СОЗДАЁТ:
|
-- СОЗДАЁТ:
|
||||||
-- asking_to_sold_ratios — таблица коэффициентов (rooms_bucket, district) → ratio.
|
-- asking_to_sold_ratios — таблица коэффициентов (rooms_bucket, district) → ratio.
|
||||||
-- rooms_bucket: 0=студия,1,2,3,4(=4+); СПЕЦ-строка rooms_bucket=-1 = global fallback.
|
-- rooms_bucket: ИМЯ ЛЕГАСИ — с #2620 (2026-08) семантика AREA-BASED, не «комнаты»:
|
||||||
|
-- 0=area<30, 1=area<44, 2=area<62, 3=area<85, 4=area>=85 м² (границы = ровно та же
|
||||||
|
-- формула, что синтезирует deals.rooms из площади при импорте, см. deploy/import-
|
||||||
|
-- rosreestr.sh и app/tasks/asking_to_sold_ratio.py: _AREA_ROOMS_BUCKET_SQL/area_bucket()).
|
||||||
|
-- Причина: Росреестр не отдаёт реальную комнатность, поэтому обе стороны (расчёт ask_side
|
||||||
|
-- И применение в estimator.py) ключуются по площади — сравнение «area-бакет vs реальные
|
||||||
|
-- комнаты» давало систематический mismatch (до 55% строк не в своём бакете, #2620).
|
||||||
|
-- СПЕЦ-строка rooms_bucket=-1 = global fallback.
|
||||||
-- district: ЗАРЕЗЕРВИРОВАНО для #647 (geo-разбивка); в #648 ВСЕГДА '' (часть PK,
|
-- district: ЗАРЕЗЕРВИРОВАНО для #647 (geo-разбивка); в #648 ВСЕГДА '' (часть PK,
|
||||||
-- поэтому NOT NULL DEFAULT '' — '' можно положить в PK, NULL нельзя).
|
-- поэтому NOT NULL DEFAULT '' — '' можно положить в PK, NULL нельзя).
|
||||||
--
|
--
|
||||||
|
|
@ -61,7 +68,8 @@ BEGIN;
|
||||||
-- district NOT NULL DEFAULT '' — часть PK; #647 заполнит район, #648 всегда ''.
|
-- district NOT NULL DEFAULT '' — часть PK; #647 заполнит район, #648 всегда ''.
|
||||||
-- sold_median/ask_median nullable — диагностика; ratio NOT NULL (строку без ratio не пишем).
|
-- sold_median/ask_median nullable — диагностика; ratio NOT NULL (строку без ratio не пишем).
|
||||||
CREATE TABLE IF NOT EXISTS asking_to_sold_ratios (
|
CREATE TABLE IF NOT EXISTS asking_to_sold_ratios (
|
||||||
rooms_bucket int NOT NULL, -- 0=студия..4=4+; -1 = global fallback row
|
rooms_bucket int NOT NULL, -- legacy name, area-based since #2620:
|
||||||
|
-- 0=area<30..4=area>=85; -1=global fallback
|
||||||
district text NOT NULL DEFAULT '', -- RESERVED for #647 (always '' in #648)
|
district text NOT NULL DEFAULT '', -- RESERVED for #647 (always '' in #648)
|
||||||
ratio numeric NOT NULL, -- sold_median_ppm2 / ask_median_ppm2
|
ratio numeric NOT NULL, -- sold_median_ppm2 / ask_median_ppm2
|
||||||
sold_median bigint, -- median(deals.price_per_m2), диагностика
|
sold_median bigint, -- median(deals.price_per_m2), диагностика
|
||||||
|
|
@ -75,18 +83,25 @@ CREATE TABLE IF NOT EXISTS asking_to_sold_ratios (
|
||||||
);
|
);
|
||||||
|
|
||||||
COMMENT ON TABLE asking_to_sold_ratios IS
|
COMMENT ON TABLE asking_to_sold_ratios IS
|
||||||
'Per-rooms asking→sold коэффициент (#648): ratio = median(SOLD ppm²)/median(ASKING ppm²). '
|
'Asking→sold коэффициент (#648): ratio = median(SOLD ppm²)/median(ASKING ppm²). '
|
||||||
'rooms_bucket 0=студия..4=4+; -1 = global fallback (basis=global_fallback, пишется всегда). '
|
'rooms_bucket — LEGACY NAME, area-based since #2620: 0=area<30..4=area>=85 m2 (same '
|
||||||
|
'formula deals.rooms is synthesized from, see import-rosreestr.sh); -1 = global fallback '
|
||||||
|
'(basis=global_fallback, пишется всегда). '
|
||||||
'Per-rooms строки только при n_deals>=30 AND n_listings>=30 (иначе estimator читает -1). '
|
'Per-rooms строки только при n_deals>=30 AND n_listings>=30 (иначе estimator читает -1). '
|
||||||
'district зарезервирован под #647 (geo), в #648 всегда ''''. '
|
'district зарезервирован под #647 (geo), в #648 всегда ''''. '
|
||||||
'Caveat: ask=ТЕКУЩИЕ listings vs sold=сделки за 12 мес (не point-in-time); ДКП=registered. '
|
'Caveat: ask=ТЕКУЩИЕ listings vs sold=сделки за 12 мес (не point-in-time); ДКП=registered. '
|
||||||
'Refresh — Stage 4 asking_to_sold_ratio_refresh переиспользует derivation ниже.';
|
'Refresh — Stage 4 asking_to_sold_ratio_refresh переиспользует derivation ниже (area-bucket '
|
||||||
|
'ask-side since #2620 — see app/tasks/asking_to_sold_ratio.py, this seed predates it).';
|
||||||
|
|
||||||
-- ── Derivation + seed ─────────────────────────────────────────────────────────
|
-- ── Derivation + seed ─────────────────────────────────────────────────────────
|
||||||
-- Вынесено как один INSERT...SELECT с CTE-«сторонами» (deal_side / ask_side), чтобы
|
-- Вынесено как один INSERT...SELECT с CTE-«сторонами» (deal_side / ask_side), чтобы
|
||||||
-- Stage 4 (asking_to_sold_ratio_refresh) переиспользовал ровно эту логику. Окно сделок
|
-- Stage 4 (asking_to_sold_ratio_refresh) переиспользовал ровно эту логику. Окно сделок
|
||||||
-- = трейлинг 12 мес; listings — текущие активные. ppm²-полоса [30000,600000] и бакет
|
-- = трейлинг 12 мес; listings — текущие активные. ppm²-полоса [30000,600000] и бакет
|
||||||
-- LEAST(GREATEST(rooms,0),4) — байт-в-байт как в харнесе (PPM2_MIN/PPM2_MAX, _bucketize_rooms).
|
-- LEAST(GREATEST(rooms,0),4) — байт-в-байт как в харнесе (PPM2_MIN/PPM2_MAX, _bucketize_rooms).
|
||||||
|
-- #2620 (2026-08): live-рефреш (app/tasks/asking_to_sold_ratio.py) ушёл от этого fresh-install
|
||||||
|
-- seed — ask_side там бакетируется по площади (_AREA_ROOMS_BUCKET_SQL), а не rooms; см. комментарий
|
||||||
|
-- в СОЗДАЁТ выше и модуль asking_to_sold_ratio.py. Этот CTE-блок оставлен как есть (fresh-install
|
||||||
|
-- seed, применяется один раз через _schema_migrations) — не источник истины для прод-derivation.
|
||||||
WITH
|
WITH
|
||||||
-- SOLD медианы по бакетам комнат за трейлинг-12мес (ДКП Росреестра).
|
-- SOLD медианы по бакетам комнат за трейлинг-12мес (ДКП Росреестра).
|
||||||
deal_side AS (
|
deal_side AS (
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,15 @@
|
||||||
-- precision БЕЗ единого внешнего HTTP-запроса (в отличие от Nominatim) — но был ТОЛЬКО
|
-- precision БЕЗ единого внешнего HTTP-запроса (в отличие от Nominatim) — но был ТОЛЬКО
|
||||||
-- manual script (`python -m app.tasks.backfill_listings_coords_geoportal`), ни разу не
|
-- manual script (`python -m app.tasks.backfill_listings_coords_geoportal`), ни разу не
|
||||||
-- запускавшийся на recurring основе. Один прошлый ручной прогон (#1841): 17241
|
-- запускавшийся на recurring основе. Один прошлый ручной прогон (#1841): 17241
|
||||||
-- кандидатов → 1008 проставлено (не-ЕКБ адреса не матчатся — корректно, EKB-only реестр).
|
-- кандидатов → 1008 проставлено.
|
||||||
|
--
|
||||||
|
-- ИСПРАВЛЕНО #2583 (находка H3): до фикса не-ЕКБ адреса region 66 (Нижний Тагил, Серов
|
||||||
|
-- и т.д.) НЕ отсекались — street+house парсились без учёта города и слепо матчились
|
||||||
|
-- против EKB-only реестра. Улица+дом могут буквально совпасть с ЕКБ ("проспект Ленина 1"
|
||||||
|
-- есть и в ЕКБ, и в Нижнем Тагиле) — такой листинг получал координаты Екатеринбурга.
|
||||||
|
-- Фикс: городской гейт _names_non_ekb_city перед вызовом _geoportal_house_match (тот же
|
||||||
|
-- гейт, что и в geocoder.geocode()). Не "корректно, EKB-only реестр", как было написано
|
||||||
|
-- здесь раньше — это была реальная утечка не-ЕКБ адресов в ЕКБ-координаты.
|
||||||
--
|
--
|
||||||
-- Решение: wire в in-app scheduler (source='geoportal_coords_backfill') по паттерну
|
-- Решение: wire в in-app scheduler (source='geoportal_coords_backfill') по паттерну
|
||||||
-- cadastral_geo_match (migration 125) — pure internal DB op, SAFE to enable=true.
|
-- cadastral_geo_match (migration 125) — pure internal DB op, SAFE to enable=true.
|
||||||
|
|
|
||||||
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