Compare commits

..

No commits in common. "main" and "feat/b2c-report-redesign" have entirely different histories.

494 changed files with 16541 additions and 72326 deletions

View file

@ -346,11 +346,11 @@ jobs:
- uses: actions/checkout@v4
- name: Set up Node
# Node 24 — major из tradein-mvp/frontend/Dockerfile (node:24-alpine).
# Node 20 — major из tradein-mvp/frontend/Dockerfile (node:20-alpine).
# cache: npm включён с #2770 — package-lock.json теперь tracked.
uses: actions/setup-node@v4
with:
node-version: "24"
node-version: "20"
cache: npm
cache-dependency-path: tradein-mvp/frontend/package-lock.json

View file

@ -82,40 +82,6 @@ jobs:
python3 scripts/check-caddy-snippet-mounts.py --selftest
python3 scripts/check-caddy-snippet-mounts.py
- name: "Guard: сервисы МЕРЫ не ссылаются на двоящееся имя"
# ПТИЦА и МЕРА — разные compose-проекты, но оба назвали сервисы
# `backend`/`postgres`/`frontend` и оба сидят в общей сети
# gendesign_shared. Docker отдаёт на такое имя ДВА адреса, клиент
# берёт любой.
#
# 30.08.2026 это уронило публичный лендинг: BACKEND_URL вёл в бэкенд
# ПТИЦЫ, тот отвечал 401, и страница про точность рендерилась БЕЗ
# ленты сделок, без строк сверки и без подписи разброса — то есть без
# единого доказательства. Отказ тихий: fetch не бросает, приходит
# валидный чужой ответ; а из-за двоения часть перегенераций попадала
# в правильный адрес, и поломка выглядела случайной.
run: |
python3 scripts/check-compose-ambiguous-hosts.py --selftest
python3 scripts/check-compose-ambiguous-hosts.py
- name: "Guard: подмена фронта МЕРЫ без окна недоступности (#3274)"
# Тем же шагом-соседом и по той же причине: секунды на PR, падение
# блокирует merge.
#
# ЗАЧЕМ. Публичный лендинг лежал 3090 с на КАЖДОМ деплое МЕРЫ —
# не потому, что подмена контейнера медленная (0,5 с), а потому, что
# `up -d` со списком сервисов делает create всех (старые контейнеры
# УДАЛЯЮТСЯ) и только потом start, дождавшись зависимостей. Лечение —
# две половинки в разных файлах: `frontend` вынесен из общей пачки в
# deploy-tradein.yml + ретрай подключения в caddy/sites/apps.caddy.
# Обе обратимы молча и незаметно (дописать frontend обратно в SERVICES
# «за компанию»; скопировать новый публичный путь с блока без импорта),
# а отказ виден только непрерывной пробой во время деплоя — то есть
# никогда, если её никто не запустил.
run: |
python3 scripts/check-frontend-swap-window.py --selftest
python3 scripts/check-frontend-swap-window.py
- name: "Guard: Caddyfile синтаксически валиден"
# Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит
# на КАЖДОМ PR, стоит секунды, падение блокирует merge.
@ -219,30 +185,6 @@ jobs:
- '.forgejo/workflows/deploy.yml'
- '.forgejo/workflows/deploy-tradein.yml'
- '.forgejo/workflows/ci.yml'
# #3448: тот же класс, ещё раз. Гейт про исключающие `!`-шаблоны
# в paths-filter проверяет ВСЕ воркфлоу, а paths-filter живёт и
# здесь — без этой строки правка ci-tradein.yml с таким шаблоном
# не запустила бы backend-tests, то есть гейт не побежал бы ровно
# на той правке, от которой стережёт.
- '.forgejo/workflows/ci-tradein.yml'
# #3467/#3475: гейт backend/tests/ops/test_3467_prometheus_reload.py
# читает оба файла ниже. Без них правка, трогающая ТОЛЬКО
# deploy-metrics.yml (скажем, дописывающая `|| true` к шагу
# перезагрузки Prometheus), даёт backend=false — джоба
# backend-tests пропускается, гейт не исполняется, регрессия
# уезжает в main зелёной. Ровно то, что осуждает комментарий выше.
- '.forgejo/workflows/deploy-metrics.yml'
- 'docker-compose.metrics.yml'
# #3443: тот же класс, третий раз. Гейт
# backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает
# ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть
# все содержательные регрессии живут в самом скрипте, а не в
# deploy.yml. PR, правящий только ops/**, без этой строки давал бы
# backend=false: джоба пропускается, гейт не исполняется, и
# «пересоздавать всегда» (окно 67 с на всех доменах) или
# «не пересоздавать никогда» (правка конфига беззвучно не доезжает)
# уезжает в main зелёным.
- 'ops/**'
frontend:
- 'frontend/**'
- '.forgejo/workflows/ci.yml'
@ -446,12 +388,12 @@ jobs:
- uses: actions/checkout@v4
- name: Set up Node
# Node 24 — совпадает с major из frontend/Dockerfile (node:24-alpine).
# Node 20 — совпадает с major из frontend/Dockerfile (node:20-alpine).
# cache=npm + cache-dependency-path на lockfile → переиспользуем ~/.npm
# между прогонами (mirror Dockerfile's `--mount=type=cache,target=/root/.npm`).
uses: actions/setup-node@v4
with:
node-version: "24"
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json
@ -507,7 +449,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "24"
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json

View file

@ -90,13 +90,8 @@ jobs:
METRICS_TELEGRAM_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_TOPIC_ID }}
METRICS_TELEGRAM_INFRA_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_INFRA_TOPIC_ID }}
METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }}
ALERT_ACK_GLITCHTIP_SECRET: ${{ secrets.ALERT_ACK_GLITCHTIP_SECRET }}
# #3471: секрет ретранслятора Telegram Bot API (tg-relay). Пусто —
# профиль relay не включаем (см. PROFILES ниже), а не падаем в
# рестарт-луп: контейнер сам делает SystemExit на пустом секрете.
TG_RELAY_SECRET: ${{ secrets.TG_RELAY_SECRET }}
with:
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL,ALERT_ACK_GLITCHTIP_SECRET,TG_RELAY_SECRET
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
@ -193,14 +188,6 @@ jobs:
echo "Инфраструктура: тема ${INFRA_TOPIC_ID} по умолчанию (METRICS_TELEGRAM_INFRA_TOPIC_ID не задана)."
fi
# Резервный приёмник GlitchTip (#3471) отвечает 503 на любой
# запрос, пока секрет пуст: тихо принимать чужие алерты настежь
# хуже, чем не принимать вовсе. Молчаливого отказа тут быть не
# должно — деплой обязан сказать, что канал не поднялся.
if [ -z "${ALERT_ACK_GLITCHTIP_SECRET:-}" ]; then
echo "::warning title=Резервный канал GlitchTip выключен::ALERT_ACK_GLITCHTIP_SECRET пуст — alert-ack отвечает 503 на /glitchtip, и при падении продуктового бэкенда его ошибки доставлять будет нечем."
fi
if [ -n "${METRICS_TELEGRAM_ONCALL:-}" ]; then
echo "Клиентские инциденты: зовём ${METRICS_TELEGRAM_ONCALL} поимённо."
else
@ -263,19 +250,6 @@ jobs:
echo "::warning title=Алерты выключены::METRICS_TELEGRAM_BOT_TOKEN/CHAT_ID не заданы. Метрики и логи собираются, но при срабатывании правила НИКТО не будет уведомлён. Канал доставки — открытый вопрос #3078."
fi
# Ретранслятор Telegram Bot API (#3471, PR #3487 сломал прод: сервис
# без profiles уходил в SystemExit на пустом секрете и висел в
# Restarting). Профиль relay включаем НЕЗАВИСИМО от alerts — это
# разные каналы (один шлёт алерты боту, другой ретранслирует
# продуктовый Bot API трафик с Selectel). PROFILES — список через
# запятую, как того требует COMPOSE_PROFILES.
if [ -n "${TG_RELAY_SECRET:-}" ]; then
PROFILES="${PROFILES:+$PROFILES,}relay"
echo "Ретранслятор Telegram: секрет задан, профиль relay включён."
else
echo "::warning title=Резервный ретранслятор Telegram выключен::TG_RELAY_SECRET пуст — tg-relay не поднимается (профиль relay выключен). Продуктовый Telegram-трафик пойдёт напрямую с Selectel, где теряется примерно каждый четвёртый короткий запрос."
fi
# ── Цели file_sd для Prometheus (#3155) ────────────────────────
# Включатель профиля и цель для Prometheus обязаны стоять в ОДНОМ
# условии. Пока они жили порознь, вышло так: 27.08 профиль alerts
@ -294,27 +268,19 @@ jobs:
AM_TARGETS_FILE=ops/metrics/prometheus/alertmanager_targets.gen.yml
: > "$AM_TARGETS_FILE"
echo "# Файл рендерится деплоем (deploy-metrics.yml), правки руками затрутся." >> "$AM_TARGETS_FILE"
# Сравнение через case, а не "=": PROFILES теперь может быть
# комбинацией через запятую ("alerts,relay") с тех пор, как #3471
# завёл независимый профиль relay — точное равенство строке
# "alerts" сломалось бы молча в тот момент, когда оба профиля
# включены разом.
case ",$PROFILES," in
*,alerts,*)
if [ "$PROFILES" = "alerts" ]; then
echo '- targets: ["alertmanager:9093"]' >> "$AM_TARGETS_FILE"
echo " labels:" >> "$AM_TARGETS_FILE"
echo " host: infra" >> "$AM_TARGETS_FILE"
echo "Prometheus: приёмник alertmanager:9093 прописан в целях."
;;
*)
else
# Пустой список, а НЕ отсутствующий файл: одиночный бинд-маунт
# несуществующего пути docker подменяет каталогом, и Prometheus
# не стартует вовсе.
echo "# Профиль alerts выключен — приёмников нет." >> "$AM_TARGETS_FILE"
echo "[]" >> "$AM_TARGETS_FILE"
echo "Prometheus: профиль alerts выключен — целей нет, это штатно."
;;
esac
fi
# ── read-only роль для датасорса GlitchTip ─────────────────────
# Идемпотентно. Прав на запись не выдаём вовсе: датасорс Grafana
@ -326,43 +292,6 @@ jobs:
COMPOSE_PROFILES="$PROFILES" \
docker compose -p gendesign-metrics -f docker-compose.metrics.yml up -d --remove-orphans
# ── alert-ack / tg-relay: код монтируется с хоста ────────────────
# Тот же класс бага, что у Alertmanager (см. ниже) и Caddyfile:
# `up -d` сравнивает ОПИСАНИЕ сервиса, а не содержимое бинд-маунта.
# alert-ack и tg-relay получают код именно бинд-маунтом файла
# (./ops/metrics/{alert-ack,tg-relay}/app.py:/app/app.py:ro), а не
# сборкой образа — правка app.py оставляет уже запущенный
# контейнер работать на СТАРОМ коде в памяти интерпретатора сколько
# угодно, и `up -d` этого не видит вовсе.
#
# Пойман на проде 12.09.2026: PR #3490 (фикс alert-ack) слился,
# `git reset --hard` обновил файл на диске (grep по новому
# комментарию находил его), а gendesign-alert-ack, запущенный за
# 25 минут до этого, продолжал отвечать по старой логике —
# зелёный деплой, тихо неверное поведение. Починил только ручной
# `docker restart gendesign-alert-ack`. force-recreate здесь —
# замена этому ручному шагу.
#
# case ",$PROFILES," — пересоздаём только если профиль сервиса
# реально включён в ЭТОМ прогоне, иначе force-recreate ругается на
# несуществующий контейнер (сервис не создан вовсе).
case ",$PROFILES," in
*,alerts,*)
COMPOSE_PROFILES="$PROFILES" \
docker compose -p gendesign-metrics -f docker-compose.metrics.yml \
up -d --force-recreate alert-ack
echo "alert-ack: контейнер пересоздан — код монтируется с хоста, up -d его не подхватывает (#3490)."
;;
esac
case ",$PROFILES," in
*,relay,*)
COMPOSE_PROFILES="$PROFILES" \
docker compose -p gendesign-metrics -f docker-compose.metrics.yml \
up -d --force-recreate tg-relay
echo "tg-relay: контейнер пересоздан — код монтируется с хоста, up -d его не подхватывает (#3490)."
;;
esac
# ── Alertmanager: пересоздать, если конфиг перерисовали ─────────
# `up -d` выше СЧИТАЕТ alertmanager неизменившимся: он сравнивает
# описание сервиса, а содержимое бинд-маунта в это сравнение не
@ -415,51 +344,6 @@ jobs:
done
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
# ── Prometheus: конфиг/правила лежат на диске, `up -d` их не
# перечитывает ────────────────────────────────────────────────
# Тот же класс бага, что у Caddyfile и alertmanager.yml выше:
# docker compose сравнивает описание сервиса, а НЕ содержимое
# бинд-маунта, поэтому уже работающий контейнер продолжает жить
# со старым конфигом сколько угодно — на проде дошло до 16 суток
# незамеченными (#3467): lastConfigTime совпадал со startTime
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
#
# У Prometheus, в отличие от Alertmanager (см. комментарий выше),
# /-/reload переоткрывает файлы ПО ПУТИ заново, поэтому новый инод
# после `git reset --hard` подхватывается без пересоздания
# контейнера. --web.enable-lifecycle уже включён в compose ради
# этого шага (см. docker-compose.metrics.yml) — просто раньше
# никто не звал сам reload.
#
# promtool проверяет ОБА файла ДО reload: битый конфиг не должен
# положить работающий Prometheus молчаливым откатом на дефолты.
if docker exec gendesign-prometheus promtool check config /etc/prometheus/prometheus.yml \
&& docker exec gendesign-prometheus sh -c 'promtool check rules /etc/prometheus/rules/*.yml'; then
LAST_CONFIG_BEFORE="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
docker exec gendesign-prometheus wget -q -O /dev/null --post-data='' http://localhost:9090/-/reload
# lastConfigTime обновляется на КАЖДЫЙ успешный reload, даже
# если содержимое конфига не поменялось — значит сравнение
# "было/стало" надёжно ловит и несостоявшийся reload, и
# изменившиеся правила.
LAST_CONFIG_AFTER=""
for i in $(seq 1 10); do
LAST_CONFIG_AFTER="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
[ -n "$LAST_CONFIG_AFTER" ] && [ "$LAST_CONFIG_AFTER" != "$LAST_CONFIG_BEFORE" ] && break
sleep 1
done
if [ -z "$LAST_CONFIG_AFTER" ] || [ "$LAST_CONFIG_AFTER" = "$LAST_CONFIG_BEFORE" ]; then
echo "ОШИБКА: reload Prometheus не подтверждён — lastConfigTime не изменился ($LAST_CONFIG_BEFORE)."
exit 1
fi
echo "Prometheus: конфиг и правила проверены, reload подтверждён ($LAST_CONFIG_BEFORE -> $LAST_CONFIG_AFTER)."
else
echo "ОШИБКА: конфиг/правила Prometheus не проходят promtool — reload НЕ выполнен, работающий Prometheus остаётся на прежнем конфиге."
exit 1
fi
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
agent-apps:
runs-on: ubuntu-latest
@ -535,22 +419,11 @@ jobs:
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml up -d
# Конфиг Alloy — бинд-маунт ОДНОГО файла, а `git reset --hard` выше пишет
# его новым инодом: `up -d` изменения не видит, контейнер держит старый.
METRICS_ROLE=apps \
METRICS_ALLOY_CONFIG=alloy-apps.alloy \
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml up -d --force-recreate alloy
sleep 10
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml ps
[ "$(stat -c %i ops/metrics/alloy/alloy-apps.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }
agent-infra:
runs-on: ubuntu-latest
needs: server
@ -605,18 +478,7 @@ jobs:
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml up -d
# Конфиг Alloy — бинд-маунт ОДНОГО файла, а `git reset --hard` (джоба server)
# пишет его новым инодом: `up -d` изменения не видит, контейнер держит старый.
METRICS_ROLE=infra \
METRICS_ALLOY_CONFIG=alloy-infra.alloy \
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml up -d --force-recreate alloy
sleep 10
METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml ps
[ "$(stat -c %i ops/metrics/alloy/alloy-infra.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }

View file

@ -511,24 +511,12 @@ jobs:
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
# см. frontend/Dockerfile комментарий у соответствующих ARG).
# NEXT_PUBLIC_YM_ID/GA_ID/YANDEX_VERIFICATION/GOOGLE_VERIFICATION —
# ПОКА ПУСТЫЕ: владелец ещё не завёл счётчики Метрики/GA4 и
# мета-теги верификации поисковых консолей. Пустая строка = скрипт
# счётчика НЕ рендерится вообще (контракт фронта, см. тот же
# Dockerfile-комментарий). Когда номера появятся — вписать
# литералом сюда И в retry-блок ниже (оба обязательны, иначе
# ретрай без кеша уедет без счётчика), и это ТРЕБУЕТ пересборки
# образа (build-time bake, не runtime-правка на проде).
build-args: |
NEXT_PUBLIC_BASE_PATH=/trade-in
NEXT_PUBLIC_API_BASE_URL=/trade-in
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
NEXT_PUBLIC_YM_ID=
NEXT_PUBLIC_GA_ID=
NEXT_PUBLIC_YANDEX_VERIFICATION=
NEXT_PUBLIC_GOOGLE_VERIFICATION=
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
@ -552,10 +540,6 @@ jobs:
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
NEXT_PUBLIC_YM_ID=
NEXT_PUBLIC_GA_ID=
NEXT_PUBLIC_YANDEX_VERIFICATION=
NEXT_PUBLIC_GOOGLE_VERIFICATION=
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_FRONTEND }}:latest
@ -1137,31 +1121,7 @@ jobs:
# tradein-mvp/backend/** включает app/tgbot_main.py), никакого
# in-flight state вроде scrape_runs → пересоздаётся безусловно вместе
# с browser/backend/frontend, отдельного graceful-drain не требует.
#
# ── FRONTEND ЗДЕСЬ НЕТ. ЭТО И ЕСТЬ ЛЕЧЕНИЕ #3274 ─────────────────
# Публичный лендинг лежал 3090 с на КАЖДОМ деплое. Причина не в
# том, что подмена контейнера медленная — она занимает полсекунды.
# Причина в том, что `docker compose up -d` со СПИСКОМ сервисов
# работает в две фазы: сначала create (старый контейнер каждого
# сервиса останавливается и УДАЛЯЕТСЯ — иначе занято container_name),
# и только потом start, в порядке зависимостей и с ожиданием их
# условий. Между фазами фронта уже нет, а нового ещё нет.
#
# Замер на проде (docker inspect, 10.09, оба деплоя МЕРЫ):
# пачка сервисов: tradein-backend создан 15:01:40 → запущен
# 15:02:10 = 30 с (и 503 на лендинге в
# 15:01:46/15:01:52/15:02:06 — ровно окно);
# ОДИН сервис: tradein-frontend создан 16:42:17.5 → запущен
# 16:42:18.0 = 0,5 с, 503 в логе нет вообще.
# Тот же двухфазный порядок воспроизведён на стенде по меткам
# .Created/.StartedAt: соседи создаются сразу, стартуют через 41 с.
#
# Поэтому фронт пересоздаётся ОТДЕЛЬНОЙ командой ниже, после этой
# пачки: в его графе один сервис, фазы create и start идут подряд.
# Остаток (~0,5 с) добирает ретрай подключения в Caddy — см.
# снипет (tradein_frontend_retry) в caddy/sites/apps.caddy.
# Гейт на обе половины: scripts/check-frontend-swap-window.py.
SERVICES="browser backend tgbot"
SERVICES="browser backend frontend tgbot"
SCRAPER_STOP_TS=""
scraper_stale=""
if [ "${SCRAPER_RECREATE:-true}" = "true" ]; then
@ -1257,21 +1217,6 @@ jobs:
" || echo "WARNING: startup-reap query failed — orphaned runs (if any) fall back to the 6h zombie reaper"
fi
# (4b) Фронт — ОТДЕЛЬНОЙ командой, один сервис в графе (#3274).
# Обоснование и прод-замеры — у SERVICES выше. Здесь важен ПОРЯДОК:
# эта команда идёт ПОСЛЕ пачки (backend уже поднят — новый SSR сразу
# ходит в новый бэкенд) и ДО `caddy reload` ниже, чтобы reload, как
# и раньше, оставался последним касанием прокси.
#
# ЗАЧЕМ ЖДАТЬ ПОСЛЕ КОМАНДЫ. `up -d` возвращает управление, когда
# контейнер ЗАПУЩЕН, а не когда Next начал слушать (на проде между
# ними ~0,1 с, см. journald «Ready in 110ms», но это не гарантия).
# Полноценная проверка фронта — health-check ниже по файлу, он же
# валит деплой при неудаче; здесь только короткая пауза, чтобы
# ретрай Caddy (2 с) не пришёлся на ещё не слушающий порт.
docker compose -p gendesign-tradein $COMPOSE_FILES up -d --no-deps frontend
sleep 1
# (5) `docker restart tradein-backend` БОЛЬШЕ НЕ НУЖЕН (issue #2216).
# История (PR #493 / deploy 1156): backend раньше поднимался ПЕРЕД
# миграциями, его lifespan-hook (ensure_fdw_user_mapping) падал с

View file

@ -121,98 +121,37 @@ jobs:
infra: ${{ steps.filter.outputs.infra }}
# #2916: правка ТОЛЬКО конфига прокси. `infra` для этого не годится — он
# включает и compose, и сам workflow, где полный деплой обязателен.
caddy_only: ${{ steps.filter.outputs.caddy_only }}
# `github.event_name == 'push'` первым множителем НАМЕРЕННО: на
# workflow_dispatch у paths-filter нет диффа, и любой его ответ не должен
# уметь отключить сборку — ручной прогон обязан оставаться полным.
caddy_only: ${{ github.event_name == 'push' && steps.filter.outputs.caddy == 'true' && steps.filter.outputs.non_caddy == 'false' }}
steps:
- uses: actions/checkout@v4
# ── #3448: список изменённых файлов считаем САМИ ─────────────────────────
#
# ЧТО БЫЛО. Быстрый путь «правка только прокси» (#2916) не отработал НИ
# РАЗУ. Причина — НЕ пустой `event.before`: эта гипотеза опровергнута
# логом задачи 29244 (run 10881, мерж 84920e6c) — `before` там валиден,
# 204e2e09…, и `git diff` вернул ровно один файл. Причина в семантике
# самого фильтра: dorny/paths-filter склеивает шаблоны ОДНОГО фильтра
# через `some`, то есть ИЛИ (src/filter.ts: `patterns.some(aPredicate)`,
# predicate-quantifier по умолчанию `some`). Список
# non_caddy: ['**', '!Caddyfile', '!caddy/**']
# читается не как «всё, КРОМЕ caddy», а как «подходит под `**` ИЛИ не
# Caddyfile ИЛИ не caddy/**». `**` матчит всё, поэтому non_caddy был true
# ВСЕГДА и caddy_only — false всегда. В логе это видно дословно:
# ##[group]Filter non_caddy = true
# Matching files:
# caddy/sites/apps.caddy [modified]
# Исключённый файл сам себя и «исключил». deploy-caddy при этом
# пропускался, а Forgejo рисует пропущенную джобу зелёной — сигнала не
# было ни одного.
#
# ПОЧЕМУ ШЕЛЛ, А НЕ ЗАПЛАТКА К ФИЛЬТРАМ. Разность множеств тут нужна одна
# («все изменения лежат под caddy»), и выражать её действием, у которого
# ИЛИ по умолчанию, — значит снова повесить решение на незаметное
# умолчание: `predicate-quantifier: every` действует на ВЕСЬ блок и
# сломал бы backend/frontend/infra. Плюс два требования #3448: решение
# обязано быть ВИДНО в логе (иначе «сработало» и «просто не совпало»
# неотличимы), и оно не должно молча зависеть от того, что платформа
# кладёт в `before`.
#
# FAIL-SAFE. База не разрешилась (ручной запуск, пустой/нулевой `before`,
# коммита нет на сервере) → считаем изменённым ВЕСЬ репозиторий: лишний
# полный деплой безопаснее пропущенного. Фолбэка на `HEAD^..HEAD` тут
# намеренно нет: у мерж-коммита он дал бы верный ответ, а у push'а из
# нескольких коммитов — молча урезанный, и быстрый путь включился бы
# там, где приехал бэкенд.
- name: Определить изменённые файлы (#3448)
- uses: dorny/paths-filter@v3
id: filter
env:
BEFORE: ${{ github.event.before }}
EVENT: ${{ github.event_name }}
run: |
set -eu
NULL_SHA=0000000000000000000000000000000000000000
BASE=""
if [ "$EVENT" = "push" ] && [ -n "${BEFORE:-}" ] && [ "$BEFORE" != "$NULL_SHA" ]; then
git cat-file -e "${BEFORE}^{commit}" 2>/dev/null \
|| git fetch --depth=1 --no-tags origin "$BEFORE" >/dev/null 2>&1 \
|| true
if git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
BASE="$BEFORE"
else
echo "::warning::коммит $BEFORE недоступен в клоне — деплой будет полным"
fi
fi
if [ -n "$BASE" ]; then
FILES=$(git -c core.quotePath=false diff --no-renames --name-only "$BASE" HEAD)
N=$(printf '%s\n' "$FILES" | grep -c . || true)
echo "База: $BASE → $(git rev-parse HEAD); изменённых файлов: $N"
printf '%s\n' "$FILES" | sed 's/^/ /'
else
FILES=$(git -c core.quotePath=false ls-files)
N=$(printf '%s\n' "$FILES" | grep -c . || true)
echo "База не определена (event=$EVENT, before='${BEFORE:-}') — считаем изменённым весь репозиторий ($N файлов), деплой полный"
fi
# Те же наборы путей, что были в фильтрах до #3448.
CADDY_RE='^(Caddyfile$|caddy/)'
has() { printf '%s\n' "$FILES" | grep -qE "$1"; }
backend=false; frontend=false; infra=false; caddy_only=false
has '^(backend/|data/sql/)' && backend=true
has '^frontend/' && frontend=true
has '^(docker-compose\.prod\.yml$|Caddyfile$|caddy/|\.forgejo/workflows/deploy\.yml$)' && infra=true
# Быстрый путь: изменения ЕСТЬ и НИ ОДНО из них не лежит вне caddy.
# Проверка `N -gt 0` обязательна: пустой список иначе прошёл бы как
# «всё под caddy» и отключил бы сборку на ровном месте.
if [ "$N" -gt 0 ] && ! printf '%s\n' "$FILES" | grep -vE "$CADDY_RE" | grep -q .; then
caddy_only=true
fi
echo "Флаги: backend=$backend frontend=$frontend infra=$infra caddy_only=$caddy_only"
{
echo "backend=$backend"
echo "frontend=$frontend"
echo "infra=$infra"
echo "caddy_only=$caddy_only"
} >> "$GITHUB_OUTPUT"
with:
filters: |
backend:
- 'backend/**'
- 'data/sql/**'
frontend:
- 'frontend/**'
infra:
- 'docker-compose.prod.yml'
- 'Caddyfile'
- 'caddy/**'
- '.forgejo/workflows/deploy.yml'
# Пара фильтров для «правка ТОЛЬКО прокси» (#2916). Одного `caddy`
# мало: он true и когда вместе с конфигом приехал бэкенд — тогда
# нужен обычный полный деплой. `non_caddy` матчит ВСЁ остальное,
# и быстрый путь включается лишь когда он false.
caddy:
- 'Caddyfile'
- 'caddy/**'
non_caddy:
- '**'
- '!Caddyfile'
- '!caddy/**'
build-backend:
runs-on: ubuntu-latest
@ -657,11 +596,6 @@ jobs:
# #3029: подлинность хоста. Секрет НЕ задан → пустая строка → easyssh-proxy
# оставляет ssh.InsecureIgnoreHostKey(), то есть сегодняшнее поведение.
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
# #3324: дефолт appleboy/ssh-action — command_timeout 10m, а worst-case
# гейта в конце скрипта ~17 мин (4 сервиса × 240s ожидания healthy +
# фронт + diagnose). Сессию убило бы посреди печати диагноза, и авария
# выглядела бы обрывом связи, а не мёртвым контейнером.
command_timeout: 30m
envs: IMAGE_TAG,SENTRY_RELEASE_VAL,GHCR_PAT,GLITCHTIP_BACKEND_DSN,OBJECTIVE_API_KEY,OPENAI_API_KEY,LLM_ENABLED,OWN_DEVELOPER_IDS,WORKER_RECREATE_GUARD,WORKER_GUARD_MAX_SKIP_H
script: |
set -euo pipefail
@ -1058,17 +992,12 @@ jobs:
docker compose -p gendesign -f docker-compose.prod.yml up -d \
--force-recreate --no-deps $WORKER_SERVICES
# Caddy: пересоздание ТОЛЬКО когда без него правка не доедет (#3443).
# Здесь стоял безусловный `up -d --force-recreate --no-deps caddy` —
# то есть КАЖДЫЙ полный деплой сносил единственный процесс, слушающий
# 80/443, и все домены хоста отдавали `code=000` (замер 05.09: 67 с).
# Довод той правки (17.05, 11e78d73 — «иначе новые volume mounts не
# появляются») не подтвердился: `up -d` БЕЗ флага пересоздаёт
# контейнер сам, как только меняется описание сервиса или образ.
# Разбор и проверки — в шапке ops/caddy-apply.sh; там же сверка
# пофайловых bind-маунтов (Caddyfile + 4 сниппета держат инод) и
# `caddy validate` до применения.
sh ops/caddy-apply.sh
# Caddy: force-recreate чтобы подхватить изменения в Caddyfile
# И в особенности новые volume mounts из docker-compose.prod.yml
# (`reload` не пересоздаёт container, поэтому новые binds не появляются —
# был случай 2026-05-17 с PR #268 preview/ — потребовался manual SSH fix).
docker compose -p gendesign -f docker-compose.prod.yml up -d \
--force-recreate --no-deps caddy
# Forwarder: force-recreate чтобы новый image / новые env подхватывались.
# Без --force-recreate обычный `up -d` НЕ recreate'ит при image rebuild
@ -1108,171 +1037,6 @@ jobs:
fi
echo "→ backend healthy на /health."
# ── Гейт деплоя (#3324) ────────────────────────────────────────────
# ДО этого блока весь гейт ПТИЦЫ = один `curl backend /health` выше:
# worker/beat не проверялись вообще (у них и healthcheck'а в compose не
# было), у frontend была только TCP-проба внутри контейнера, которую
# деплой не читал. То есть crash-loop воркера, вставший beat и фронт,
# отдающий 500, уезжали ЗЕЛЁНЫМ деплоем. Дисциплина перенесена из
# deploy-tradein.yml (health каждого сервиса + HTTP фронта + сверка
# образов), сюда добавлено чтение docker-health, потому что у ПТИЦЫ
# пробы теперь описаны в compose.
# `up -d --wait` НЕ используется намеренно: подъём здесь разбит на
# несколько `up` (bulk без worker'а → guard #3029 → caddy → forwarder),
# и общий --wait ждал бы ещё и профильные/инфраструктурные сервисы,
# ломая порядок «миграции до подъёма кода». Читаем состояние явно.
# Все проверки выполняются ДО выхода (не падаем на первой) — один
# прогон обязан показать ВСЕ поломанные сервисы, а не первый по списку.
# tail -n1: у сервиса может остаться залежавшийся exited-контейнер, и
# тогда `ps -aq` вернёт НЕСКОЛЬКО id через \n — `docker inspect` с таким
# аргументом падает, статус приходит пустым и гейт краснеет на ровном
# месте. Последний id — самый свежий контейнер сервиса.
cid() { docker compose -p gendesign -f docker-compose.prod.yml ps -aq "$1" 2>/dev/null | tail -n1 || true; }
diagnose() { # $1 сервис, $2 id контейнера (может быть пустым)
local svc="$1" c="$2"
echo "── ДИАГНОЗ $svc ──"
if [ -z "$c" ]; then
echo " контейнера нет вообще (docker compose ps -aq $svc пусто)"
return 0
fi
docker inspect -f ' state={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}<healthcheck не сконфигурирован>{{end}} restarts={{.RestartCount}} exit_code={{.State.ExitCode}} image={{.Image}}' "$c" || true
echo " healthcheck log:"
docker inspect -f '{{json .State.Health}}' "$c" 2>/dev/null | head -c 2000 || true
echo ""
echo " последние 40 строк логов $svc:"
docker logs --tail 40 "$c" 2>&1 | sed 's/^/ /' || true
}
wait_healthy() { # $1 сервис, $2 таймаут, с
local svc="$1" deadline="$2" c="" status="" alive="" r0="" r1="" waited=0
while [ "$waited" -lt "$deadline" ]; do
c="$(cid "$svc")"
if [ -n "$c" ]; then
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none:{{.State.Status}}{{end}}' "$c" 2>/dev/null || echo '')"
case "$status" in
healthy)
echo "→ $svc healthy (за ${waited}s)"
return 0
;;
none:running)
# Контейнер без healthcheck-конфига = создан ДО этой правки
# compose и в этом прогоне не пересоздавался (штатный случай —
# worker, пропущенный guard'ом #3029). Валить деплой за это
# нельзя, но и молчать нельзя: падаем на «стабильный running»
# (двойное чтение, как tgbot/scraper в deploy-tradein.yml).
# Одного `running` дважды НЕДОСТАТОЧНО: crash-loop с временем
# жизни больше паузы читается как «стабилен» — контейнер оба
# раза running, просто это разные его жизни. Поэтому вместе со
# статусом сверяем RestartCount: изменился за окно = именно
# тот дефект, ради которого этот гейт и писался.
r0="$(docker inspect -f '{{.RestartCount}}' "$c" 2>/dev/null || echo '')"
sleep 15
alive="$(docker inspect -f '{{.State.Status}}' "$c" 2>/dev/null || echo unknown)"
r1="$(docker inspect -f '{{.RestartCount}}' "$c" 2>/dev/null || echo '')"
if [ "$alive" = "running" ] && [ -n "$r0" ] && [ "$r0" = "$r1" ]; then
echo "→ $svc: healthcheck не сконфигурирован (контейнер не пересоздавался), running стабилен (RestartCount=$r0 не изменился за 15s)"
return 0
fi
# waited растёт на длину ЭТОЙ паузы тоже — иначе таймаут
# 240s превратился бы в ~24 минуты реального ожидания и упёрся
# бы в command_timeout SSH-сессии.
waited=$((waited + 15))
echo " $svc: running нестабилен — status='$alive', RestartCount ${r0:-<нет>}→${r1:-<нет>} (контейнер перезапускался внутри окна наблюдения); продолжаю ждать"
;;
esac
fi
waited=$((waited + 3))
sleep 3
done
echo "ERROR (#3324): $svc не стал healthy за ${deadline}s (последний статус: '${status:-<контейнера нет>}') — деплой FAILED"
diagnose "$svc" "$c"
return 1
}
health_rc=0
for gate_svc in backend worker beat frontend; do
wait_healthy "$gate_svc" 240 || health_rc=$?
done
# Фронт: HTTP-СТАТУС, а не только «порт слушает». Compose-проба фронта
# намеренно TCP-only (в node:alpine нет ни curl, ни wget), и она не
# отличает живой Next.js от процесса, отдающего 500 на каждый запрос.
# Тянем с хоста через опубликованный 127.0.0.1:3000. basePath у ПТИЦЫ
# нет (frontend/next.config.*), корень — настоящий маршрут приложения.
# Годным считаем 2xx/3xx: редирект middleware'а на логин — это живой
# роутинг, а не поломка (тот же критерий, что `curl -f` в tradein).
fe_rc=0
fe_code=000
for i in $(seq 1 5); do
fe_code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://localhost:3000/ || echo 000)"
case "$fe_code" in
2*|3*) break ;;
esac
sleep 3
done
case "$fe_code" in
2*|3*) echo "→ frontend отвечает HTTP $fe_code на /." ;;
*)
echo "ERROR (#3324): frontend на http://localhost:3000/ вернул '$fe_code' (000 = соединения нет) — деплой FAILED"
diagnose frontend "$(cid frontend)"
fe_rc=1
;;
esac
# Сверка образов (приём #2679 из deploy-tradein.yml, адаптирован под
# ПТИЦУ). Здесь не одно «backend-семейство»: backend и beat бегут один
# образ gendesign-backend, worker и frontend — свои. Поэтому эталон не
# «образ backend'а», а то, что реально лежит локально под тегом
# $IMAGE_TAG после pull'а: контейнер, оставшийся на другом id, работает
# на старом коде при зелёном деплое.
# Гард свежести самого :latest в registry — отдельный шаг выше
# (scripts/check-latest-image-revision.sh, #2950); здесь проверяется
# следующее звено: доехал ли уже скачанный образ до контейнера.
check_image() { # $1 сервис, $2 репозиторий образа
local svc="$1" repo="$2" want run c
want="$(docker image inspect -f '{{.Id}}' "$repo:$IMAGE_TAG" 2>/dev/null || echo '')"
c="$(cid "$svc")"
run="$(docker inspect -f '{{.Image}}' "$c" 2>/dev/null || echo '')"
if [ -z "$want" ]; then
echo "ERROR (#3324): локально нет образа $repo:$IMAGE_TAG — сверять не с чем (pull не отработал?)"
return 1
fi
if [ -z "$run" ]; then
echo "ERROR (#3324): контейнера сервиса $svc НЕТ — это не «отставший образ», а неполный стек"
return 1
fi
if [ "$want" != "$run" ]; then
echo "ERROR (#3324): $svc ОТСТАЛ: работает на $run, а $repo:$IMAGE_TAG — это $want"
echo " лечение: docker compose -p gendesign -f docker-compose.prod.yml up -d --force-recreate --no-deps $svc"
diagnose "$svc" "$c"
return 1
fi
echo "→ $svc на свежем $repo:$IMAGE_TAG ($run)"
}
image_rc=0
check_image backend ghcr.io/lekss361/gendesign-backend || image_rc=$?
check_image beat ghcr.io/lekss361/gendesign-backend || image_rc=$?
check_image frontend ghcr.io/lekss361/gendesign-frontend || image_rc=$?
# worker сверяем ТОЛЬКО если этот прогон его пересоздавал: guard #3029
# намеренно оставляет worker на старом образе, пока идёт живой прогон
# скрейпа, и это уже отражено WARNING'ом выше. Падать здесь означало бы
# красить деплой за штатное поведение guard'а.
case " $WORKER_SERVICES " in
*" worker "*) check_image worker ghcr.io/lekss361/gendesign-worker || image_rc=$? ;;
*) echo "→ сверка образа worker'а пропущена: guard #3029 не пересоздавал его в этом прогоне (см. WARNING выше)" ;;
esac
# Явный rc: «зелёная сводка» ниже печатается ДО выхода, поэтому итог
# обязан быть числом в логе, а не выводом из отсутствия ERROR-строк.
gate_rc=0
[ "$health_rc" = 0 ] || gate_rc=1
[ "$fe_rc" = 0 ] || gate_rc=1
[ "$image_rc" = 0 ] || gate_rc=1
echo "Гейт деплоя (#3324): health_rc=$health_rc frontend_rc=$fe_rc image_rc=$image_rc → rc=$gate_rc"
exit "$gate_rc"
# Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:`
# молча (result=skipped), когда build падает (например, битый blob в
# buildcache роняет `docker/build-push-action` — до ретрая выше, #2841).
@ -1295,13 +1059,14 @@ jobs:
# Публичный периметр МЕРЫ живёт в этом файле и будет меняться часто: новая
# страница = новая строка allowlist'а.
#
# ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Опечатка в конфиге на
# пересоздании уводит контейнер в crash-loop и роняет ВСЕ домены сразу, а
# `caddy reload` её просто не принимает: job краснеет, домены продолжают
# обслуживаться прежним конфигом. С #3443 ровно тот же порядок действует и на
# полном деплое — оба пути зовут ops/caddy-apply.sh, который сперва проверяет
# конфиг одноразовым контейнером и пересоздаёт Caddy, только если правка иначе
# не доедет (пофайловый bind-маунт держит инод).
# ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Полный деплой
# осознанно пересоздаёт контейнер (комментарий в ci.yml: `reload` отказался бы
# принять битый конфиг и оставил бы работать старый — на общем деплое это
# скрыло бы поломку). Здесь наоборот: правится ТОЛЬКО конфиг, и отказ
# применить битый — ровно то, что нужно. `caddy reload` возвращает ненулевой
# код → job краснеет, а домены продолжают обслуживаться старым конфигом.
# Альтернатива (`--force-recreate`) на опечатке уводит контейнер в crash-loop
# и роняет ВСЕ домены сразу.
#
# Гейт `caddy validate` на PR (#2913) остаётся первой линией; этот шаг —
# вторая, уже против боевого файла после `git reset`.
@ -1347,73 +1112,14 @@ jobs:
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
script: |
set -euo pipefail
# #3448: ТОТ ЖЕ ЛОК, что берёт полный деплой (см. job `deploy` выше).
# Эта джоба делает `git reset --hard` в /opt/gendesign, то есть правит
# прод-дерево — ровно то, что полный деплой сериализует локом. Пока
# быстрый путь был мёртв, столкнуться было нечему; теперь есть.
exec 9>/var/lock/gendesign-docker-deploy.lock
if flock -n 9; then
echo "→ докер-лок свободен, взят сразу"
else
echo "→ докер-лок занят соседним деплоем, жду (до 900с)…"
lock_wait_started=$(date +%s)
if ! flock -w 900 9; then
echo "ERROR: не дождался лока докер-деплоя за 900с."
echo " Кто держит: ssh на хост, затем fuser -v /var/lock/gendesign-docker-deploy.lock"
exit 1
fi
echo "→ докер-лок получен через $(( $(date +%s) - lock_wait_started ))с ожидания"
fi
cd /opt/gendesign
git fetch origin main
# ── #3448: быстрый путь законен, только если прод отстаёт РОВНО на
# конфиг прокси ────────────────────────────────────────────────────
#
# Джоба `changes` считает дифф between-push (before→HEAD) и не знает,
# что доехало до прода. Пока caddy_only был мёртв, любой push шёл
# полным деплоем и гард свежести :latest (#2950, job `deploy`)
# прикрывал прод по умолчанию. Оживший быстрый путь этот гард
# обходит: при caddy_only=true джоба `deploy` пропускается целиком.
#
# Сценарий отказа: push A правит бэкенд, билды ~6 мин, `deploy` в
# очереди; через 2 мин push B правит только caddy/. Forgejo на
# 10.0.3 отменяет ещё не стартовавший `deploy` предыдущего прогона
# ДАЖЕ при cancel-in-progress: false (наблюдение 21.08.2026 10:35:13,
# см. шапку scripts/check-latest-image-revision.sh). Дифф A..B — один
# caddy-файл, быстрый путь включается, `compose pull` + `up -d` не
# делает никто: прод крутит старый образ при зелёной голове main.
#
# Единственный источник правды о том, что реально на проде, — HEAD
# прод-дерева (у Trade-In для этого заведён отдельный маркер
# /opt/gendesign/.tradein-deployed-sha, см. deploy-tradein.yml:150;
# у ПТИЦЫ маркера нет, но git reset ниже делает HEAD эквивалентом).
# Проверка стоит ДО reset намеренно: при отказе прод-HEAD остаётся
# честным для следующего прогона.
#
# ЧЕГО ЭТА ПРОВЕРКА НЕ ЛОВИТ: `deploy` прогона A, упавшую ПОСЛЕ
# `git reset --hard` (например на миграции). Тогда прод-HEAD уже
# равен A, а контейнеры старые, и caddy-only push пройдёт быстрым
# путём. Это остаётся за настоящим маркером «что задеплоено».
PROD_HEAD=$(git rev-parse HEAD)
OUTSIDE=$(git -c core.quotePath=false diff --name-only "$PROD_HEAD" origin/main | grep -vE '^(Caddyfile$|caddy/)' || true)
if [ -n "$OUTSIDE" ]; then
echo "::error::прод отстаёт не только по конфигу прокси — быстрый путь запрещён:"
printf '%s\n' "$OUTSIDE" | sed 's/^/ /'
echo "Запусти полный деплой через workflow_dispatch."
exit 1
fi
git reset --hard origin/main
# #3443: тот же скрипт, что и в полном деплое. Голый `exec caddy
# reload` здесь был ВЕРЕН только для каталогов (caddy/sites/**,
# caddy/local/**). Caddyfile и четыре сниппета смонтированы
# ПОФАЙЛОВО, а `git reset --hard` выше пишет новый инод — контейнер
# остаётся на прежнем, и reload перечитывает СТАРЫЙ текст. Отказ
# беззвучный: джоба зелёная, конфиг на диске новый, прокси работает
# по старому. Скрипт сверяет, что именно видит контейнер, и
# пересоздаёт его только в этом случае.
sh ops/caddy-apply.sh
echo "✓ быстрый путь завершён: без пересборки образов и без миграций"
# Конфиг примонтирован read-only с хоста, пересборка не нужна —
# контейнер читает тот же файл, что только что обновил git.
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
echo "✓ конфиг прокси перезагружен без пересборки и без миграций"
# ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ──────────────────
#

View file

@ -35,35 +35,7 @@ concurrency:
jobs:
smoke:
runs-on: ubuntu-latest
# 11.09.2026: было 5 минут — теперь мало. В скрипте появились пауза между
# проверками (2 c) и повтор запроса, если ответа не пришло вовсе: обычный
# прогон вырос с ~89 c до ~175 c, а ХУДШИЙ случай — гораздо больше, потому
# что каждая неотвечающая проверка стоит до 3×15 c таймаута плюс паузы
# (~53 c против обычных ~2 c).
#
# 12.09.2026: 10 минут — тоже мало, и мало ровно в том сценарии, ради
# которого повтор писался. АРИФМЕТИКА ХУДШЕГО СЛУЧАЯ. Одна неотвечающая
# проверка сетевого класса = 3×15 c таймаута + 2 c и 4 c пауз ретрая + 2 c
# паузы между проверками = 53 c. Прод не отвечает целиком (DNS не
# резолвится, вход лежит) — мертвы ВСЕ проверки: 43 × 53 = 2279 c ≈ 38 мин.
# Откуда 43 (замер 12.09, зелёный прогон против прода — 43 PASS за 167 c):
# 42 обычные проверки + отдельная загрузка HTML лэндинга; 43-я, производный
# layout-чанк, при мёртвом ответе не запрашивается вовсе — запросов ровно
# столько же.
# В 10 минут помещалось ~8 мёртвых проверок из 43, дальше job убивали ДО
# печати FAIL-строк и итога — то есть лог терялся при полном отказе прода.
#
# Правка «повторяем только сетевой класс» (12.09) худший случай НЕ
# уменьшает: 15-секундный таймаут как раз сетевой (rc=28) и повторяется
# по-прежнему. Она удешевляет ДРУГОЙ сценарий — протухший/чужой сертификат
# (rc=60): отказ приходит сразу и без повторов. Замер 12.09 на
# expired.badssl.com, одна проверка при SMOKE_PAUSE=0 — 23 c на прежней
# голове (3 попытки + 6 c пауз) против <1 c теперь.
#
# 40 минут = 38 мин худшего случая + запас на чекаут и разброс сети.
# Цена промаха несимметрична: занятый раннер стоит дёшево (прогон daily +
# on-push), потерянный лог при полном отказе прода — дорого.
timeout-minutes: 40
timeout-minutes: 5
steps:
- name: Checkout repo

7
.gitignore vendored
View file

@ -109,10 +109,3 @@ ops/metrics/alertmanager/alertmanager.yml
# и Prometheus не видел ни одного приёмника (#3155). Производный файл убирает
# сам зазор — правится только там же, где принимается решение о профиле.
ops/metrics/prometheus/alertmanager_targets.gen.yml
# Временные рабочие копии-worktree вида _wt-<тема>/ живут рядом с репозиторием
# и в него попадать не должны. 09.09 копия _wt-mskcol попала в индекс одним
# файлом сборщика, и три следующих фикса ушли в дубликат мимо канонического
# tradein-mvp/scripts/local-avito-msk/collect.py — дефект нашёлся только при
# сверке page size.
_wt-*/

View file

@ -50,27 +50,30 @@
# ничего не меняется. В окне: на Selectel CADDY_SITES=apps, на Beget=infra.
import caddy/sites/{$CADDY_SITES:*}.caddy
# Plain HTTP by IP. /health остаётся публичным (liveness). Всё остальное —
# РЕДИРЕКТ на канонический HTTPS, а не проксирование под basic_auth.
#
# ЗДЕСЬ СТОЯЛ auth-гейт с проксированием приложения — «закрыть обход через
# голый IP тем же гейтом». Замысел верный, исполнение — дыра: Basic-challenge
# на plain HTTP означает, что браузер отправит пароль пилота ОТКРЫТЫМ ТЕКСТОМ
# любому, кто слушает канал (аудит 02.09.2026: curl http://<IP>/api/v1/me →
# 401 + Www-Authenticate: Basic realm="GenDesign Pilot"). Редирект строже
# гейта: по HTTP не отдаётся ни контент, ни сам запрос пароля, обход через
# IP закрыт тем, что отвечать нечему. Потребителей у IP:80 нет: все
# deploy-смоки ходят docker exec → localhost внутри контейнеров (проверено
# grep-ом по .forgejo/workflows и ops/ 02.09.2026).
# Plain HTTP by IP — closed by same auth gate (prevent bypass via direct IP / SSH tunnel).
# Caddy issues no TLS here (no hostname). /health remains public.
:80 {
encode zstd gzip
route {
# /health — public, без auth (liveness probe).
# /health — public, без auth (GHA deploy smoke check, liveness probe).
handle /health {
reverse_proxy backend:8000
}
# Auth gate (same snippet as gendsgn.ru).
import caddy/users.caddy.snippet
handle /api/* {
reverse_proxy backend:8000 {
header_up X-Authenticated-User {http.auth.user.id}
}
}
handle {
redir https://gendsgn.ru{uri} permanent
reverse_proxy frontend:3000 {
header_up X-Authenticated-User {http.auth.user.id}
}
}
}
}

View file

@ -133,11 +133,6 @@ users:
# продукта; ранее expired с 2026-06-27). Безлимитная квота оценок
# выдана через account_quota_overrides.unlimited (migration 191),
# не через код — см. app.services.account_quota.is_unlimited.
buyer1: pilot # Тестовый доступ потенциального покупателя — заведён 2026-09-02 по
# просьбе владельца. Квота 50 оценок/мес через
# account_quota_overrides.monthly_limit (не unlimited). DB-роль
# manager (как praktika/kopylov — самостоятельный внешний аккаунт,
# не employee под чьим-то manager_id).
admintest: admin # temp QA 2026-05-26
pilottest: pilot # temp QA 2026-05-26
analysttest: analyst # temp QA 2026-06-07 (#962)

View file

@ -28,16 +28,10 @@ RUN pip install --no-cache-dir uv
WORKDIR /app
# Без глоба и без фолбэка: uv.lock ОБЯЗАТЕЛЕН. `uv.lock*` + `if [ -f uv.lock ]`
# означали, что пропавший лок не ломает сборку, а тихо переключает её на резолв
# «свежайшее из диапазонов pyproject» — образ собирался бы с версиями, которых
# никто не видел ни в одном PR, и воспроизвести прод-сборку было бы нечем.
# Теперь пропажа лока = падение COPY, то есть красный билд вместо незаметной
# подмены зависимостей.
COPY pyproject.toml uv.lock ./
COPY pyproject.toml uv.lock* ./
# uv-кеш переживает между билдами: при неизменном lock'е sync ~5 сек вместо 1-2 мин.
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
if [ -f uv.lock ]; then uv sync --frozen --no-dev; else uv sync --no-dev; fi
COPY app ./app
COPY alembic.ini ./

View file

@ -43,12 +43,6 @@ def run_migrations_online() -> None:
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
# #3194: SQLAlchemy печатает ВСЕ bind-параметры в тексте StatementError.
# Миграции гоняют DDL/DML с литералами и параметрами из данных — флаг
# на уровне движка не даёт им уехать в GlitchTip.
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со
# значением) и сырые psycopg-подключения мимо движков.
hide_parameters=True,
)
with connectable.connect() as connection:
context.configure(

View file

@ -17,7 +17,6 @@ from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.observability.metrics import REPORTS_EXPORTED
from app.schemas.parcel import (
AnalysisRunDetail,
AnalysisRunListResponse,
@ -151,6 +150,13 @@ NOISE_L_BASE: dict[str, float] = {
}
def _wind_label(deg: float) -> str:
"""Перевести угол направления ветра (0-360) в 8-позиционную розу на русском."""
rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"]
idx = round(deg / 45) % 8
return rose[idx]
# Координаты центра ЕКБ — Площадь 1905 года
EKB_CENTER_LAT: float = 56.838011
EKB_CENTER_LON: float = 60.597474
@ -1613,11 +1619,6 @@ def export_parcel_forecast(
if run is None:
raise HTTPException(status_code=404, detail="прогноз ещё не посчитан")
# #3471: считаем выгрузку здесь, а не в каждой format-ветке ниже — рано
# (до самого рендера), зато один раз на весь запрос и без риска разъехаться
# с новой веткой формата, если её когда-нибудь добавят.
REPORTS_EXPORTED.labels(format=format).inc()
# tg — INLINE сниппет (не файл): краткая сводка для копипаста в Telegram, без attachment.
if format == "tg":
return Response(
@ -4917,7 +4918,6 @@ async def get_parcel_best_layouts_pdf(
today = _dt.date.today().strftime("%Y-%m-%d")
cad_safe = cad_num.replace(":", "-")
filename = f"tz-layout-{cad_safe}-{today}.pdf"
REPORTS_EXPORTED.labels(format="best_layouts_pdf").inc()
return Response(
content=pdf_bytes,
media_type="application/pdf",

View file

@ -139,13 +139,6 @@ def _build() -> tuple[Engine, sessionmaker[Session]]:
future=True,
pool_timeout=3,
connect_args={"connect_timeout": 3, "options": "-c statement_timeout=3000"},
# #3194: SQLAlchemy печатает ВСЕ bind-параметры в тексте StatementError —
# через них в GlitchTip уезжали ключ шифрования кук и сами куки
# (pgp_sym_encrypt(:cookies_json, :key)). Флаг на УРОВНЕ ДВИЖКА кроет все
# сайты вызова разом, включая будущие.
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
# и сырые psycopg-подключения мимо движков — это отдельный класс.
hide_parameters=True,
)
except (ArgumentError, ValueError):
# ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до

View file

@ -5,18 +5,7 @@ from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.core.config import settings
engine = create_engine(
settings.database_url,
pool_pre_ping=True,
future=True,
# #3194: SQLAlchemy печатает ВСЕ bind-параметры в тексте StatementError —
# через них в GlitchTip уезжали ключ шифрования кук и сами куки
# (pgp_sym_encrypt(:cookies_json, :key)). Флаг на УРОВНЕ ДВИЖКА кроет все
# сайты вызова разом, включая будущие.
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
# и сырые psycopg-подключения мимо движков — это отдельный класс.
hide_parameters=True,
)
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)

View file

@ -100,19 +100,6 @@ BUILD_INFO.labels(
release=os.getenv("SENTRY_RELEASE") or os.getenv("IMAGE_TAG") or "unknown",
).set(1)
# ═══ ПРОДУКТОВЫЕ СЧЁТЧИКИ (#3471) ═══════════════════════════════════════════
#
# `format` — фиксированный литерал из сигнатуры эндпоинта (Literal["md", "json",
# "tg", "docx", "pptx", "pdf"] в `export_parcel_forecast` + одно статичное
# значение "best_layouts_pdf" из ТЗ-на-проектирование), НЕ произвольная строка —
# кардинальность ограничена набором форматов экспорта, а не количеством
# участков/пользователей.
REPORTS_EXPORTED = Counter(
"sitefinder_reports_exported_total",
"Экспортов отчётов по участку (§22-форсайт, ТЗ на проектирование), по формату",
labelnames=("format",),
)
def route_label(scope: Scope) -> str:
"""Шаблон маршрута из ASGI-scope, либо ``__unmatched__``.

View file

@ -6,6 +6,29 @@ from pydantic import BaseModel, ConfigDict, Field
# ── #105 Phase 5: Recent permits schemas ──────────────────────────────────────
class RecentPermit(BaseModel):
"""Одно строительное разрешение (РНС или РВЭ) из ekburg_construction_permits."""
permit_type: str
permit_number: str
issue_date: str | None
developer_name: str | None
developer_inn: str | None
object_name: str | None
object_type: str | None
construction_address: str | None
total_area_sqm: float | None
class PermitsSummary(BaseModel):
"""Агрегированная сводка по разрешениям в квартале."""
rns_count: int
rve_count: int
rns_total_area_sqm: float
by_developer: list[dict[str, Any]]
# ── Connection points schemas (issue #115) ────────────────────────────────────
@ -541,6 +564,16 @@ class DeveloperAttributionResult(BaseModel):
# ── Layout analysis (Issue #113) ───────────────────────────────────────────
class LayoutSignature(BaseModel):
"""Минимальная сигнатура планировки = (room_bucket, area_bin).
Phase 2.1: layout_type/balcony_count в БД нет, ждут B2B Объектив (#52).
"""
room_bucket: Literal["studio", "1", "2", "3", "4+"]
area_bin: Literal["<25", "25-40", "40-60", "60-80", "80-100", "100+"]
class BestLayoutsRequest(BaseModel):
"""Параметры запроса top-планировок в радиусе вокруг участка."""

View file

@ -181,3 +181,13 @@ def upsert_documents(db: Session, obj_id: int, docs: list[dict[str, Any]]) -> tu
# ── Stub for future Celery download task ──────────────────────────────────────
def download_document_stub(obj_id: int, doc_id: int, file_url: str) -> None:
"""Placeholder для будущей Celery-задачи download_domrf_documents.
Скачивает PDF, сохраняет в data/raw/domrf_docs/{obj_id}/{filename},
обновляет domrf_kn_documents.local_path + downloaded_at.
Реализация отдельный PR (future task).
"""
raise NotImplementedError("PDF download not implemented in 22i. See issue #297 future PR.")

View file

@ -1807,6 +1807,55 @@ async def fetch_flats_for_object(sess: BrowserSession, obj_id: int) -> list[dict
return _flatten_table(payload)
async def probe_endpoint(
url_or_path: str,
region_code: int = 66,
headed: bool = False,
load_state: str | None = None,
) -> dict[str, Any]:
"""Quick one-shot probe through a real Playwright session for debugging.
Returns {status, content_type, body_preview, full_url}. Use to verify that
a URL works in our session before/after a failing scraper run.
"""
if url_or_path.startswith("http"):
# split into path+query for BrowserSession.get_json
from urllib.parse import parse_qsl, urlsplit
s = urlsplit(url_or_path)
path = s.path
params = dict(parse_qsl(s.query, keep_blank_values=True))
else:
path = url_or_path.split("?", 1)[0]
params = {}
if "?" in url_or_path:
from urllib.parse import parse_qsl
params = dict(parse_qsl(url_or_path.split("?", 1)[1], keep_blank_values=True))
async with BrowserSession(
region_code=region_code, headed=headed, load_state=load_state
) as sess:
try:
payload = await sess.get_json(path, params)
body = json.dumps(payload, ensure_ascii=False)[:2000]
qs = "&".join(f"{k}={v}" for k, v in params.items())
full_url = f"{BASE_URL}{path}" + (f"?{qs}" if qs else "")
return {
"status": 200,
"content_type": "application/json",
"body_preview": body,
"full_url": full_url,
}
except Exception as e:
return {
"status": _http_status_of(e),
"content_type": "error",
"body_preview": str(e)[:2000],
"full_url": f"{BASE_URL}{path}",
}
def log_progress(
db: Session,
run_id: int,

View file

@ -133,6 +133,21 @@ def fetch_geoportal(
raise NspdLiteError(f"Network error: {e}") from e
def fetch_quarter(quarter_cad_num: str, **kwargs) -> dict[str, Any]:
"""Конкретный кадастровый квартал (typed wrapper)."""
return fetch_geoportal(quarter_cad_num, thematic_id=THEMATIC["quarter"], **kwargs)
def fetch_parcel(parcel_cad_num: str, **kwargs) -> dict[str, Any]:
"""Конкретный участок ЗУ (typed wrapper)."""
return fetch_geoportal(parcel_cad_num, thematic_id=THEMATIC["parcel"], **kwargs)
def fetch_building(building_cad_num: str, **kwargs) -> dict[str, Any]:
"""Конкретное здание ОКС (typed wrapper)."""
return fetch_geoportal(building_cad_num, thematic_id=THEMATIC["building"], **kwargs)
# ── Bulk через rosreestr2coord library ──────────────────────────────────────

View file

@ -28,7 +28,7 @@ import logging
import time
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import date
from datetime import date, datetime
from typing import Any
import httpx
@ -557,3 +557,36 @@ class ObjectiveClient:
# ── удобный one-shot helper ─────────────────────────────────────────────────
def fetch_with_raw_log(
fn_name: str,
*,
save_dir: str | None = None,
**kwargs: Any,
) -> tuple[Any, str | None]:
"""Вызывает client.<fn_name>(**kwargs), сохраняет raw JSON в файл если задан save_dir.
Возвращает (data, file_path or None). Удобно для smoke-сценариев и backfill."""
from pathlib import Path
client = ObjectiveClient()
try:
method = getattr(client, fn_name)
data = method(**kwargs)
finally:
client.close()
file_path: str | None = None
if save_dir:
Path(save_dir).mkdir(parents=True, exist_ok=True)
ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
slug = (
"_".join(str(v) for k, v in kwargs.items() if k in ("group_name", "complex_name") and v)
or "all"
)
slug = slug.replace(" ", "_").replace("/", "_")
file_path = f"{save_dir}/objective_{fn_name}_{slug}_{ts}.json"
Path(file_path).write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return data, file_path

View file

@ -246,19 +246,22 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
reserve_unit = 'МВт',
installed_capacity_mva = :installed,
district = :district,
-- #2464-B: сюда БОЛЬШЕ НЕ пишем степень загрузки:
-- load_index категориальная колонка, её заполняет
-- rosseti_wfs_loader._map_load_index. Историю см. в
-- git log этого файла.
--
-- ВАЖНО: в этом комментарии НЕЛЬЗЯ упоминать
-- бинд-параметры в синтаксисе «двоеточие + имя».
-- SQLAlchemy text() парсит бинды и внутри
-- SQL-комментариев: упоминание снятого параметра
-- «(двоеточие)load_pct» в тексте комментария
-- сделало его ОБЯЗАТЕЛЬНЫМ, все 71 UPDATE падали
-- с 18.08 по 02.09, а per-row except глотал это
-- как «битую строку» задача оставалась зелёной.
-- #2464-B: сюда БОЛЬШЕ НЕ пишем степень загрузки.
-- load_index категориальная колонка
-- ('open'|'limited'|'closed'|NULL, см.
-- data/sql/180_connection_capacity.sql:35), её
-- заполняет rosseti_wfs_loader._map_load_index.
-- Раньше тут стоял COALESCE(load_index,
-- CAST(:load_pct AS text)) при пустой ячейке
-- в колонку легло бы число строкой ("72.5"),
-- а фронтовый classifyLoadIndex такое значение
-- отбрасывает в null («неизвестно»), и в
-- power_summary.by_load_index появился бы
-- бакет с именем "72.5".
-- Сегодня не стреляло только потому, что у всех
-- 3416 строк load_index уже заполнен
-- (open 2741 / limited 346 / closed 329, NULL 0)
-- и COALESCE не проваливался.
capacity_source = 'eesk_35_220',
reserve_asof = :asof
WHERE sc_name_norm = :name_norm

View file

@ -61,6 +61,18 @@ _KRT_AT_SQL = text("""
""")
def _functional_zone_at(
client: EKBGeoportalClient, lon: float, lat: float
) -> dict[str, Any] | None:
"""Функц.зона генплана (#1058) в точке — properties первой WFS-фичи; None при сбое/пусто."""
try:
feats = client.features_at_point("functional_zone", lon, lat)
except Exception as exc: # внешний геопортал, graceful
logger.warning("ird_analyze: functional_zone WFS failed: %s", exc)
return None
return feats[0].properties if feats else None
def _krt_at(db: Session, lon: float, lat: float) -> list[dict[str, Any]]:
"""КРТ-объекты (#1060/#1130) в точке — из БД ekb_krt_geometry.

View file

@ -15,6 +15,7 @@ Graceful: нет таблицы / пустой WKT / исключение БД
from __future__ import annotations
import logging
import re
from typing import Any
from sqlalchemy import text
@ -97,6 +98,12 @@ _PLANNING_NO_MATCH_SQL = text(
)
def _extract_paga_num(full_name: str) -> str | None:
"""Извлечь номер ПАГЕ из заголовка planning_project.full_name."""
m = re.search(r"ПАГЕ\s*№\s*(\d+)\s+от", full_name)
return m.group(1) if m else None
def parcel_krt_requisites(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]:
"""КРТ-реквизиты для участка: застройщик / цена / градпотенциал.

View file

@ -19,7 +19,6 @@ per-row SAVEPOINT при UPSERT (битая фича не валит weekly-sync
import hashlib
import json
import logging
import math
import re
import httpx
@ -102,47 +101,20 @@ def parse_voltage_class(name: str | None) -> str | None:
return m.group(1).replace(".", "/")
def _coord_e5(value: float | None) -> str:
"""Координата → целое в единицах 1e-5 градуса (~1 м), полукруглением от нуля.
Целое, а не форматированный float: ключ обязан совпадать байт-в-байт с SQL-
бэкфиллом (99c), а текстовое представление double в питоне и в PG разное.
Округление ДВОИЧНОЕ (по значению double, не по десятичному представлению):
64.423605*1e5 == 6442360.499999999 6442360, хотя «по десятичному» было бы
6442361. В 99c та же семантика: floor/abs/sign над float8, БЕЗ каста в numeric
(каст округляет по кратчайшему десятичному repr и расходится в 0.19% координат).
"""
if value is None:
return ""
n = math.floor(abs(value) * 100000 + 0.5)
return str(-n if value < 0 else n)
def _stable_external_id(feature: dict, props: dict) -> str:
"""Стабильный external_id фичи — хэш атрибутов. ``feature['id']`` ИГНОРИРУЕТСЯ.
"""Стабильный external_id фичи: feature['id'] или хэш ключевых полей.
GeoServer отдаёт СЕССИОННЫЙ fid (``sc_points_fullview.fid--<random>``), новый на
каждый GetFeature ON CONFLICT (source, external_id) не срабатывал ни разу и
таблица росла ×10 (4880 строк на 481 ЦП, #3322). Ключ считаем только по стабильным
атрибутам: нормализованное имя | класс напряжения | координаты в 1e-5 градуса.
ФОРМУЛА ПРОДУБЛИРОВАНА в ``data/sql/99c_power_supply_centers_dedup.sql`` (бэкфилл
существующих строк) менять только синхронно. sha256, а не sha1: sha256 встроен
в PG16, sha1 требует pgcrypto. Префикс ``h:`` отличает новый ключ от старого fid.
WFS обычно отдаёт стабильный ``feature['id']``; если его нет детерминированный
sha1 по (sc_name, координаты) чтобы UPSERT оставался идемпотентным.
"""
geom_pair = _point_geom_sql(feature)
coords = geom_pair[1] if geom_pair else {}
sc_name = props.get("sc_name")
seed = "|".join(
(
normalize_sc_name(sc_name),
parse_voltage_class(sc_name) or "",
_coord_e5(coords.get("lon")),
_coord_e5(coords.get("lat")),
)
)
# sha256 здесь — стабильный дедуп-id фичи, не криптография.
return "h:" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
fid = feature.get("id")
if fid:
return str(fid)
geom = feature.get("geometry") or {}
coords = geom.get("coordinates")
seed = f"{props.get('sc_name', '')}|{coords}"
# sha1 здесь — стабильный дедуп-id фичи, не криптография.
return "h:" + hashlib.sha1(seed.encode("utf-8")).hexdigest()[:16]
def _map_load_index(props: dict) -> str | None:

View file

@ -472,6 +472,18 @@ def _upsert_elements(elements: list[dict]) -> dict[str, int]:
return result_dict
async def load_utility_infrastructure(db: Session | None = None) -> dict[str, int]:
"""Async-обёртка для прямого вызова из async-контекста (#1746 spec).
Тянет Overpass и UPSERT-ит в таблицу. ``db`` принимается для сигнатурной
совместимости со спекой, но UPSERT идёт через собственную SessionLocal
(как noise/poi sync) Overpass-fetch async, запись в БД sync. Возвращает
те же per-kind counts, что ``sync_utility_infrastructure_to_db``.
"""
elements = await fetch_overpass_utility()
return _upsert_elements(elements)
# Junk-значения OSM tags, которые не несут смысла для попапа.
_JUNK_TAG_VALUES = frozenset({"", "yes", "no", "fixme", "unknown", "-"})

View file

@ -150,6 +150,69 @@ def _build_beat_schedule_from_db() -> dict:
return schedule
def _build_beat_schedule_fallback() -> dict:
"""Fallback-расписание из env vars — используется если БД недоступна на старте."""
logger.warning("beat_schedule: строим fallback из env vars")
schedule: dict = {}
# scrape_kn
try:
for rc in _default_regions():
schedule[f"kn-region-{rc}"] = {
"task": "tasks.scrape_kn.scrape_kn_region",
"schedule": _parse_cron(settings.scrape_kn_cron),
"args": [rc, None],
}
except Exception as e:
logger.warning("beat_schedule fallback: scrape_kn failed: %s", e)
# objective_sync
try:
from app.core.db import SessionLocal
from app.services.objective_sync_config import get_cron_schedule_safe
cron_str = get_cron_schedule_safe(SessionLocal)
except Exception:
cron_str = settings.objective_sync_cron
try:
schedule["objective-sync"] = {
"task": "tasks.scrape_objective.sync_all_groups",
"schedule": _parse_cron(cron_str),
"kwargs": {"triggered_by": "beat"},
}
except Exception as e:
logger.warning("beat_schedule fallback: objective_sync failed: %s", e)
# refresh ekb-districts medians — ежемесячно 5-го числа в 04:00 МСК
try:
schedule["refresh-ekb-districts-medians"] = {
"task": "tasks.refresh_analytics.refresh_ekb_districts_medians",
"schedule": _parse_cron("0 4 5 * *"),
"kwargs": {"window_months": 24, "min_deals": 50},
}
except Exception as e:
logger.warning("beat_schedule fallback: refresh_analytics failed: %s", e)
# КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ),
# раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00).
schedule["ekb-krt-sync-weekly"] = {
"task": "tasks.ekb_krt_sync.sync_krt_sites",
"schedule": _parse_cron("0 5 * * wed"),
"options": {"queue": "celery"},
}
# РИАСУРТ Свердл (#108): ежеквартально (1-е число янв/апр/июл/окт, 04:00 МСК).
# NB: harvest_all_* на ПЛЕЙСХОЛДЕР-bbox 5 МО (riasurt_sverdl_harvest.MO_BBOXES TODO).
schedule["riasurt-sverdl-harvest-quarterly"] = {
"task": "tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl",
"schedule": _parse_cron("0 4 1 1,4,7,10 *"),
"options": {"queue": "celery"},
}
return schedule
def build_beat_schedule() -> dict:
"""Строит beat_schedule из DB (job_settings) + добавляет hardcoded entries.

View file

@ -17,22 +17,12 @@ Analyze-тесты с ПОЗИЦИОННЫМ DB-моком (``_make_db_for_analy
(``test_analyze_zoning_regulation.py``), переопределяют этот же target своим
per-test ``patch`` он применяется ПОВЕРХ авто-фикстуры (вложенный mock-scope), так
что их ожидаемые значения резолвера сохраняются.
Perf-fix (2026-09-12): в конце ``analyze_parcel`` безусловный best-effort
``forecast_site_finder_report.delay(...)`` (§22-форсайт enqueue, см. app/api/v1/parcels.py).
В песочнице тестов Celery-брокер (Redis) недоступен ``.delay()`` синхронно ждёт
kombu-реконнект с растущим backoff (~69с) ДО того как try/except его проглотит
эта пауза оказалась внутри КАЖДОГО теста, который дергает ``POST /analyze`` и не
мокал форсайт-таску. Авто-фикстура ниже глушит ``.delay`` в no-op-мок для ВСЕХ
тестов каталога (как и с резолвером выше) тесты самого enqueue
(``test_parcels_forecast.py``, ``test_run_history_and_response_contract.py``)
переопределяют тот же target своим per-test ``patch`` поверх авто-фикстуры.
"""
from __future__ import annotations
from collections.abc import Iterator
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
@ -47,34 +37,3 @@ def _stub_zone_regulation_resolver() -> Iterator[None]:
"""
with patch("app.api.v1.parcels.get_or_fetch_zone_regulation", return_value=None):
yield
@pytest.fixture(autouse=True)
def _stub_forecast_enqueue() -> Iterator[None]:
"""No-op форсайт-enqueue по умолчанию (без реального Celery/Redis round-trip).
``.delay(...)`` в проде fire-and-forget (best-effort, обёрнут в try/except в
``analyze_parcel``), тестам сам форсайт не нужен, а живой брокер в CI/локальной
песочнице недоступен и держит запрос ~69с на реконнект-backoff.
"""
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", MagicMock()):
yield
@pytest.fixture(autouse=True)
def _fast_inline_fetch_wait(monkeypatch: pytest.MonkeyPatch) -> None:
"""Схлопнуть inline-ожидание NSPD-фетча (#93 graceful fallback) до миллисекунд.
В ``analyze_parcel`` ветка «участка нет в БД» ждёт появления геометрии циклом
``sleep(_INLINE_FETCH_POLL_INTERVAL_S)`` до ``_INLINE_FETCH_WAIT_S`` (15с прод-
значение). В тестах фетч замокан и геометрия не появится никогда каждый такой
тест честно спал 16с (``test_market_price_invalid_cad_returns_404``,
``test_recent_permits_invalid_cad_no_regression``).
Оставляем цикл РАБОЧИМ (несколько итераций по 10мс), а не выключаем его нулём:
тесты, проверяющие сам fast-path «строка появилась на N-м опросе», продолжают
видеть опросы. Тесты с собственным ``patch`` того же имени (напр.
``test_run_history_and_response_contract.py``) переопределяют это поверх.
"""
monkeypatch.setattr("app.api.v1.parcels._INLINE_FETCH_WAIT_S", 0.05)
monkeypatch.setattr("app.api.v1.parcels._INLINE_FETCH_POLL_INTERVAL_S", 0.01)

View file

@ -1,463 +0,0 @@
"""Полный деплой не пересоздаёт Caddy без нужды (#3443).
ЧТО СЛУЧИЛОСЬ. Каждый полный деплой ПТИЦЫ делал `up -d --force-recreate
--no-deps caddy`, то есть сносил единственный процесс, слушающий 80/443.
Замер 05.09 (#3274): 67 с `code=000` на ВСЕХ доменах хоста — gendsgn.ru,
meraocenka.ru и зеркала. Не 502/503: принимающего процесса нет вовсе, поэтому
заглушка окна деплоя бессильна по построению её отдаёт тот же Caddy.
ЧТО УСТАНОВЛЕНО. Безусловный флаг появился 17.05 (11e78d73) ради нового
bind-маунта `./preview`, который «не появлялся в running container». Довод
неверен: `docker compose up -d` БЕЗ `--force-recreate` пересоздаёт контейнер
сам, как только меняется описание сервиса или образ (проверено на живом демоне
docker 28.4). Единственное, чего compose не видит, СОДЕРЖИМОЕ пофайлового
bind-маунта: `git reset --hard` пишет новый инод, контейнер держит прежний, и
`caddy reload` перечитывает старый текст. У Caddy так смонтированы Caddyfile и
четыре сниппета; каталоги (caddy/sites, caddy/local, preview) этим не страдают.
ЗАЧЕМ ЭТОТ ФАЙЛ. У правки нет отрицательного признака: вернуть `--force-recreate`
«на всякий случай» одна строка, все деплои останутся зелёными, а окно в минуту
увидит только тот, кто в этот момент держал непрерывную пробу. Проверки ниже
ИСПОЛНЯЮТ ops/caddy-apply.sh с подставным `docker` и смотрят на СОВЕРШЁННЫЕ
действия (пересоздал / перезагрузил / не тронул), а не на текст скрипта.
Отдельно проверяется проводка в deploy.yml что оба пути деплоя зовут именно
его.
"""
from __future__ import annotations
import re
import shutil
import stat
import subprocess
from pathlib import Path
import pytest
import yaml
# backend/tests/ops/<этот файл> → корень репозитория
REPO_ROOT = Path(__file__).resolve().parents[3]
SCRIPT = REPO_ROOT / "ops" / "caddy-apply.sh"
WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows"
DEPLOY = WORKFLOWS / "deploy.yml"
CID = "caddy-cid-0001"
# Маунты Caddy ровно как на проде (`docker inspect gendesign-caddy-1`, 12.09):
# пять ПОФАЙЛОВЫХ bind-маунтов и три каталога. Тома (caddy_data и соседи) в
# сверку не входят — их фильтрует `{{if eq .Type "bind"}}`.
FILE_MOUNTS = {
"Caddyfile": "/etc/caddy/Caddyfile",
"caddy/users.caddy.snippet": "/etc/caddy/caddy/users.caddy.snippet",
"caddy/metrics-ui.caddy.snippet": "/etc/caddy/caddy/metrics-ui.caddy.snippet",
"caddy/metrics-ingest.caddy.snippet": "/etc/caddy/caddy/metrics-ingest.caddy.snippet",
"caddy/deploy-window.caddy.snippet": "/etc/caddy/caddy/deploy-window.caddy.snippet",
}
DIR_MOUNTS = {
"caddy/sites": "/etc/caddy/caddy/sites",
"caddy/local": "/etc/caddy/caddy/local",
"preview": "/srv/preview",
}
# Подставной `docker`. Пишет каждый вызов в $FAKE_LOG и отвечает по сценарию:
# run — одноразовый `caddy validate`, код из $FAKE_VALIDATE_RC;
# inspect — список маунтов из $FAKE_MOUNTS, код из $FAKE_INSPECT_RC;
# exec — sha256sum ФАЙЛА, КОТОРЫЙ ВИДИТ КОНТЕЙНЕР ($FAKE_VIEW/<slug>);
# compose … ps — текущий id контейнера из $FAKE_CID_FILE;
# compose … up — при $FAKE_UP_RECREATES=1 подменяет id (compose пересоздал сам).
FAKE_DOCKER = r"""#!/bin/bash
printf '%s\n' "$*" >> "$FAKE_LOG"
cmd="$1"; shift
case "$cmd" in
run) exit "${FAKE_VALIDATE_RC:-0}" ;;
inspect)
if [ "${FAKE_INSPECT_RC:-0}" != "0" ]; then
echo "Error: No such object" >&2
exit "$FAKE_INSPECT_RC"
fi
cat "$FAKE_MOUNTS"
;;
exec)
dst="$3"
view="$FAKE_VIEW/$(printf '%s' "$dst" | tr '/' '_')"
[ -f "$view" ] || exit 1
sha256sum "$view"
;;
compose)
case " $* " in
*" ps "*) cat "$FAKE_CID_FILE" ;;
*--force-recreate*) echo "recreated caddy (forced)" ;;
*" up "*)
if [ "${FAKE_UP_RECREATES:-0}" = "1" ]; then
printf 'caddy-cid-NEW\n' > "$FAKE_CID_FILE"
echo "Container gendesign-caddy-1 Started"
else
echo "Container gendesign-caddy-1 Running"
fi
;;
*) echo "(compose $*)" ;;
esac
;;
esac
exit 0
"""
# macOS несёт shasum вместо sha256sum; на раннере (ubuntu) и на проде утилита
# настоящая. Шим ставится только при её отсутствии — иначе гейт не запускался бы
# локально вовсе.
SHA_SHIM = '#!/bin/sh\nexec shasum -a 256 "$@"\n'
def _write_exec(path: Path, text: str) -> None:
path.write_text(text, encoding="utf-8")
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
@pytest.fixture
def prod_tree(tmp_path: Path) -> Path:
"""Копия боевого дерева: скрипт + конфиги + «взгляд контейнера»."""
tree = tmp_path / "opt" / "gendesign"
(tree / "ops").mkdir(parents=True)
shutil.copy(SCRIPT, tree / "ops" / SCRIPT.name)
for rel in [*FILE_MOUNTS, "caddy/sites/apps.caddy", "caddy/local/.gitignore"]:
path = tree / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"# {rel} версия НОВАЯ\n", encoding="utf-8")
(tree / "preview").mkdir()
view = tmp_path / "view"
view.mkdir()
# По умолчанию контейнер видит ровно то же, что лежит на диске.
for rel, dst in FILE_MOUNTS.items():
(view / dst.replace("/", "_")).write_text(
(tree / rel).read_text(encoding="utf-8"), encoding="utf-8"
)
mounts = tmp_path / "mounts"
mounts.write_text(
"".join(f"{tree / rel}|{dst}\n" for rel, dst in {**FILE_MOUNTS, **DIR_MOUNTS}.items()),
encoding="utf-8",
)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
_write_exec(bin_dir / "docker", FAKE_DOCKER)
if shutil.which("sha256sum") is None:
_write_exec(bin_dir / "sha256sum", SHA_SHIM)
(tmp_path / "cid").write_text(CID + "\n", encoding="utf-8")
(tmp_path / "log").write_text("", encoding="utf-8")
return tree
def _run(tree: Path, **env_extra: str) -> tuple[int, str, list[str]]:
root = tree.parent.parent
env = {
"PATH": f"{root / 'bin'}:/usr/bin:/bin:/usr/sbin:/sbin",
"FAKE_LOG": str(root / "log"),
"FAKE_CID_FILE": str(root / "cid"),
"FAKE_MOUNTS": str(root / "mounts"),
"FAKE_VIEW": str(root / "view"),
**env_extra,
}
proc = subprocess.run(
["sh", str(tree / "ops" / "caddy-apply.sh")],
cwd=str(tree),
env=env,
capture_output=True,
text=True,
)
calls = [c for c in (root / "log").read_text(encoding="utf-8").splitlines() if c]
return proc.returncode, proc.stdout + proc.stderr, calls
def _stale(tree: Path, dst: str) -> None:
"""Контейнер остался на старом иноде этого маунта."""
view = tree.parent.parent / "view" / dst.replace("/", "_")
view.write_text("# версия СТАРАЯ\n", encoding="utf-8")
def _recreated(calls: list[str]) -> bool:
return any("--force-recreate" in c for c in calls)
def _reloaded(calls: list[str]) -> bool:
return any("caddy reload" in c for c in calls)
# ── Что скрипт делает на самом деле ──────────────────────────────────────────
def test_nothing_changed_reloads_without_recreate(prod_tree: Path) -> None:
"""Обычный полный деплой (конфиг прокси не трогали): reload, без окна."""
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert not _recreated(calls), (
f"Caddy пересоздан, хотя ничего не изменилось: {calls}. "
"Это и есть #3443: 67 с code=000 на всех доменах при каждом деплое."
)
assert _reloaded(calls), f"конфиг не применён вовсе: {calls}"
def test_changed_file_mount_forces_recreate(prod_tree: Path) -> None:
"""Caddyfile правлен: reload перечитал бы старый инод — нужен recreate."""
_stale(prod_tree, "/etc/caddy/Caddyfile")
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert _recreated(calls), (
f"пересоздания нет: {calls}. Пофайловый bind-маунт держит инод — правка "
"Caddyfile не доехала бы до контейнера, а деплой ушёл бы зелёным."
)
assert "/etc/caddy/Caddyfile" in out, f"решение не названо в логе:\n{out}"
@pytest.mark.parametrize("dst", sorted(set(FILE_MOUNTS.values()) - {"/etc/caddy/Caddyfile"}))
def test_changed_snippet_forces_recreate(prod_tree: Path, dst: str) -> None:
"""Каждый из четырёх сниппетов — тот же класс, не только Caddyfile."""
_stale(prod_tree, dst)
_, _out, calls = _run(prod_tree)
assert _recreated(calls), f"{dst}: правка сниппета не доехала бы: {calls}"
def test_directory_mount_change_does_not_recreate(prod_tree: Path) -> None:
"""caddy/sites/apps.caddy — самый частый случай; каталог инод не держит.
Если сюда приползёт пересоздание «за компанию», окно недоступности вернётся
ровно на тех правках, ради которых заведён быстрый путь #2916.
"""
(prod_tree / "caddy" / "sites" / "apps.caddy").write_text("# новый блок\n", encoding="utf-8")
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert not _recreated(calls), f"правка в КАТАЛОГЕ вызвала пересоздание: {calls}"
assert _reloaded(calls), f"правка в каталоге не применена: {calls}"
def test_broken_config_touches_nothing(prod_tree: Path) -> None:
"""Битый конфиг: ни up, ни reload, ни пересоздания — прокси не тронут.
Иначе опечатка в Caddyfile уводит контейнер в crash-loop и роняет все
домены сразу (ровно то, чем опасен `--force-recreate` вслепую).
"""
rc, out, calls = _run(prod_tree, FAKE_VALIDATE_RC="1")
assert rc != 0, f"скрипт не упал на битом конфиге:\n{out}"
assert not _recreated(calls), f"битый конфиг поехал в пересоздание: {calls}"
assert not _reloaded(calls), f"битый конфиг поехал в reload: {calls}"
assert not any(" up " in f" {c} " for c in calls), f"был `up` при битом конфиге: {calls}"
def test_compose_recreate_is_not_doubled(prod_tree: Path) -> None:
"""compose пересоздал сам (сменилось описание сервиса/образ) — хватит.
Второй `--force-recreate` поверх ещё одно окно недоступности на ровном
месте, а новый контейнер и так читает свежие файлы.
"""
rc, out, calls = _run(prod_tree, FAKE_UP_RECREATES="1")
assert rc == 0, out
assert not _recreated(calls), f"пересоздание сделано дважды: {calls}"
assert not _reloaded(calls), f"reload поверх свежего контейнера: {calls}"
def test_unreadable_container_view_falls_back_to_recreate(prod_tree: Path) -> None:
"""Сверка не отработала (контейнер не отвечает) → прежнее поведение.
Fail-safe направлен в сторону пересоздания: лучше окно в секунды, чем
беззвучно не применённая правка конфига прокси.
"""
(prod_tree.parent.parent / "view" / "_etc_caddy_Caddyfile").unlink()
_, out, calls = _run(prod_tree)
assert _recreated(calls), f"непрочитанный маунт сочли доехавшим: {calls}\n{out}"
def test_unreadable_mount_list_falls_back_to_recreate(prod_tree: Path) -> None:
"""`docker inspect` не ответил → пересоздать, а не «расхождений нет».
Статус `$(docker inspect | while )` это статус `while`, то есть всегда
0, а `pipefail` в POSIX-sh не существует. Провал команды давал бы пустой
список маунтов, ветку «всё доехало» и зелёную строку «окна недоступности
нет» при прокси, работающем по СТАРОМУ конфигу тот самый беззвучный отказ,
ради которого написан скрипт.
"""
rc, out, calls = _run(prod_tree, FAKE_INSPECT_RC="1")
assert rc == 0, out
assert _recreated(calls), f"непрочитанный список маунтов сочли «всё доехало»: {calls}\n{out}"
assert not _reloaded(calls), f"reload вместо пересоздания: {calls}"
def test_no_file_mounts_is_not_silence(prod_tree: Path) -> None:
"""Ноль пофайловых маунтов — не «сверка прошла», а «сверять было нечем».
Так выглядит, например, перевод Caddyfile на именованный том: фильтр
`{{if eq .Type "bind"}}` перестаёт что-либо отбирать, и сверка становится
тавтологически успешной.
"""
root = prod_tree.parent.parent
(root / "mounts").write_text(
"".join(f"{prod_tree / rel}|{dst}\n" for rel, dst in DIR_MOUNTS.items()), encoding="utf-8"
)
rc, out, calls = _run(prod_tree)
assert rc == 0, out
assert _recreated(calls), f"пустая сверка сочтена успешной: {calls}\n{out}"
def test_missing_host_file_is_not_skipped_as_a_directory(prod_tree: Path) -> None:
"""Файла на хосте нет — это расхождение, а не «нечего сверять».
Пропуск по `[ -f "$src" ] || continue` склеивает два разных случая: каталог
(пропустить верно инод он не держит) и исчезнувший/нечитаемый файл, для
которого в контейнере как раз живёт старый инод со старым текстом. Файл
удаляется после проверки конфига (в тесте она подставная) проверяется
именно ветка сверки.
"""
(prod_tree / "Caddyfile").unlink()
_, out, calls = _run(prod_tree)
assert _recreated(calls), f"исчезнувший файл сочли доехавшим: {calls}\n{out}"
def test_log_says_how_many_mounts_were_compared(prod_tree: Path) -> None:
"""В логе должно быть ЧИСЛО сверенных файлов, а не только вердикт.
«Сверили пять» и «сверили ноль» обязаны различаться: иначе строка
«перезагружен без пересоздания» одинаково означает и проверку, и её
отсутствие.
"""
_, out, _ = _run(prod_tree)
assert re.search(r"сверено пофайловых маунтов[^\n]*: 5", out), (
f"скрипт не печатает число сверенных маунтов (их пять):\n{out}"
)
def test_validation_precedes_any_action(prod_tree: Path) -> None:
"""Проверка конфига идёт ПЕРВЫМ вызовом, до любого изменения состояния."""
_, out, calls = _run(prod_tree)
assert calls, f"скрипт не сделал ни одного вызова docker:\n{out}"
assert calls[0].startswith("run "), f"первым идёт не проверка конфига: {calls}"
assert "caddy validate" in calls[0], f"первый вызов — не validate: {calls[0]}"
# ── Проводка: оба пути деплоя зовут именно этот скрипт ───────────────────────
def _ssh_script(job: str) -> str:
spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8"))
steps = [s for s in spec["jobs"][job]["steps"] if "ssh-action" in str(s.get("uses"))]
assert len(steps) == 1, f"в job `{job}` нет ровно одного ssh-шага — гейт #3443 ослеп"
script = steps[0]["with"]["script"]
assert script.strip(), f"ssh-скрипт job `{job}` пуст"
return script
def _commands(script: str) -> str:
"""Только команды: комментарии выкинуты, продолжения строк склеены.
Комментарии потому что разбор дефекта живёт в тех же файлах и содержит
его формулировку дословно: гейт по голому тексту краснел бы от объяснения,
а не от кода. Склейка `\\` потому что `--force-recreate` и имя сервиса
легко оказываются на РАЗНЫХ физических строках, и построчный поиск такую
запись не увидел бы (зелено по построению).
"""
kept = [ln for ln in script.splitlines() if not ln.lstrip().startswith("#")]
return re.sub(r"\\\n\s*", " ", "\n".join(kept))
def _forced_caddy_recreates(commands: str) -> list[str]:
"""Строки, которые пересоздают именно сервис caddy."""
return [
ln
for ln in commands.splitlines()
if "--force-recreate" in ln and re.search(r"\bcaddy\b", ln)
]
def test_script_exists_and_is_the_one_under_test() -> None:
"""Признак непустоты: без скрипта проверки выше проходили бы вхолостую."""
assert SCRIPT.is_file(), f"нет {SCRIPT} — проводка ниже проверяла бы пустоту"
@pytest.mark.parametrize("job", ["deploy", "deploy-caddy"])
def test_deploy_applies_caddy_config_through_the_script(job: str) -> None:
assert "ops/caddy-apply.sh" in _commands(_ssh_script(job)), (
f"job `{job}` не зовёт ops/caddy-apply.sh — конфиг прокси применяется "
"мимо разбора #3443 (или безусловным пересозданием, или reload'ом, "
"который на пофайловом маунте читает старый инод)"
)
def test_full_deploy_has_no_unconditional_caddy_recreate() -> None:
"""Главный инвариант: в полном деплое нет безусловного пересоздания Caddy.
Возврат одной строки `up -d --force-recreate --no-deps caddy` в job `deploy`
возвращает 67-секундное окно `code=000` на всех доменах и не краснит
ничего: деплой остаётся зелёным, а увидеть отказ может только непрерывная
проба, запущенная ровно в эту минуту.
"""
bad = _forced_caddy_recreates(_commands(_ssh_script("deploy")))
assert not bad, (
"в полный деплой вернулось безусловное пересоздание Caddy:\n "
+ "\n ".join(bad)
+ "\nПересоздание обязано быть УСЛОВНЫМ — см. ops/caddy-apply.sh: compose "
"сам пересоздаёт контейнер при смене описания сервиса или образа, а "
"вручную это нужно только когда до контейнера не доехал пофайловый "
"bind-маунт (#3443)."
)
def test_failures_are_not_swallowed() -> None:
"""Ни применение конфига, ни сам reload не гасятся `|| true`.
Строка `caddy reload || true` в репозитории уже живёт
(deploy-tradein.yml), то есть это не гипотеза: с ней отказ применения
перестаёт краснеть, и «конфиг доехал» становится неотличимо от «команда
упала, а мы продолжили». Проверяется и вызов скрипта из обеих джоб, и
строка reload внутри самого скрипта.
"""
swallow = re.compile(r"\|\|\s*(true|:)\s*$")
offenders = []
for where, text in [
("deploy", _commands(_ssh_script("deploy"))),
("deploy-caddy", _commands(_ssh_script("deploy-caddy"))),
(SCRIPT.name, SCRIPT.read_text(encoding="utf-8")),
]:
for line in text.splitlines():
code = line.split("#", 1)[0] if not line.lstrip().startswith("#") else ""
if ("caddy-apply.sh" in code or "caddy reload" in code) and swallow.search(code):
offenders.append(f"{where}: {line.strip()}")
assert not offenders, "отказ применения конфига проглочен:\n " + "\n ".join(offenders)
def test_gate_runs_on_changes_to_the_script_itself() -> None:
"""CI-фильтр обязан пускать backend-тесты на правку ops/**.
Все содержательные регрессии живут в ops/caddy-apply.sh: проверки выше его
ИСПОЛНЯЮТ. Без `ops/**` в фильтре PR, правящий только скрипт, даёт
backend=false джоба пропускается, гейт не исполняется, и «пересоздавать
всегда» уезжает в main зелёным. Тот же класс, что #2950/#3448/#3467.
"""
spec = yaml.safe_load((WORKFLOWS / "ci.yml").read_text(encoding="utf-8"))
steps = [
s
for job in spec["jobs"].values()
for s in job.get("steps") or []
if str(s.get("uses", "")).startswith("dorny/paths-filter")
]
assert steps, "в ci.yml не найден paths-filter — проверка прошла бы вхолостую"
patterns = [p for s in steps for p in yaml.safe_load(s["with"]["filters"]).get("backend") or []]
assert "ops/**" in patterns, (
f"фильтр backend не покрывает ops/** (сейчас: {patterns}) — гейт #3443 не "
"побежит на правке ops/caddy-apply.sh, то есть ровно на той правке, от "
"которой стережёт"
)
def test_gate_would_notice_the_regression() -> None:
"""Сам гейт обязан краснеть на возвращённом дефекте — проверка на себя.
Без этого «не нашли force-recreate» неотличимо от «искали не там»: маска
поиска, промахнувшаяся мимо строки, выглядит зелёной ровно так же.
"""
regressed = _commands(
" # безусловное пересоздание caddy вернулось сюда\n"
" docker compose -p gendesign -f docker-compose.prod.yml up -d \\\n"
" --force-recreate --no-deps caddy\n"
)
assert _forced_caddy_recreates(regressed), (
"маска поиска не видит дословно ту строку, ради которой заведён гейт"
)

View file

@ -1,438 +0,0 @@
"""Гейт: быстрый путь «правка только прокси» действительно включается (#3448).
ЧТО СЛУЧИЛОСЬ. Быстрый путь #2916 (`caddy_only` → джоба `deploy-caddy` с
`caddy reload` вместо пересоздания контейнеров) не отработал ни разу за всё
время жизни. Проверено на мерже 84920e6c, где в диффе ровно один файл
`caddy/sites/apps.caddy`: контейнеры пересозданы, в логе Caddy
`serving initial configuration` холодный старт, а не reload.
ПРИЧИНА НЕ пустой `github.event.before` (рабочая гипотеза #3448 опровергнута
логом задачи 29244: `before` = 204e2e09, `git diff` вернул ровно один файл).
Причина в том, что dorny/paths-filter склеивает шаблоны одного фильтра через
`some`, то есть ИЛИ (src/filter.ts: `patterns.some(aPredicate)`; параметр
`predicate-quantifier` по умолчанию `some`). Список
non_caddy: ['**', '!Caddyfile', '!caddy/**']
значит «подходит под `**` ИЛИ не Caddyfile ИЛИ не caddy/**», а `**` матчит всё
non_caddy был true ВСЕГДА. В логе это видно дословно:
##[group]Filter non_caddy = true
Matching files:
caddy/sites/apps.caddy [modified]
исключённый файл сам себя и «исключил».
ЗАЧЕМ ЭТОТ ФАЙЛ. У самой правки нет отрицательного признака: пропущенную джобу
Forgejo рисует зелёной, поэтому «зелёный deploy-caddy» одинаково выглядит и
когда быстрый путь сработал, и когда его вообще не было. Проверки ниже
ИСПОЛНЯЮТ шаг определения файлов из deploy.yml на настоящем временном
репозитории (включая мерж-коммит ровно случай #3448) и смотрят на значения
флагов, а не на текст воркфлоу. Так же исполняется и прод-сторож из джобы
`deploy-caddy`: быстрый путь пропускает джобу `deploy` целиком, а вместе с ней
и гард свежести :latest (#2950), поэтому перезагружать прокси можно, только
если прод отстаёт РОВНО на конфиг прокси. Регресс к исключающим шаблонам
paths-filter ловит отдельная проверка в конце.
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
import pytest
import yaml
# backend/tests/ops/<этот файл> → корень репозитория
REPO_ROOT = Path(__file__).resolve().parents[3]
WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows"
DEPLOY = WORKFLOWS / "deploy.yml"
NULL_SHA = "0" * 40
# Файлы, которые лежат в тестовом репозитории до правки. Набор подобран так,
# чтобы фолбэк «база не определена» мог отличить полный деплой от пустого.
BASE_FILES = (
"caddy/sites/apps.caddy",
"Caddyfile",
"backend/app/main.py",
"frontend/src/page.tsx",
"data/sql/001.sql",
"docker-compose.prod.yml",
"README.md",
)
GIT_ENV = {
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@example.com",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@example.com",
"GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_CONFIG_SYSTEM": "/dev/null",
}
def _detect_script() -> str:
"""Тело шага, который считает изменённые файлы в job `changes`."""
spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8"))
job = spec["jobs"]["changes"]
steps = [s for s in job["steps"] if s.get("id") == "filter"]
assert len(steps) == 1, (
"в job `changes` нет ровно одного шага с `id: filter` — определение "
"изменённых файлов переехало, гейт #3448 ослеп"
)
step = steps[0]
assert "run" in step, (
f"шаг `filter` не считает файлы сам, а делегирует их {step.get('uses')!r}. "
"Именно так и возник #3448: у dorny/paths-filter шаблоны одного фильтра "
"склеиваются через ИЛИ, поэтому `non_caddy: ['**', '!caddy/**']` был true "
"ВСЕГДА и быстрый путь не включался ни разу."
)
assert step["run"].strip(), "шаг `filter` пуст"
return step["run"]
def _git(repo: Path, *args: str) -> None:
subprocess.run(
["git", "-C", str(repo), *args], check=True, env=dict(GIT_ENV), capture_output=True
)
def _sha(repo: Path) -> str:
return subprocess.run(
["git", "-C", str(repo), "rev-parse", "HEAD"],
check=True,
capture_output=True,
text=True,
env=dict(GIT_ENV),
).stdout.strip()
def _commit(repo: Path, files: tuple[str, ...], msg: str = "c") -> None:
for name in files:
path = repo / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("changed\n", encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", msg, *([] if files else ["--allow-empty"]))
def _base_repo(tmp_path: Path) -> tuple[Path, str]:
"""Репозиторий с одним базовым коммитом; возвращает его sha — это `before`."""
repo = tmp_path / "repo"
repo.mkdir(parents=True)
_git(repo, "init", "-q", "-b", "main")
for name in BASE_FILES:
path = repo / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("base\n", encoding="utf-8")
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", "base")
return repo, _sha(repo)
def _merge_commit(repo: Path, changed: tuple[str, ...]) -> None:
"""Ветка с правкой и мерж `--no-ff` обратно в main.
Мерж, а не обычный коммит, потому что #3448 наблюдался именно на мерже
PR'а: у мерж-коммита две родительские линии, и любой разбор диффа обязан
работать на этой форме. Что `before` нельзя заменить на `HEAD^`, стережёт
отдельная проверка test_multi_commit_push_is_not_truncated: на ОДНОМ
мерж-коммите `HEAD^..HEAD` даёт верный ответ и такую подмену не ловит.
"""
_git(repo, "checkout", "-q", "-b", "feature")
_commit(repo, changed, "feature")
_git(repo, "checkout", "-q", "main")
_git(repo, "merge", "-q", "--no-ff", "-m", "merge feature", "feature")
def _exec(repo: Path, before: str, event: str = "push") -> tuple[dict[str, str], str]:
out_file = repo.parent / "outputs"
out_file.touch()
env = {
"PATH": "/usr/bin:/bin:/usr/local/bin",
"BEFORE": before,
"EVENT": event,
"GITHUB_OUTPUT": str(out_file),
**GIT_ENV,
}
proc = subprocess.run(
["bash", "-c", _detect_script()],
cwd=repo,
env=env,
capture_output=True,
text=True,
)
assert proc.returncode == 0, f"шаг упал:\n{proc.stdout}\n{proc.stderr}"
outputs = dict(
line.split("=", 1)
for line in out_file.read_text(encoding="utf-8").splitlines()
if "=" in line
)
return outputs, proc.stdout
def _run(
tmp_path: Path, changed: tuple[str, ...], *, before: str | None = None, event: str = "push"
) -> tuple[dict[str, str], str]:
repo, base_sha = _base_repo(tmp_path)
_merge_commit(repo, changed)
return _exec(repo, base_sha if before is None else before, event)
def test_merge_with_only_caddy_file_takes_the_fast_path(tmp_path: Path) -> None:
"""Случай #3448 дословно: мерж, в диффе один файл под caddy/."""
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy",))
assert outputs["caddy_only"] == "true", (
f"быстрый путь не включился на правке ТОЛЬКО прокси: {outputs}. "
"Ровно это и есть #3448: deploy-caddy пропускается, идёт полный деплой "
"с пересозданием контейнеров, а Forgejo рисует пропуск зелёным."
)
assert outputs["backend"] == "false"
assert outputs["frontend"] == "false"
def test_caddy_plus_backend_is_a_full_deploy(tmp_path: Path) -> None:
"""Обратное направление: быстрый путь НЕ должен красть обычный деплой."""
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy", "backend/app/main.py"))
assert outputs["caddy_only"] == "false", (
f"быстрый путь включился, хотя вместе с конфигом приехал бэкенд: {outputs}. "
"Так прод остался бы на старом образе при зелёном деплое."
)
assert outputs["backend"] == "true"
def test_missing_base_falls_back_to_full_deploy(tmp_path: Path) -> None:
"""База не разрешилась → полный деплой, а не пустой список.
Пустой список изменений это `caddy_only` без единого caddy-файла и
отключённая сборка: отказ, который выглядит как успешный быстрый путь.
"""
for before, event in ((NULL_SHA, "push"), ("", "push"), (None, "workflow_dispatch")):
outputs, log = _run(
tmp_path / f"case-{event}-{before!r}",
("caddy/sites/apps.caddy",),
before=before,
event=event,
)
assert outputs["caddy_only"] == "false", f"before={before!r} event={event}: {outputs}"
assert outputs["backend"] == "true", f"before={before!r} event={event}: {outputs}"
assert outputs["frontend"] == "true", f"before={before!r} event={event}: {outputs}"
assert outputs["infra"] == "true", f"before={before!r} event={event}: {outputs}"
assert "деплой полный" in log
def test_decision_is_visible_in_the_log(tmp_path: Path) -> None:
"""Решение печатается: и список файлов, и итоговые флаги.
Без этого «сработало» и «просто не совпало» неотличимы единственным
свидетелем остаётся метка Created у контейнера на проде.
"""
_, log = _run(tmp_path, ("caddy/sites/apps.caddy",))
assert "caddy/sites/apps.caddy" in log, f"шаг не печатает список файлов:\n{log}"
assert "caddy_only=true" in log and "backend=false" in log, (
f"шаг не печатает итоговые флаги:\n{log}"
)
# ── Быстрый путь на самом проде: джоба deploy-caddy ──────────────────────────
#
# Пока caddy_only был мёртв, каждый push шёл полным деплоем, и гард свежести
# :latest (#2950, job `deploy`) прикрывал прод по умолчанию. Оживший быстрый
# путь его обходит: при caddy_only=true джоба `deploy` пропускается целиком.
# Дифф between-push (before→HEAD) не знает, что реально доехало до прода:
# отменённая очередью `deploy` предыдущего прогона оставляет прод на старом
# образе, а следующий caddy-only push честно видит «изменился один caddy-файл».
def _caddy_deploy_script() -> str:
spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8"))
steps = [s for s in spec["jobs"]["deploy-caddy"]["steps"] if "ssh-action" in str(s.get("uses"))]
assert len(steps) == 1, "в deploy-caddy нет ровно одного ssh-шага — гейт #3448 ослеп"
return steps[0]["with"]["script"]
def _prod_lag_guard() -> str:
"""Кусок ssh-скрипта от вычисления PROD_HEAD до `git reset --hard`."""
script = _caddy_deploy_script()
assert "PROD_HEAD=" in script, (
"джоба deploy-caddy не сверяет отставание прода: быстрый путь перезагрузит "
"прокси и уйдёт зелёным, оставив прод на старом образе (#3448)"
)
# Ищем КОМАНДУ, а не подстроку: `git reset --hard` упоминается выше в
# комментариях, и поиск по тексту нашёл бы объяснение вместо кода.
reset_cmd = re.search(r"(?m)^\s*git reset --hard", script)
assert reset_cmd, "в deploy-caddy пропал `git reset --hard` — гейт опирается на него"
start, reset = script.index("PROD_HEAD="), reset_cmd.start()
assert start < reset, (
"проверка отставания прода стоит ПОСЛЕ `git reset --hard` — при отказе "
"прод-HEAD уже переписан, и следующий прогон снова уйдёт быстрым путём"
)
return "set -euo pipefail\n" + script[start:reset]
def _prod_repo(tmp_path: Path, ahead: tuple[str, ...]) -> Path:
"""Прод-дерево на базовом коммите, origin/main — на `ahead` впереди."""
repo, base_sha = _base_repo(tmp_path)
_commit(repo, ahead, "ahead")
_git(repo, "update-ref", "refs/remotes/origin/main", "HEAD")
_git(repo, "reset", "--hard", "-q", base_sha)
return repo
def _run_guard(repo: Path) -> subprocess.CompletedProcess:
return subprocess.run(
["bash", "-c", _prod_lag_guard()],
cwd=repo,
capture_output=True,
text=True,
env={"PATH": "/usr/bin:/bin:/usr/local/bin", **GIT_ENV},
)
def test_fast_path_allowed_when_prod_lags_only_by_proxy_config(tmp_path: Path) -> None:
proc = _run_guard(_prod_repo(tmp_path, ("caddy/sites/apps.caddy",)))
assert proc.returncode == 0, f"законный быстрый путь заблокирован:\n{proc.stdout}{proc.stderr}"
def test_fast_path_refuses_when_prod_lags_by_code(tmp_path: Path) -> None:
"""Прод отстаёт не только по конфигу прокси → перезагрузка прокси запрещена."""
proc = _run_guard(_prod_repo(tmp_path, ("backend/app/main.py", "caddy/sites/apps.caddy")))
assert proc.returncode != 0, (
"быстрый путь разрешён, хотя прод отстаёт по коду бэкенда: перезагрузка "
f"прокси подменила бы выкатку, деплой ушёл бы зелёным.\n{proc.stdout}"
)
assert "backend/app/main.py" in proc.stdout, (
f"отказ не называет файлы, из-за которых он произошёл:\n{proc.stdout}"
)
def test_fast_path_takes_the_same_host_lock() -> None:
"""deploy-caddy правит прод-дерево — значит берёт тот же лок, что `deploy`.
Проверка текстовая, как в test_2950: исполнить flock-секцию в тесте нельзя,
а её пропажа не даёт ни одного сигнала до совпадения окон двух деплоев.
"""
script = _caddy_deploy_script()
assert "exec 9>/var/lock/gendesign-docker-deploy.lock" in script, (
"deploy-caddy делает `git reset --hard` в /opt/gendesign в обход лока, "
"которым полный деплой сериализует работу с прод-деревом (#2950)"
)
assert "flock -w 900 9" in script, "лок открывается, но не захватывается"
@pytest.mark.parametrize(
"changed", [("caddy-extra/x.txt",), ("Caddyfile.bak",), ("docs/caddy.md",)]
)
def test_paths_that_merely_start_with_caddy_are_not_the_fast_path(
tmp_path: Path, changed: tuple[str, ...]
) -> None:
"""`caddy-extra/…` и `Caddyfile.bak` — НЕ конфиг прокси.
Граница шаблона единственное, что отделяет быстрый путь от тихого
пропуска полного деплоя: `^(Caddyfile|caddy)` вместо `^(Caddyfile$|caddy/)`
отправил бы эти правки перезагружать прокси вместо выкатки.
"""
outputs, _ = _run(tmp_path, changed)
assert outputs["caddy_only"] == "false", f"{changed}: {outputs}"
def test_empty_diff_is_not_the_fast_path(tmp_path: Path) -> None:
"""Пустой дифф (`before` == HEAD, пустой мерж) — не «всё под caddy».
Без проверки «файлов больше нуля» пустой список формально удовлетворяет
«ни один файл не лежит вне caddy»: сборка отключается, деплой подменяется
перезагрузкой прокси отказ, выглядящий как успешный быстрый путь.
"""
outputs, log = _run(tmp_path, ())
assert outputs["caddy_only"] == "false", f"пустой дифф ушёл в быстрый путь: {outputs}"
assert "изменённых файлов: 0" in log
def test_data_sql_counts_as_backend(tmp_path: Path) -> None:
"""`data/sql/**` собирает backend-образ: миграции едут в нём."""
outputs, _ = _run(tmp_path, ("data/sql/002.sql",))
assert outputs["backend"] == "true", outputs
assert outputs["caddy_only"] == "false", outputs
def test_non_ascii_path_is_classified(tmp_path: Path) -> None:
"""Кириллица в пути не должна прятать файл от классификации.
`git diff --name-only` при `core.quotePath=true` (умолчание) отдаёт
не-ASCII пути закавыченными и с \\NNN-экранированием `^backend/`
такую строку не матчит. Старый paths-filter брал `--name-status -z`, где
квотирования нет; при переходе на свой diff это единственное место, где
поведение могло разойтись. В дереве такие пути уже живут (docs/).
"""
outputs, log = _run(tmp_path, ("backend/модуль.py",))
assert outputs["backend"] == "true", f"кириллический путь потерян: {outputs}\n{log}"
def test_multi_commit_push_is_not_truncated(tmp_path: Path) -> None:
"""Push из нескольких коммитов разбирается целиком, а не по последнему.
Ровно та подмена, которую соблазнительно сделать «чтобы не зависеть от
before»: `HEAD^..HEAD`. На одном мерж-коммите она даёт верный ответ и
выглядит рабочей, а здесь молча теряет бэкенд из первого коммита и
включает быстрый путь, то есть пропускает выкатку кода.
"""
repo, base_sha = _base_repo(tmp_path)
_commit(repo, ("backend/app/main.py",), "backend")
_commit(repo, ("caddy/sites/apps.caddy",), "caddy")
outputs, log = _exec(repo, base_sha)
assert outputs["backend"] == "true", f"первый коммит push'а потерян: {outputs}\n{log}"
assert outputs["caddy_only"] == "false", outputs
def _paths_filter_steps() -> list[tuple[Path, str, dict]]:
"""Все шаги dorny/paths-filter во всех воркфлоу (включая .yaml)."""
found = []
for path in sorted(WORKFLOWS.glob("*.y*ml")):
spec = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
for job_name, job in (spec.get("jobs") or {}).items():
for step in job.get("steps") or []:
if str(step.get("uses", "")).startswith("dorny/paths-filter"):
found.append((path, job_name, step))
return found
def test_exclusion_gate_has_something_to_check() -> None:
"""Признак непустоты: проверка ниже обязана что-то находить.
Переименуют действие, разнесут воркфлоу по .yaml, уедут шаги и гейт
пройдёт при нулевом охвате, молча (ровно то, от чего страхуется ci.yml:190).
"""
steps = _paths_filter_steps()
assert steps, (
"не найдено ни одного шага dorny/paths-filter — проверка исключающих "
"шаблонов прошла бы впустую, перепроверь маску поиска"
)
@pytest.mark.parametrize(
"path,job_name,step",
_paths_filter_steps(),
ids=[f"{p.name}:{j}" for p, j, _ in _paths_filter_steps()],
)
def test_no_paths_filter_relies_on_exclusion_patterns(
path: Path, job_name: str, step: dict
) -> None:
"""Ни один paths-filter в репозитории не вычитает пути через `!`.
Класс бага, а не единственный его случай: при `predicate-quantifier: some`
(умолчание) шаблоны фильтра склеиваются через ИЛИ, и `!` ничего не вычитает.
"""
with_ = step.get("with") or {}
if with_.get("predicate-quantifier") == "every":
pytest.skip("predicate-quantifier: every — шаблоны склеиваются через И")
filters = yaml.safe_load(with_.get("filters") or "") or {}
assert filters, f"{path.name}: job {job_name}у paths-filter пустой блок filters"
for filter_name, patterns in filters.items():
bad = [p for p in (patterns or []) if isinstance(p, str) and p.startswith("!")]
assert not bad, (
f"{path.name}: job {job_name}, фильтр {filter_name!r} вычитает пути "
f"шаблонами {bad} — при `some` (умолчание) они склеиваются через ИЛИ "
"и фильтр становится true ВСЕГДА. Так #2916 не сработал ни разу (#3448)."
)

View file

@ -1,112 +0,0 @@
"""Правки Prometheus-конфига/правил обязаны доезжать до работающего процесса.
ЧТО СЛУЧИЛОСЬ НА ПРОДЕ. `GET /api/v1/status/runtimeinfo` внутри
`gendesign-prometheus` 12.09 отдавал `lastConfigTime`, совпадающий со
`startTime` контейнера, конфиг и правила не перечитывались 16 суток.
Деплой при этом был зелёный: `docker compose up -d` не пересоздаёт
контейнер из-за изменения содержимого бинд-маунта (он сравнивает только
описание сервиса), а `--web.enable-lifecycle` был включён в
docker-compose.metrics.yml, но эндпоинт `/-/reload` никто не вызывал.
Тот же класс бага, что уже пойман и починен для Caddy (`caddy reload`)
и для Alertmanager (`--force-recreate`, см. test_3xxx_alertmanager_inode.py)
в этом же workflow только для Prometheus починка не пересоздание
контейнера, а именно `POST /-/reload`: он переоткрывает файлы конфига по
пути заново, так что новый инод после `git reset --hard` подхватывается
без даунтайма.
Проверяется здесь: (1) валидация promtool ЕСТЬ, (2) reload вызывается
ТОЛЬКО после успешной валидации, (3) шаг обязан упасть, если reload не
подтверждён сменой lastConfigTime.
"""
from __future__ import annotations
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
def _text() -> str:
return WORKFLOW.read_text(encoding="utf-8")
def test_promtool_checks_config_and_rules() -> None:
"""promtool обязан проверять и конфиг, и правила — не только один файл."""
text = _text()
assert "promtool check config /etc/prometheus/prometheus.yml" in text, (
"нет проверки конфига promtool'ом — битый prometheus.yml долетит до reload"
)
assert "promtool check rules" in text, (
"нет проверки правил promtool'ом — битое правило долетит до reload"
)
def test_reload_endpoint_is_called() -> None:
"""Сам reload обязан вызываться — иначе валидация ничего не решает."""
text = _text()
assert "localhost:9090/-/reload" in text, (
"нет вызова POST /-/reload — конфиг/правила проверяются, но в силу не вступают "
"(#3467: lastConfigTime не менялся 16 суток при зелёном деплое)"
)
def test_reload_happens_after_validation_not_before() -> None:
"""Reload обязан идти ПОСЛЕ promtool, а не до/вместо него."""
text = _text()
check_pos = text.index("promtool check config /etc/prometheus/prometheus.yml")
reload_pos = text.index("localhost:9090/-/reload")
assert check_pos < reload_pos, (
"reload стоит раньше проверки конфига — битый конфиг мог бы применяться вслепую"
)
def test_reload_is_guarded_by_the_promtool_check() -> None:
"""Reload обязан быть ВНУТРИ `if promtool ...; then`, а не безусловным."""
text = _text()
guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
else_pos = text.index("else", guard_start)
reload_pos = text.index("localhost:9090/-/reload")
assert guard_start < reload_pos < else_pos, (
"вызов reload лежит вне ветки успешной проверки promtool — "
"битый конфиг всё равно приведёт к reload, либо reload вообще не защищён проверкой"
)
def test_failed_validation_skips_reload_and_fails_the_step() -> None:
"""При провале promtool — reload НЕ вызывается, и шаг падает (exit 1)."""
text = _text()
guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
else_pos = text.index("else", guard_start)
fi_pos = text.index("fi", else_pos)
else_branch = text[else_pos:fi_pos]
assert "localhost:9090/-/reload" not in else_branch, (
"reload вызывается даже в ветке провалившейся проверки"
)
assert "exit 1" in else_branch, (
"провал promtool не роняет шаг — деплой останется зелёным при битом конфиге"
)
def test_acceptance_checks_last_config_time_actually_changed() -> None:
"""Приёмка обязана сверять `lastConfigTime` до/после, а не доверять коду ответа reload.
`wget` на POST /-/reload может отрапортовать успех, даже если Prometheus
молча остался на старом конфиге (например, если бинарь внутри образа не
поддерживает --post-data так, как ожидалось) единственное надёжное
подтверждение реального перечитывания конфига это смена таймстемпа.
"""
text = _text()
assert text.count("lastConfigTime") >= 2, (
"нет сравнения lastConfigTime до/после — reload не проверяется по факту"
)
assert "LAST_CONFIG_BEFORE" in text and "LAST_CONFIG_AFTER" in text, (
"нет явного до/после сравнения таймстемпа последней перезагрузки конфига"
)
verify_start = text.index("LAST_CONFIG_AFTER")
verify_block_end = text.index("Prometheus: конфиг и правила проверены", verify_start)
verify_block = text[verify_start:verify_block_end]
assert "exit 1" in verify_block, (
"если lastConfigTime не изменился, шаг обязан падать, а не считаться успешным"
)

View file

@ -1,108 +0,0 @@
"""Гейт: `$value` в тексте алерта — это та величина, которую текст называет.
Найдено 12.09.2026 по боевым сообщениям в Telegram:
«apps / tradein-browser: 2.684e+11% от mem_limit. Дальше OOM-kill.»
«apps / listings: доля HOT 75.21%.» (при пороге срабатывания «доля < 20%»)
Причина у обоих одна и она про ФОРМУ выражения, а не про условие. В PromQL
`A and B` возвращает ЗНАЧЕНИЯ ЛЕВОЙ части, отфильтрованные правой. Значит в
`$value` попадает A, а не то отношение, ради которого правило написано:
- ContainerNearMemoryLimit: слева стоял `container_spec_memory_limit_bytes`,
и в сообщение уходил ЛИМИТ В БАЙТАХ, отрендеренный `humanizePercentage`
(2 684 354 560 «2.684e+11%»). Условие при этом срабатывало верно.
- PostgresLowHotUpdateRatio: слева стоял `rate(tup_upd[6h])` АПДЕЙТОВ В
СЕКУНДУ. Это опаснее: 0.7521 превращалось в «75.21%», число попадало в
правдоподобный диапазон и противоречило собственному порогу («доля < 20%»),
но выглядело настоящим.
Отсюда инвариант, который здесь и проверяется: **если описание рендерит
`$value` как долю (`humanizePercentage`), выражение обязано возвращать долю**
то есть его ЛЕВАЯ часть (до первого `and`/`unless`) обязана содержать деление.
Отсев побочных условий переносится внутрь знаменателя (`X / (Y > 0)`), а не в
`and` слева.
Гейт намеренно не пытается «понять» PromQL целиком: он ловит ровно ту форму,
которая уже дважды уехала в прод, и не мешает правилам, печатающим абсолютные
величины без humanize (PostgresDeadTuplesHigh, PostgresIdleInTransaction).
"""
from __future__ import annotations
import re
from pathlib import Path
import yaml
_RULES_DIR = Path(__file__).resolve().parents[3] / "ops" / "metrics" / "prometheus" / "rules"
def _alerts() -> list[tuple[str, str, str, dict]]:
"""(файл, имя алерта, expr, annotations) по всем файлам правил."""
out: list[tuple[str, str, str, dict]] = []
for path in sorted(_RULES_DIR.glob("*.yml")):
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
for group in doc.get("groups", []):
for rule in group.get("rules", []):
if "alert" in rule:
out.append(
(
path.name,
rule["alert"],
rule.get("expr", ""),
rule.get("annotations") or {},
)
)
return out
def _left_of_and(expr: str) -> str:
"""Часть выражения ДО первого бинарного `and`/`unless` — её значения и видит $value."""
parts = re.split(r"\band\b|\bunless\b", expr, maxsplit=1)
return parts[0]
def test_rules_dir_is_found() -> None:
assert _RULES_DIR.is_dir(), f"нет каталога правил: {_RULES_DIR}"
assert _alerts(), "правила не распарсились — гейт был бы зелёным впустую"
def test_percentage_annotations_come_from_a_ratio() -> None:
"""Текст обещает долю → выражение обязано её и возвращать."""
broken: list[str] = []
for fname, name, expr, ann in _alerts():
text = " ".join(str(v) for v in ann.values())
if "humanizePercentage" not in text:
continue
left = _left_of_and(expr)
if "/" not in left:
broken.append(
f"{fname}::{name}: описание печатает $value как долю, но левая часть "
f"выражения (её и видит $value) деления не содержит: {' '.join(left.split())!r}"
)
assert not broken, "\n".join(broken)
def test_known_two_rules_are_fixed() -> None:
"""Именные проверки для двух правил, которые уже соврали в проде."""
by_name = {name: (expr, ann) for _, name, expr, ann in _alerts()}
expr, ann = by_name["ContainerNearMemoryLimit"]
flat = " ".join(expr.split())
assert flat.startswith("container_memory_working_set_bytes"), (
"слева должно стоять потребление, иначе в Telegram уедет лимит в байтах: " + flat
)
assert "(container_spec_memory_limit_bytes" in flat and "> 0)" in flat, (
"отсев нулевого лимита должен стоять В ЗНАМЕНАТЕЛЕ, а не в `and` слева: " + flat
)
assert "humanizePercentage" in " ".join(ann.values())
expr, ann = by_name["PostgresLowHotUpdateRatio"]
flat = " ".join(expr.split())
assert flat.startswith("rate(pg_table_write_amplification_tup_hot_upd"), (
"слева должен стоять числитель доли HOT, иначе печатается rate(tup_upd): " + flat
)
assert "/ (rate(pg_table_write_amplification_tup_upd[6h]) > 0.5)" in flat, (
"гейт по объёму апдейтов должен жить в знаменателе — он же защищает от 0/0: " + flat
)

View file

@ -1,36 +0,0 @@
"""Продуктовый счётчик экспорта отчётов (#3471): выгрузок §22-форсайта / ТЗ.
Мера кардинальности та же, что в `test_metrics.py`: единственный лейбл
`format`, фиксированный литерал из `Literal[...]` сигнатуры эндпоинта
(`export_parcel_forecast`) плюс одно статичное значение `best_layouts_pdf`
(ТЗ на проектирование) не кадастровый номер и не идентификатор пользователя.
"""
from __future__ import annotations
from prometheus_client import REGISTRY, generate_latest
from app.observability import metrics as m
def test_reports_exported_counter_has_bounded_format_label() -> None:
assert tuple(m.REPORTS_EXPORTED._labelnames) == ("format",) # type: ignore[attr-defined]
def test_reports_exported_counter_increments_per_format() -> None:
def _value(fmt: str) -> float:
return (
REGISTRY.get_sample_value("sitefinder_reports_exported_total", {"format": fmt}) or 0.0
)
before_pdf = _value("pdf")
before_layouts = _value("best_layouts_pdf")
m.REPORTS_EXPORTED.labels(format="pdf").inc()
m.REPORTS_EXPORTED.labels(format="best_layouts_pdf").inc()
assert _value("pdf") - before_pdf == 1.0
assert _value("best_layouts_pdf") - before_layouts == 1.0
body = generate_latest(REGISTRY).decode()
assert "sitefinder_reports_exported_total" in body

View file

@ -18,7 +18,6 @@ Pure / mock-based — без реальной сети и БД. Покрывае
from __future__ import annotations
import hashlib
import io
from contextlib import contextmanager
from typing import Any
@ -97,64 +96,6 @@ def test_map_load_index_unknown_and_missing() -> None:
assert rw._map_load_index({"sc_indexload_id": "мусор"}) is None
# ── _stable_external_id (#3322: сессионный fid раздувал таблицу ×10) ───────────
def _wfs_feature(fid: str, name: str, lon: float, lat: float) -> dict[str, Any]:
return {
"id": fid,
"geometry": {"type": "Point", "coordinates": [lon, lat]},
"properties": {"sc_name": name},
}
def test_stable_external_id_ignores_session_fid() -> None:
"""Разные сессионные fid + одинаковые атрибуты → ОДИН ключ (регрессия #3322)."""
a = _wfs_feature("sc_points_fullview.fid--1a2b3c", "ПС 110/10 Уктус", 60.47123, 56.77456)
b = _wfs_feature("sc_points_fullview.fid--9f8e7d", "ПС 110/10 Уктус", 60.47123, 56.77456)
key_a = rw._stable_external_id(a, a["properties"])
assert key_a == rw._stable_external_id(b, b["properties"])
# Проверка ПО ЗНАЧЕНИЮ: ключ = sha256 по «имя|напряжение|lon_e5|lat_e5»,
# не fid. Тот же seed повторён в data/sql/99c_power_supply_centers_dedup.sql.
expected = "h:" + hashlib.sha256("уктус|110/10|6047123|5677456".encode()).hexdigest()[:16]
assert key_a == expected == "h:844e54f0152d2799"
def test_stable_external_id_differs_on_attributes() -> None:
"""Одинаковый fid, разные атрибуты (координата / имя) → РАЗНЫЕ ключи."""
base = _wfs_feature("sc_points_fullview.fid--same", "ПС 110/10 Уктус", 60.47123, 56.77456)
moved = _wfs_feature("sc_points_fullview.fid--same", "ПС 110/10 Уктус", 60.47124, 56.77456)
renamed = _wfs_feature("sc_points_fullview.fid--same", "ПС 110/10 Северная", 60.47123, 56.77456)
keys = {rw._stable_external_id(f, f["properties"]) for f in (base, moved, renamed)}
assert len(keys) == 3
assert rw._stable_external_id(moved, moved["properties"]) == "h:8c5b5fa5f35c651b"
assert rw._stable_external_id(renamed, renamed["properties"]) == "h:297e00f6e0d4f3b8"
def test_stable_external_id_no_geometry() -> None:
"""Фича без геометрии: координатные компоненты пустые, ключ всё равно стабилен."""
f: dict[str, Any] = {"id": "fid--x", "properties": {"sc_name": "ПС 110/10 Уктус"}}
assert rw._stable_external_id(f, f["properties"]) == "h:eb91917f35aff23f"
def test_coord_e5_rounds_on_binary_double_not_decimal() -> None:
"""Округление по ДВОИЧНОМУ double, не по десятичному представлению.
64.423605*1e5 == 6442360.499999999 6442360; «по десятичному» вышло бы 6442361
(так считал бы round(ST_X(geom)::numeric*100000) расхождение на 0.19% реальных
координат). 99c обязана давать те же цифры, поэтому семантика закреплена тестом.
"""
assert rw._coord_e5(64.423605) == "6442360"
assert rw._coord_e5(-64.423605) == "-6442360"
# 60.123455*1e5 == ровно 6012345.5 → полукругление ОТ нуля, симметрично знаку.
assert rw._coord_e5(60.123455) == "6012346"
assert rw._coord_e5(-60.123455) == "-6012346"
assert rw._coord_e5(60.6) == "6060000"
assert rw._coord_e5(None) == ""
# ── sanitize_tp_capacity_mva (кВА-санитайз) ───────────────────────────────────

View file

@ -1,68 +0,0 @@
"""Бинды SQL-стейтментов ЕЭСК-лоадера обязаны совпадать с передаваемыми params.
ПОЧЕМУ ЭТОТ ТЕСТ СУЩЕСТВУЕТ. 18.08-02.09.2026 все 71 UPDATE резервов ПС
падали, а задача оставалась зелёной: упоминание СНЯТОГО параметра load_pct
в SQL-КОММЕНТАРИИ (в синтаксисе «двоеточие+имя») SQLAlchemy text() распарсил
как обязательный бинд, params его не содержал, каждая строка падала на
compile, а per-row except глотал это как «битую строку». ЕЭСК-резервы не
обновлялись две недели, freshness этого не видел (задача done).
Тест ловит МЕХАНИЗМ: собирает все text()-стейтменты модуля и сверяет их
бинд-имена с ключами словаря params из того же контекста. Никакой БД не
нужно дефект живёт на этапе компиляции стейтмента.
"""
from __future__ import annotations
import re
from pathlib import Path
from sqlalchemy import text
LOADER = (
Path(__file__).resolve().parents[1]
/ "app"
/ "services"
/ "site_finder"
/ "eesk_reserve_loader.py"
)
def _extract_update_sql() -> str:
"""Достаёт текст UPDATE power_supply_centers из исходника лоадера."""
src = LOADER.read_text(encoding="utf-8")
m = re.search(r'text\("""\s*(UPDATE power_supply_centers.*?)"""\)', src, re.S)
assert m, "UPDATE power_supply_centers не найден в лоадере"
return m.group(1)
def _extract_params_keys() -> set[str]:
"""Ключи словаря params, который передаётся в этот execute."""
src = LOADER.read_text(encoding="utf-8")
m = re.search(r"params = \{(.*?)\}", src, re.S)
assert m, "словарь params не найден"
return set(re.findall(r'"([a-z_]+)":', m.group(1)))
def test_update_binds_are_subset_of_params() -> None:
"""Каждый бинд стейтмента обязан приходить из params — иначе UPDATE падает
на КАЖДОЙ строке, и per-row SAVEPOINT-except прячет это под «битую строку»."""
stmt = text(_extract_update_sql())
binds = {p.key for p in stmt._bindparams.values()}
params = _extract_params_keys()
missing = binds - params
assert not missing, (
f"стейтмент требует биндов {sorted(missing)}, которых нет в params — "
"все строки батча упадут молча. Частая причина: упоминание "
"«двоеточие+имя» в SQL-комментарии (text() парсит бинды и там)."
)
def test_comment_does_not_reintroduce_phantom_bind() -> None:
"""Регресс 18.08: снятый параметр упомянули в комментарии в живом
синтаксисе бинда. Проверяем ЗНАЧЕНИЕМ: компиляция с ровно теми params,
что собирает лоадер, не требует ничего лишнего."""
stmt = text(_extract_update_sql())
have = _extract_params_keys()
for bp in stmt._bindparams.values():
assert bp.key in have, f"фантомный бинд из комментария: {bp.key!r}"

257
backend/uv.lock generated
View file

@ -3,16 +3,16 @@ revision = 3
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.15' and sys_platform == 'win32'",
"python_full_version >= '3.15' and sys_platform == 'emscripten'",
"python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version == '3.14.*' and sys_platform == 'win32'",
"python_full_version >= '3.15' and sys_platform == 'emscripten'",
"python_full_version == '3.14.*' and sys_platform == 'emscripten'",
"python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version < '3.13' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
"python_full_version < '3.13' and sys_platform == 'emscripten'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
@ -581,52 +581,55 @@ wheels = [
[[package]]
name = "cryptography"
version = "50.0.1"
version = "47.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" },
{ url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" },
{ url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" },
{ url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" },
{ url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" },
{ url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" },
{ url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" },
{ url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" },
{ url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" },
{ url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" },
{ url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" },
{ url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" },
{ url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" },
{ url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" },
{ url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" },
{ url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" },
{ url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" },
{ url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" },
{ url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" },
{ url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" },
{ url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" },
{ url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" },
{ url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" },
{ url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" },
{ url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" },
{ url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" },
{ url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" },
{ url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" },
{ url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" },
{ url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" },
{ url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" },
{ url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" },
{ url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" },
{ url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" },
{ url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" },
{ url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" },
{ url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" },
{ url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" },
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
{ url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" },
{ url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" },
{ url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" },
{ url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" },
{ url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" },
{ url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" },
{ url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" },
{ url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" },
{ url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" },
{ url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" },
{ url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" },
{ url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" },
{ url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" },
{ url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" },
{ url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" },
{ url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" },
{ url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" },
{ url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" },
{ url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" },
{ url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" },
{ url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" },
{ url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" },
{ url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" },
{ url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" },
{ url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" },
{ url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" },
{ url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" },
{ url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" },
{ url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" },
{ url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" },
{ url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" },
{ url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" },
{ url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" },
{ url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" },
{ url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" },
{ url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" },
{ url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" },
{ url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" },
{ url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" },
]
[[package]]
@ -1055,11 +1058,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.19"
version = "3.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
]
[[package]]
@ -1398,14 +1401,14 @@ wheels = [
[[package]]
name = "mako"
version = "1.4.1"
version = "1.3.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" }
sdist = { url = "https://files.pythonhosted.org/packages/59/8a/805404d0c0b9f3d7a326475ca008db57aea9c5c9f2e1e39ed0faa335571c/mako-1.3.11.tar.gz", hash = "sha256:071eb4ab4c5010443152255d77db7faa6ce5916f35226eb02dc34479b6858069", size = 399811, upload-time = "2026-04-14T20:19:51.493Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" },
{ url = "https://files.pythonhosted.org/packages/68/a5/19d7aaa7e433713ffe881df33705925a196afb9532efc8475d26593921a6/mako-1.3.11-py3-none-any.whl", hash = "sha256:e372c6e333cf004aa736a15f425087ec977e1fcbd2966aae7f17c8dc1da27a77", size = 78503, upload-time = "2026-04-14T20:19:53.233Z" },
]
[[package]]
@ -1770,73 +1773,71 @@ wheels = [
[[package]]
name = "pillow"
version = "12.3.0"
version = "12.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
{ url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
{ url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
{ url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
{ url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
{ url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
{ url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
{ url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
{ url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
{ url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
{ url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
{ url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
{ url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
{ url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
{ url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
{ url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
{ url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
{ url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
{ url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
{ url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
{ url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
{ url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
{ url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
{ url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
{ url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
{ url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
{ url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
{ url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
{ url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
{ url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
{ url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
{ url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
{ url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
]
[[package]]
@ -2072,16 +2073,16 @@ wheels = [
[[package]]
name = "pydantic-settings"
version = "2.15.0"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" }
sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" },
{ url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" },
]
[[package]]
@ -2797,15 +2798,15 @@ wheels = [
[[package]]
name = "starlette"
version = "1.6.0"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
]
[[package]]
@ -2941,11 +2942,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.7.0"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
@ -3109,7 +3110,7 @@ wheels = [
[[package]]
name = "weasyprint"
version = "69.0"
version = "68.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
@ -3121,9 +3122,9 @@ dependencies = [
{ name = "tinycss2" },
{ name = "tinyhtml5" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/53/dcc3885c2f7a47faa45f6b8b801412f5f9e055173a52801ef01c09943c5a/weasyprint-69.0.tar.gz", hash = "sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c", size = 1549834, upload-time = "2026-06-02T14:42:17.765Z" }
sdist = { url = "https://files.pythonhosted.org/packages/db/3e/65c0f176e6fb5c2b0a1ac13185b366f727d9723541babfa7fa4309998169/weasyprint-68.1.tar.gz", hash = "sha256:d3b752049b453a5c95edb27ce78d69e9319af5a34f257fa0f4c738c701b4184e", size = 1542379, upload-time = "2026-02-06T15:04:11.203Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/cb/208525c6bd5033d7b2589b55e07bec23d9c61bb00703cbaf20ef52c3811f/weasyprint-69.0-py3-none-any.whl", hash = "sha256:475951cfd917014de6d4d005caff48c6aa867e7e42b80cd5b16a0484a1609ee6", size = 322872, upload-time = "2026-06-02T14:42:15.871Z" },
{ url = "https://files.pythonhosted.org/packages/dd/dd/14eb73cea481ad8162d3b18a4850d4a84d6e804a22840cca207648532265/weasyprint-68.1-py3-none-any.whl", hash = "sha256:4dc3ba63c68bbbce3e9617cb2226251c372f5ee90a8a484503b1c099da9cf5be", size = 319789, upload-time = "2026-02-06T15:04:09.189Z" },
]
[[package]]

View file

@ -1,54 +0,0 @@
# ═══════════════════════════════════════════════════════════════════════════
# caddy/deploy-window.caddy.snippet — ответ на окно деплоя (#3274)
#
# Импортируется ВНУТРЬ `handle_errors 502 503 504 { ... }` (см. apps.caddy):
# сам по себе снипет ничего не перехватывает, он только решает, ЧТО отдать,
# когда апстрим не отвечает.
#
# ЧТО ЭТО ЛЕЧИТ, А ЧТО НЕТ. Каждый деплой tradein-frontend/tradein-backend
# оставляет окно 3090 с, в котором контейнера просто нет: Caddy набирает
# новый апстрим сразу, тот ещё не слушает (замер по access-логам, #3274 —
# все 502 кластеризуются на окнах мержа, duration < 2 мс = мгновенный отказ
# соединения). Снипет НЕ УБИРАЕТ окно — он меняет то, что видит человек и
# клиент внутри окна. Настоящее лечение (готовность нового контейнера до
# переключения) — п.1 issue, решение владельца, здесь его нет.
#
# ПОЧЕМУ 503, А НЕ 502. 502 значит «апстрим ответил мусором» — постоянная
# поломка; поисковик по нему выкидывает страницу из индекса, клиентские
# библиотеки не ретраят. 503 + `Retry-After: 30` — стандартный код «временно
# недоступен, приходи через 30 секунд»: Googlebot держит страницу в индексе,
# HTTP-клиенты понимают, что повтор осмыслен.
#
# ПОЧЕМУ ДВА ТЕЛА. `/trade-in/api/*` вызывают из JS и внешних клиентов — они
# парсят JSON, и HTML-страница у них превращается в ошибку разбора вместо
# читаемого статуса. Всё остальное открывает человек браузером.
#
# ВНЕШНИХ РЕСУРСОВ В СТРАНИЦЕ НЕТ ВООБЩЕ — ни шрифта, ни CSS-файла, ни
# картинки. В окне деплоя они пришли бы с того же мёртвого апстрима, и
# страница-заглушка отрисовалась бы голым текстом. Отсюда же инлайновые
# `style=` вместо блока `<style>`: фигурные скобки в теле `respond` Caddy
# пытается разобрать как плейсхолдеры.
# ═══════════════════════════════════════════════════════════════════════════
@deployWindowApi path /trade-in/api/*
handle @deployWindowApi {
header Content-Type "application/json; charset=utf-8"
header Retry-After "30"
respond `{"detail":"Сервис обновляется, повторите запрос через минуту","error":"service_unavailable","retry_after":30}` 503
}
handle {
header Content-Type "text/html; charset=utf-8"
header Retry-After "30"
respond `<!doctype html>
<html lang="ru">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Сервис обновляется</title>
<body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;background:#fff;color:#111">
<main style="max-width:30rem;padding:2rem;text-align:center">
<h1 style="font-size:1.25rem;font-weight:600;margin:0 0 .75rem">Сервис обновляется</h1>
<p style="margin:0;line-height:1.6;color:#444">Это занимает около минуты. Обновите страницу чуть позже — введённые данные не потеряются.</p>
</main>
` 503
}

View file

@ -42,40 +42,6 @@
# код.
# ═══════════════════════════════════════════════════════════════════════════
# ── (tradein_frontend_retry) — подмена контейнера фронта без 502 (#3274) ─────
#
# ЧТО ЭТО. Ретрай ПОДКЛЮЧЕНИЯ к tradein-frontend: пока идёт подмена
# контейнера, Caddy не отдаёт ошибку сразу, а до 2 с переспрашивает апстрим с
# шагом 100 мс. Импортируется ВНУТРЬ каждого `reverse_proxy tradein-frontend`
# в этом файле (9 блоков: gendsgn.ru/trade-in/* и все публичные пути
# meraocenka.ru).
#
# ПОЧЕМУ ЭТО НЕ ТО, ЧТО ОТВЕРГНУТО В #3274. Там `lb_try_duration` отвергнут
# для окна 3090 с — держать посетителя минуту в ожидании хуже честной
# ошибки, инструмент рассчитан на разрыв в сотни миллисекунд. Правка в
# deploy-tradein.yml (frontend вынесен из общего `up -d`) СНАЧАЛА сводит окно
# к этим сотням миллисекунд, и только после этого ретрай становится
# применим. Числа, на которых это стоит (замеры 11.09):
# - прод, ОДИН сервис в `up -d`: контейнер создан 16:42:17.5 → запущен
# 16:42:18.0 — 0,5 с;
# - прод, ПАЧКА сервисов в одном `up -d`: создан 15:01:40 → запущен
# 15:02:10 — 30 с (фаза start ждёт готовности зависимостей, а старый
# контейнер снесён ещё в фазе create);
# - стенд (реальный образ фронта + Caddy 2), одиночная подмена: без ретрая
# 1 × 502, с ретраем 241/241 × 200, один запрос подождал 0,55 с.
#
# ПОТОЛОК. 2 с — это ЦЕНА ОЖИДАНИЯ при настоящей аварии: если фронт лежит
# долго, каждый запрос сначала висит 2 с и лишь потом получает 503-заглушку
# (../deploy-window.caddy.snippet). Поэтому не «побольше на всякий случай»:
# запас над измеренными 0,5 с четырёхкратный, дальше растёт только вред.
# Бэкенду (tradein-backend) этот приём НАМЕРЕННО не дан: его старт — единицы
# секунд (uvicorn + подключение к БД), там 2 с не хватит, а больше — уже тот
# самый вред. Его окно закрывается отдельно, не этой правкой.
(tradein_frontend_retry) {
lb_try_duration 2s
lb_try_interval 100ms
}
# Caddy config.
#
# - gendsgn.ru — main production site, auto-TLS via Let's Encrypt.
@ -133,35 +99,6 @@ gendsgn.ru {
format json
}
# #3471: копия access-лога на stdout. За 3 часа наблюдения в
# gendesign-caddy-1 не было НИ ОДНОЙ строки http.log.access — только ACME/
# TLS/warn от reverse_proxy, статусы/латентность/RPS прокси не видны в
# Loki вообще. Alloy на этом хосте уже собирает stdout контейнеров через
# journald (`loki.source.journal "host"`,
# ops/metrics/alloy/alloy-apps.alloy) — второй bind-монт не нужен.
#
# Секреты в query (`?secret=`, `?token=` и т.п., см. инцидент #3154) режет
# УЖЕ РАБОТАЮЩИЙ `loki.process.scrub_credentials` (#3354, тот же список
# имён параметров, что в app/core/log_scrub.py) — он стоит на пути ЛЮБОГО
# journal-лога, включая этот, поэтому второй слой скраба здесь не заводим.
#
# `log_skip` — общий для ВСЕХ логгеров сайта флаг на запрос (Caddy не даёт
# скипать выборочно по конкретному логгеру), поэтому /health и статика
# Next пропадают заодно и из файлового gendsgn.ru.log выше, не только из
# копии на stdout. Это осознанный побочный эффект, не только экономия:
# /health — 32% строк gendsgn.ru.log в измеренном сегменте (12.09.2026,
# 240 из 742 строк за ~4ч, аптайм-монитор раз в минуту) без диагностической
# ценности, и именно он гонит файловый лог через 50MiB roll_size так часто.
log access_stdout {
output stdout
format json
}
@noisy_access_log {
path /health /_next/static/*
}
log_skip @noisy_access_log
route {
# `/metrics` наружу не отдаётся — ни бэкендом, ни фронтом (#3078).
# Сегодня он и так недостижим: бэкенду «Птицы» ниже уходят только
@ -200,7 +137,6 @@ gendsgn.ru {
@uipreview path /trade-in/ui-preview/* /trade-in/_next/static/*
handle @uipreview {
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
# #2558 review: тот же периметр-scrub, что и у /trade-in/api/* и
# @tradein ниже — этот блок тоже теперь ДО basic_auth, клиент
# мог бы прислать свой X-Authenticated-User. Сейчас инертно
@ -239,28 +175,10 @@ gendsgn.ru {
# "удалить заголовок" (Caddyfile reverse_proxy directive: `-<field>` =
# delete) — корректное поведение не должно зависеть от того, как именно
# Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции.
# X-Internal-Auth-Secret ЗДЕСЬ БОЛЬШЕ НЕ ПОДСТАВЛЯЕТСЯ (#3324). До этой
# правки Caddy инжектил его в КАЖДЫЙ запрос этого хопа — включая
# анонимный, до всякого логина trade-in.
#
# ПОЧЕМУ ОН БЫЛ МЁРТВЫМ. Единственное место, где backend читает этот
# ЗАГОЛОВОК, — `app/core/rbac.py` (legacy trusted-header ветка): он
# сверяется ТОЛЬКО после того, как в запросе нашёлся непустой
# X-Authenticated-User (иначе ветка отдаёт 401 раньше, на «no
# authenticated user»). А X-Authenticated-User на этом же хопе строкой
# ниже удаляется — то есть пара «имя + секрет» через Caddy прийти не
# может по построению, сравнение недостижимо. Второй потребитель
# секрета, приёмник вебхуков GlitchTip (`app/api/v1/glitchtip.py`),
# берёт его из query-параметра `?secret=`, а не из заголовка, и на эту
# правку не реагирует.
#
# ЧТО ОТСЕКАЕТ ПОДДЕЛКУ ЗАГОЛОВКОВ ИЗНУТРИ gendesign_shared — проверка
# в rbac.py, а не факт подстановки в Caddy; она остаётся нетронутой.
# Инжект работал в обратную сторону: раздавал внутренний секрет по
# анонимному пути (он же виден в env контейнера Caddy). Легитимный
# dual-mode трафик — внутрисетевой (`docker exec tradein-backend curl
# -H 'X-Authenticated-User: …' -H 'X-Internal-Auth-Secret: …'`,
# см. auth/roles.yaml) — ходит мимо Caddy и сам несёт оба заголовка.
# X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается
# из env (Set-операция с непустым значением, никак не связана с auth-гейтом
# basic_auth), это единственное, что теперь отсекает подделку заголовков
# изнутри gendesign_shared network для legacy dual-mode пути.
handle /trade-in/api/* {
# `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
@ -268,6 +186,7 @@ gendsgn.ru {
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {
header_up -X-Authenticated-User
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
@ -304,15 +223,10 @@ gendsgn.ru {
handle @tradein {
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
# См. комментарий над /trade-in/api/* выше — та же логика (явное
# удаление вместо Set с пустым {http.auth.user.id}), и по той же
# причине здесь больше нет инжекта X-Internal-Auth-Secret
# (#3324). На этом хопе он был мёртв ещё очевиднее: адресат —
# Next-сервер tradein-frontend, в его коде заголовок не читается
# и дальше в backend не пробрасывается (SSR не форвардит
# входящие заголовки — `headers()` во фронте не используется).
# удаление вместо Set с пустым {http.auth.user.id}).
header_up -X-Authenticated-User
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
@ -332,32 +246,6 @@ gendsgn.ru {
}
}
}
# Окно деплоя (#3274) — ТОЛЬКО для /trade-in. Каждый мерж в tradein
# оставляет 3090 с, в которые контейнера нет и посетитель видит голый 502
# (замер по access-логам: 23 × 502 на этом домене за 4 суток, все —
# на окнах деплоя). Снипет подменяет это на 503 + Retry-After и читаемое
# тело; разбор «что лечится, а что нет» — в самом снипете.
#
# ГЕЙТ ПО ПУТИ ОБЯЗАТЕЛЕН: `handle_errors` объявляется на весь site-блок,
# а этот блок обслуживает ещё и Site Finder («Птица») — его апстримы
# (backend, frontend) деплоятся отдельным пайплайном и в задачу не входят.
# Пути вне матчера не попадают ни в один вложенный handle, ошибка остаётся
# необработанной, и Caddy отдаёт ровно то, что отдавал раньше. Проверено на
# живом Caddy 2.11.3 (dead-upstream 127.0.0.1:9): `/` и `/api/v1/*` —
# прежний пустой 502, `/trade-in/*` — новый 503.
#
# Матчер здесь сверяется с ИСХОДНЫМ путём запроса, а не с переписанным:
# для error-маршрута Caddy восстанавливает запрос, каким он пришёл. Поэтому
# `/trade-in/api/*` внутри снипета матчится, хотя на основном маршруте до
# падения апстрима уже отработал `uri strip_prefix /trade-in`. Тоже
# проверено на стенде, а не выведено из документации.
handle_errors 502 503 504 {
@tradeinScope path /trade-in /trade-in/*
handle @tradeinScope {
import ../deploy-window.caddy.snippet
}
}
}
www.gendsgn.ru {
@ -404,24 +292,6 @@ meraocenka.ru {
output file /var/log/caddy/meraocenka.ru.log
}
# #3471: та же копия access-лога на stdout, что у gendsgn.ru — см.
# развёрнутый комментарий там (Alloy/journald, scrub_credentials #3354,
# log_skip общий на все логгеры сайта). `/trade-in/_next/static/*` — 5.1%
# строк meraocenka.ru.log в измерении 12.09.2026 (3782 из 73810 за
# ~17.6 суток без ротации, roll_size здесь вообще не настроен) — статика
# Next не может быть источником 5xx бэкенда. `/health` на этом домене не
# проксируется (allowlist ниже отдаёт по нему 404), но матчер добавлен для
# единообразия с gendsgn.ru — вреда от него ноль.
log access_stdout {
output stdout
format json
}
@noisy_access_log {
path /health /trade-in/_next/static/*
}
log_skip @noisy_access_log
# `/metrics` наружу не отдаётся (#3078). Здесь действует белый список и
# финальный `handle { respond 404 }`, так что путь и без этой строки не
# проходит, — но у бэкенда «Меры» он ОТКРЫТ без авторизации ради агента
@ -438,7 +308,6 @@ meraocenka.ru {
handle / {
rewrite * /trade-in/mera-public
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
# Тот же периметр-скраб, что у @uipreview (:87) и @tradein ниже.
# Этот блок вообще не под basic_auth, поэтому анонимный клиент
# тем более может прислать свой X-Authenticated-User. Сейчас
@ -478,18 +347,14 @@ meraocenka.ru {
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
# «/», и запрос закольцуется.
# /sitemap.xml дописан той же строкой, а не отдельным handle (как
# robots.txt ниже): Next отдаёт его по /trade-in/mera-public/sitemap.xml
# (src/app/mera-public/sitemap.ts) — ровно в форме, которую уже собирает
# rewrite `/trade-in/mera-public{path}` ниже. robots.txt так не смог бы:
# Next отдаёт его КОРНЕВЫМ /trade-in/robots.txt (конвенция Next —
# robots.txt существует только в корне app/, не под mera-public/), эта
# форма под `{path}` не попадает — отсюда отдельный handle у него.
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business /sitemap.xml
# `/v3` — ВРЕМЕННОЕ превью второго варианта дизайна, а не публичная
# страница: владелец сравнивает его с текущим лэндингом. Оно `noindex` и
# ни с одной страницы на него нет ссылки. Убрать эту строку в тот момент,
# когда вариант выберут и он станет корнем.
@meraPages path /estimate /oferta /refund /privacy /v3
handle @meraPages {
rewrite * /trade-in/mera-public{path}
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
header_up -X-Authenticated-User
}
}
@ -500,65 +365,9 @@ meraocenka.ru {
# голый 404 (так было и до этого PR, с момента #2615). Заодно это
# замыкает цепочку для длинных адресов со слэшем: они приходят на короткий
# со слэшем и здесь нормализуются.
# БАГ, найденный замером на живом проде 10.09.2026:
# https://meraocenka.ru/articles/?utm_source=vc -> Location:
# https://meraocenka.ru/articles — query терялся, потому что
# `redir * /{re.shortslash.1}` собирает цель заново из regex-захвата
# пути и не переносит исходную query-строку. Вред тихий: статьи
# публикуются с UTM-метками, а мессенджеры и автолинкификаторы дописывают
# слэш к скопированной ссылке — то есть именно трафик по опубликованному
# посту терял атрибуцию, и 301 при этом честно отрабатывал (просто не
# туда), ошибки никто не видел.
#
# Тот же приём, что у @meraLongPages/@meraV3Gone ниже и выше: `uri
# strip_suffix /` снимает ровно завершающий слэш (сам путь уже сужен
# матчером до канонического короткого вида), `redir * {uri} permanent`
# переносит query как есть. Обёртка `route` обязательна по той же
# причине, что у соседей — внутри `handle` порядок директив определяет
# Caddy, и без неё `redir` выполнился бы раньше `uri`.
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs|business)/$
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|v3)/$
handle @meraShortSlash {
route {
uri strip_suffix /
redir * {uri} permanent
}
}
# `/v3` — АДРЕС ПРЕВЬЮ, КОТОРОГО БОЛЬШЕ НЕТ. С 29.08.2026 выбранный вариант
# дизайна стоит в корне, каталог `v3/` во фронте удалён, и проксировать
# сюда нечего — Next отдал бы 404. Но адрес жил на публичном домене больше
# двух недель: владелец сравнивал по нему версии, и ссылка почти наверняка
# осталась в закладках и в переписке. Поэтому 301 на корень, а не молчание
# белого списка: человек по старой ссылке попадает ровно на то, что по ней
# и смотрел, только теперь это главная.
#
# ЧЕТЫРЕ ФОРМЫ, А НЕ ДВЕ. Превью раздавалось и коротким `/v3`, и длинным
# `/trade-in/mera-public/v3` — вторая пара до 29.08.2026 жила в
# @meraLongPages и оттуда выбыла вместе со страницей. Короткие формы её
# не подхватывают (матчер `path` требует точного совпадения), поэтому
# длинный адрес превью падал в catch-all 404 — тот же класс дефекта,
# ради которого длинные адреса вообще оставлены живыми. Все четыре формы
# ведут в один корень, поэтому и матчер один.
#
# QUERY ПЕРЕНОСИТСЯ — `redir * {uri}`, как в @meraLongPages, а не
# `redir * /`, как было здесь до этой правки. Причина ровно та же, что
# записана у @meraLongPages: по разосланной ссылке приходят с UTM-метками,
# и `/v3?utm=…` не должен терять атрибуцию на редиректе. Оба
# `uri strip_prefix` срезают путь целиком (для `/v3` — до пустого, для
# `/v3/` — до `/`), а `{uri}` при пустом пути даёт `/`, поэтому запрос без
# параметров отдаёт чистый `Location: /` без хвоста `?`. Обёртка `route`
# обязательна по той же причине, что у @meraLongPages: внутри `handle`
# порядок директив определяет Caddy, и без неё `redir` выполнится раньше
# `uri`. Проверено на живом Caddy 2.11 (прод — `caddy:2`): все четыре формы
# с параметрами и без дают 301 на `/` с сохранённой query, а лишний
# сегмент (`/trade-in/mera-public/v3/evil`) по-прежнему падает в 404.
@meraV3Gone path /v3 /v3/ /trade-in/mera-public/v3 /trade-in/mera-public/v3/
handle @meraV3Gone {
route {
uri strip_prefix /trade-in/mera-public
uri strip_prefix /v3
redir * {uri} permanent
}
redir * /{re.shortslash.1} permanent
}
# Длинные адреса поддерева → 301 на короткие. Один канонический адрес у
@ -604,7 +413,7 @@ meraocenka.ru {
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
# (бесконечный цикл; поймано на локальном стенде).
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/v3 /trade-in/mera-public/v3/
handle @meraLongPages {
route {
uri strip_prefix /trade-in/mera-public
@ -621,42 +430,8 @@ meraocenka.ru {
# `_next/*` открыл бы анонимам ещё и `/_next/image` (оптимизация картинок,
# CPU-нагрузка по запросу), который на лэндинге не используется вообще:
# next/image в tradein-mvp/frontend/src/app/mera-public/ не импортируется.
#
# #3324: внутри разрешённой статики закрыто ПОДДЕРЕВО ЧУЖИХ МАРШРУТОВ.
# App Router раскладывает код постранично: `chunks/app/<маршрут>/page-<hash>.js`
# (замер на проде 02.09.2026 — лэндингу нужны РОВНО `chunks/app/layout-*`,
# `chunks/app/error-*`, `chunks/app/global-error-*`, `chunks/app/mera-public/*`
# и общие хэш-чанки прямо в `chunks/`). Всё остальное под `chunks/app/` —
# это /admin, /scrapers, /team, /history, /cache, /versions, /v2,
# /sale-share, /login, /ui-preview: страницы закрытого контура, которые
# аноним с публичного домена качал целиком и читал в них имена внутренних
# ручек. Матчер отсекает ровно «есть подкаталог, и он не mera-public» —
# `not` вместо негативного lookahead, RE2 его не поддерживает.
#
# `_not-found` в исключениях — это не маршрут продукта, а внутренняя
# страница Next (404 при клиентской навигации по битому href). Закрытого
# кода не несёт, а без исключения клиентский переход падал бы в
# error-boundary вместо честного «не найдено».
#
# Ограничение честно: это убирает КОД, но не имена файлов — карта чанков
# лежит в `webpack-*.js`, который лэндингу нужен, поэтому перечень
# маршрутов из неё по-прежнему вычитывается. По той же причине оставлены
# без фильтра и `static/css/app/<маршрут>/*`: это тот же класс «имена, а не
# код», и городить второй матчер ради него смысла нет. Полное разделение —
# split build (отдельный public-бандл), отдельная задача.
#
# `respond` внутри `handle` выполняется раньше `reverse_proxy` (порядок
# директив Caddy), поэтому вложенный матчер не зависит от сортировки
# handle-блоков между собой.
handle /trade-in/_next/static/* {
@foreignRouteChunk {
path_regexp ^/trade-in/_next/static/chunks/app/[^/]+/
not path /trade-in/_next/static/chunks/app/mera-public/* /trade-in/_next/static/chunks/app/_not-found/*
}
respond @foreignRouteChunk 404
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
header_up -X-Authenticated-User
}
}
@ -667,53 +442,6 @@ meraocenka.ru {
handle /favicon.ico {
rewrite * /trade-in/favicon.ico
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
header_up -X-Authenticated-User
}
}
# robots.txt — ОТДЕЛЬНЫЙ handle, а не строка в @meraPages выше: у Next
# robots.txt — конвенция корня app/ (src/app/robots.ts), а не
# mera-public/, поэтому rewrite `/trade-in/mera-public{path}` его не
# обслужит — нужен свой rewrite на /trade-in/robots.txt. По образцу
# favicon.ico прямо над этим блоком, включая тот же периметр-скраб
# заголовка.
handle /robots.txt {
rewrite * /trade-in/robots.txt
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
header_up -X-Authenticated-User
}
}
# og-mera.png — картинка превью ссылок (og:image). Её просят НЕ браузеры
# посетителей, а обходчики мессенджеров и соцсетей (Telegram, VK,
# WhatsApp), причём анонимно и без Referer: если путь не открыт, ссылка на
# сайт разворачивается без картинки, и понять это по логам приложения
# нельзя — запрос просто не доходит.
#
# Отдельный handle по той же причине, что у robots.txt: файл лежит в
# `public/` фронта и раздаётся Next'ом по basePath-корню
# (/trade-in/og-mera.png), а не под /trade-in/mera-public{path}, куда
# рерайтит @meraPages. Адрес намеренно короткий: он попадает в разметку
# каждой публичной страницы и расходится по чужим кэшам, так что менять
# его потом дорого.
handle /og-mera.png {
rewrite * /trade-in/og-mera.png
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
header_up -X-Authenticated-User
}
}
# logo-mera.png — логотип организации из микроразметки (Organization.logo).
# Отдельный файл от og-mera.png выше: превью ссылки широкое, а логотип
# поисковик обрезает близко к квадрату. Свой handle по той же причине —
# файл лежит в `public/` и раздаётся по basePath-корню.
handle /logo-mera.png {
rewrite * /trade-in/logo-mera.png
reverse_proxy tradein-frontend:3000 {
import tradein_frontend_retry
header_up -X-Authenticated-User
}
}
@ -738,12 +466,9 @@ meraocenka.ru {
# strip_prefix — та же причина, что у B2B-хопа (:127): basePath Next'а не
# часть маршрута FastAPI.
#
# X-Internal-Auth-Secret здесь НЕ подставляется: публичные ручки его не
# проверяют, а инжектить внутренний секрет в хоп с анонимного домена —
# расширять доверие без нужды. С #3324 это уже не отличие от B2B-блока
# gendsgn.ru, а общее правило: инжекта не осталось нигде в Caddyfile —
# заголовок нужен только внутрисетевому dual-mode трафику, который ходит
# мимо прокси и несёт его сам.
# X-Internal-Auth-Secret здесь НЕ подставляется (в отличие от :130):
# публичные ручки его не проверяют, а инжектить внутренний секрет в хоп с
# анонимного домена — расширять доверие без нужды.
handle /trade-in/api/public/* {
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {
@ -756,19 +481,6 @@ meraocenka.ru {
handle {
respond 404
}
# Окно деплоя (#3274). В отличие от gendsgn.ru гейт по пути не нужен: все
# апстримы этого блока — контейнеры МЕРЫ (tradein-frontend/tradein-backend),
# чей деплой и создаёт окно. 15 × 502 за 4 суток, все на окнах мержа.
#
# Белый список выше это НЕ ослабляет. Во-первых, `handle_errors`
# срабатывает только на перечисленные статусы, а отказ белого списка —
# 404. Во-вторых, `respond 404` пишет ответ напрямую и ошибкой маршрута
# вообще не является. Проверено на стенде: при мёртвом апстриме `/admin`
# по-прежнему отдаёт 404, а не страницу обновления.
handle_errors 502 503 504 {
import ../deploy-window.caddy.snippet
}
}
# Домены-спутники МЕРА → 301 на канонический meraocenka.ru.

View file

@ -105,44 +105,11 @@ metrics.gendsgn.ru {
# угадавший, — ложная отметка «принято» в чате, где сразу видно, что её
# поставил не человек. Прав в системе токен не даёт никаких.
#
# Сервис отвечает только на /ack/*, /glitchtip и /healthz; всё прочее — 404.
# Сервис отвечает только на /ack/* и /healthz; всё прочее — 404.
handle /ack/* {
reverse_proxy alert-ack:8080
}
# Резервный приёмник алертов GlitchTip (#3471). Основной получатель —
# продуктовый бэкенд на Selectel, то есть тот самый сервис, за которым эти
# алерты и следят: пока он лежит, его собственные ошибки доставлять некому.
# Этот путь живёт у другого провайдера и с чистой сетью до Telegram, поэтому
# переживает падение Selectel целиком.
#
# Секрет — в значении query-параметра, а не в пути: путь сам по себе не
# секрет, и его попадание в access-лог безопасно. Само значение вырезает
# scrub_credentials в Alloy до записи в Loki (#3154).
handle /glitchtip* {
reverse_proxy alert-ack:8080
}
# Ретранслятор Bot API продукта (#3471). Путь от Selectel до
# api.telegram.org теряет примерно каждый четвёртый короткий запрос, тот же
# замер с Beget в те же минуты — чистый. Поэтому продуктовый бот ходит в
# Telegram отсюда, а не напрямую.
#
# Путь содержит токен бота (/tg-relay/bot<TOKEN>/<method>) — он не должен
# осесть ни в файловом логе сайта, ни в stdout-копии (#3154), поэтому
# запрос помечен log_skip.
@tg_relay path /tg-relay/*
log_skip @tg_relay
handle_path /tg-relay/* {
reverse_proxy tg-relay:8080 {
# getUpdates — long-poll до ~40с, дефолтный таймаут ответа короче.
transport http {
response_header_timeout 80s
}
}
}
# Вход ОДИН — собственный вход Grafana (#3078). Внешний basic_auth снят по
# решению владельца: два запроса пароля подряд мешали работе, а Grafana имеет
# собственную аутентификацию с ролями и `GF_USERS_ALLOW_SIGN_UP=false`.

View file

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

View file

@ -1,57 +0,0 @@
-- 195_tradein_osm_roads_view.sql
-- Улицы ЕКБ на витрину сделок МЕРЫ: сторона gendesign (PR 1 из 2).
--
-- Карточка игры на лэндинге показывала ПОЛИГОН РАЙОНА — единственную геометрию,
-- которая у tradein была. Улицы живут здесь, в `osm_noise_sources_ekb`, а у
-- tradein нет ни foreign table, ни гранта. Эта миграция открывает вьюху ровно
-- по образцу соседей (`v_tradein_cad_buildings`, `v_tradein_osm_poi_ekb`,
-- 185_tradein_osm_poi_view.sql): плоский срез нужных колонок + GRANT SELECT
-- уже существующей роли `tradein_fdw_reader` (100_tradein_fdw_role.sql).
-- Новых ролей и user mapping здесь нет.
--
-- ГРАНТ СТОИТ РЯДОМ С CREATE, В ЭТОМ ЖЕ ФАЙЛЕ — И ЭТО НЕ СТИЛЬ. 2026-08-29
-- (#3227) объект пересоздали, а GRANT жил отдельной миграцией и потерялся:
-- пересоздание снимает права молча, и чтение с той стороны падает на
-- `permission denied`. То же было с `ekb_districts_geom` (194) и
-- `mv_quarter_price_index` (188). Кто будет менять эту вьюху — меняйте файл
-- целиком, вместе с грантом.
--
-- ЧТО В СРЕЗЕ И ЧЕГО В НЁМ НЕТ (замер на проде 2026-08-29):
-- * 10 025 линий, 1377 различных названий улиц. Выгрузка собиралась как
-- «источники шума», поэтому именованные улицы покрыты хорошо, а дворовые
-- проезды и служебные ОТСУТСТВУЮТ. Потребитель обязан это знать: схема из
-- этих данных — улицы района, а не полная уличная сеть.
-- * `source_type IN ('highway','water')` — только то, что рисуется на схеме.
-- Железные дороги, ЛЭП, трубопроводы, подстанции и промзона (ещё ~4 400
-- строк) сюда НЕ едут: на карточке 320 px они дают шум, а не смысл.
-- * ЗДАНИЙ ЗДЕСЬ НЕТ И НЕ БУДЕТ. `cad_buildings` — 18 307 контуров на весь
-- город; в плотном центре это 51 здание на радиус 450 м, где их в разы
-- больше. Нарисованная застройка заявляла бы полноту, которой в данных
-- нет, и показывала бы пустые кварталы там, где стоят дома.
--
-- Идемпотентна: CREATE OR REPLACE VIEW + повторяемый GRANT.
BEGIN;
-- Конвенция проекта (#2752). CREATE OR REPLACE VIEW и GRANT берут лок на объект;
-- ждать его выдачи за чужой сессией — значит собрать очередь перед приложением.
SET LOCAL lock_timeout = '5s';
CREATE OR REPLACE VIEW v_tradein_osm_roads_ekb AS
SELECT
name,
road_class,
geom
FROM osm_noise_sources_ekb
WHERE source_type IN ('highway', 'water');
GRANT SELECT ON v_tradein_osm_roads_ekb TO tradein_fdw_reader;
COMMENT ON VIEW v_tradein_osm_roads_ekb IS
'FDW-источник для tradein-mvp: улицы и вода ЕКБ для схемы улицы на витрине '
'сделок лэндинга МЕРЫ. Срез osm_noise_sources_ekb по source_type '
'IN (highway, water) — 10 025 линий, 1377 названий. Выгрузка фильтрованная '
'(«источники шума»): дворовых и служебных проездов в ней нет. Зданий нет '
'намеренно, см. шапку 195_tradein_osm_roads_view.sql. GRANT живёт в том же '
'файле — пересоздание вьюхи снимает права молча (#3227).';
COMMIT;

View file

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

View file

@ -1,137 +0,0 @@
-- 99c_power_supply_centers_dedup.sql
-- Issue #3322 — power_supply_centers раздут ×10: 4880 строк на 481 уникальный ЦП.
--
-- Причина. rosseti_wfs_loader брал external_id из feature['id'] WFS-ответа, а
-- GeoServer отдаёт СЕССИОННЫЙ fid (новый на каждый GetFeature) → ON CONFLICT
-- (source, external_id) не срабатывал ни разу, каждый weekly-прогон добавлял
-- полный набор ~488 фич заново. Починка разбора сама старые строки не убирает
-- (ON CONFLICT ничего не перезапишет, ключи не совпадут) → нужен этот бэкфилл.
--
-- Что делает файл:
-- (а) пересчитывает external_id по НОВОЙ формуле (см. ниже) для всех строк
-- source='rosseti_wfs';
-- (б) схлопывает копии: победитель группы — свежайший снапшот
-- (fetched_at DESC NULLS LAST, id DESC — DESC в PG это NULLS FIRST,
-- поэтому NULLS LAST задан ЯВНО);
-- (в) печатает числа: строк до / после, удалено, переключено на новый ключ.
-- Ожидание после прогона — ~481-488 строк (столько ЦП отдаёт источник).
-- (г) идемпотентен: на повторном прогоне ключи уже совпадают → 0 удалений,
-- 0 обновлений, «до» = «после».
--
-- ФОРМУЛА КЛЮЧА (дублирует rosseti_wfs_loader._stable_external_id — менять только
-- синхронно, иначе следующий weekly-прогон вставит второй комплект строк):
-- seed = sc_name_norm || '|' || voltage_class || '|' || lon_e5 || '|' || lat_e5
-- external_id = 'h:' || left(hex(sha256(utf8(seed))), 16)
-- где lon_e5/lat_e5 — координата в единицах 1e-5 градуса (~1 м), округление
-- floor(|v|*1e5 + 0.5) со знаком — ДВОИЧНОЕ, ровно как в питоне; пустая строка,
-- если geom отсутствует. Целые, а не форматированный float — текстовое
-- представление double в питоне и в PG различается.
--
-- ПОЧЕМУ НЕ round(...::numeric): каст float8→numeric берёт кратчайшее десятичное
-- представление, и округление идёт по нему, а не по двоичному double. На реальных
-- координатах расходится в 0.19% случаев (замер: 761 из 400000), напр. 64.423605
-- → питон 6442360 (двоичное 6442360.499999999), numeric-путь 6442361. Каждое
-- расхождение = вечный дубль ЦП, который сам не зарастёт: миграция применяется
-- один раз (_schema_migrations). Поэтому в SQL считаем ТЕМ ЖЕ double: floor/abs/
-- sign над float8 — это IEEE754, бит в бит как math.floor в питоне.
-- sha256, а не sha1: sha256 встроен в PG16, sha1 потребовал бы pgcrypto.
--
-- Байт-в-байт совпадение с питоном держится на том, что SQL НИЧЕГО не нормализует
-- сам: sc_name_norm и voltage_class — уже готовые колонки, их записал тот же
-- normalize_sc_name / parse_voltage_class. Если normalize_sc_name когда-нибудь
-- изменится, старые sc_name_norm разъедутся с новыми ключами — тогда нужен
-- повторный прогон логики этого файла (он идемпотентен, ре-apply безопасен).
--
-- Порядок: миграция ПЕРЕД деплоем кода (schema-first) — новый код после неё
-- попадает ON CONFLICT-ом в уже схлопнутые строки.
--
-- Naming: deploy.yml применяет файлы по `ls -1 data/sql/*.sql | sort`;
-- '99c_' идёт после '99b_grant_quarter_price_index_fdw.sql' ('b' < 'c').
BEGIN;
SET LOCAL lock_timeout = '5s';
DO $$
DECLARE
rows_before bigint;
names_before bigint;
rows_after bigint;
names_after bigint;
deleted bigint;
rekeyed bigint;
BEGIN
SELECT count(*), count(DISTINCT sc_name_norm)
INTO rows_before, names_before
FROM power_supply_centers
WHERE source = 'rosseti_wfs';
CREATE TEMP TABLE psc_new_key ON COMMIT DROP AS
SELECT
id,
fetched_at,
'h:' || substring(
encode(
sha256(convert_to(
sc_name_norm
|| '|' || coalesce(voltage_class, '')
|| '|' || CASE WHEN geom IS NULL THEN ''
ELSE (sign(ST_X(geom))
* floor(abs(ST_X(geom)) * 100000 + 0.5))::bigint::text END
|| '|' || CASE WHEN geom IS NULL THEN ''
ELSE (sign(ST_Y(geom))
* floor(abs(ST_Y(geom)) * 100000 + 0.5))::bigint::text END,
'UTF8'
)),
'hex'
) FROM 1 FOR 16
) AS new_key
FROM power_supply_centers
WHERE source = 'rosseti_wfs';
-- (б) схлопывание: оставляем свежайший снапшот каждой группы.
-- Резервы (reserve_mva и пр.) не теряются: rosseti/eesk-лоадеры пишут их
-- UPDATE-ом по sc_name_norm, т.е. во ВСЕ копии сразу, победитель их несёт.
WITH ranked AS (
SELECT
id,
row_number() OVER (
PARTITION BY new_key
ORDER BY fetched_at DESC NULLS LAST, id DESC
) AS rn
FROM psc_new_key
)
DELETE FROM power_supply_centers p
USING ranked r
WHERE p.id = r.id
AND r.rn > 1;
GET DIAGNOSTICS deleted = ROW_COUNT;
-- (а) пересчёт ключа у выживших. После DELETE каждый new_key принадлежит
-- ровно одной строке → UNIQUE (source, external_id) не нарушается.
-- IS DISTINCT FROM даёт идемпотентность: второй прогон обновит 0 строк.
UPDATE power_supply_centers p
SET external_id = k.new_key
FROM psc_new_key k
WHERE p.id = k.id
AND p.external_id IS DISTINCT FROM k.new_key;
GET DIAGNOSTICS rekeyed = ROW_COUNT;
SELECT count(*), count(DISTINCT sc_name_norm)
INTO rows_after, names_after
FROM power_supply_centers
WHERE source = 'rosseti_wfs';
RAISE NOTICE '#3322 power_supply_centers: было % строк / % имён -> стало % строк / % имён (удалено %, переключено на стабильный ключ %)',
rows_before, names_before, rows_after, names_after, deleted, rekeyed;
-- 700 — потолок здравого смысла: источник отдаёт ~488 ЦП по области.
-- Превышение = формула ключа не схлопнула дубли. EXCEPTION, а не WARNING:
-- иначе файл пометится applied навсегда, а дубли останутся. Откат всей
-- транзакции ничего не теряет и оставляет миграцию непринятой до разбора.
IF rows_after > 700 THEN
RAISE EXCEPTION '#3322: после дедупа осталось % строк (ожидалось ~481-488) — формула ключа не схлопнула дубли, транзакция откачена', rows_after;
END IF;
END $$;
COMMIT;

View file

@ -231,71 +231,6 @@ services:
mem_limit: 128m
logging: *default-logging
# ── redis-exporter: здоровье общего Redis (только на Poincare) ───────────────
# #3471. Redis — один инстанс на три потребителя: db0 celery-брокер Site
# Finder, db1 SearchCache trade-in, db2 glitchtip (см. комментарий у сервиса
# `redis` в docker-compose.prod.yml). Один `redis_up` покрывает риск для всех
# трёх разом — до этой правки Redis не измерялся вообще, переполнение
# брокера и обычная недоступность снаружи выглядели одинаково — тишиной.
#
# Адрес — через alias `gendesign-redis`, который `redis` регистрирует на
# сети `shared` (см. #2709 в docker-compose.prod.yml) — джойнить ещё и
# `product` не нужно, тем же путём уже идёт postgres-exporter-tradein.
#
# Пароль — из окружения, не хардкод: сегодня на Redis нет requirepass (нет
# переменной ни в docker-compose.prod.yml, ни здесь), но если он появится,
# значение подставляется через METRICS_REDIS_PASSWORD в /opt/gendesign/.env
# на хосте, а не в этот файл.
redis-exporter:
image: oliver006/redis_exporter:v1.65.0
container_name: gendesign-redis-exporter
restart: unless-stopped
profiles: ["apps"]
environment:
REDIS_ADDR: ${METRICS_REDIS_ADDR:-redis://gendesign-redis:6379}
REDIS_PASSWORD: ${METRICS_REDIS_PASSWORD:-}
expose:
- "9121"
networks:
- shared
mem_limit: 64m
logging: *default-logging
# ── celery-exporter: очередь Site Finder (только на Poincare) ────────────────
# #3471. Слепая зона: глубина очереди, число живых воркеров и счётчик
# неуспешных задач нигде не измерялись — залипший воркер и переполненная
# очередь снаружи неотличимы от тишины.
#
# ПОЧЕМУ ОТДЕЛЬНЫЙ ОБРАЗ, А НЕ redis-exporter --check-keys. check-keys дал
# бы LLEN дефолтной очереди "celery" (в backend/app/workers/celery_app.py
# НЕТ task_routes — все таски идут в один дефолтный queue, имя буквально
# "celery") без нового образа вообще. Но он НЕ умеет считать живых
# воркеров и неуспешные таски — то есть закрыл бы только треть минимума
# из задачи. celery-exporter слушает событийную шину Celery через тот же
# брокер и даёт все три метрики разом, поэтому выбран он, а не комбинация
# check-keys + что-то ещё для остальных двух чисел.
#
# ⚠️ ИМЕНА МЕТРИК НИЖЕ (celery_queue_length, celery_worker_up,
# celery_task_failed_total) — по документации проекта на момент правки, БЕЗ
# прогона на реальном брокере (агент писал этот файл без доступа к проду).
# Сверить с `curl http://gendesign-celery-exporter:9808/metrics` на хосте
# после первого деплоя и поправить `ops/metrics/prometheus/rules/infra.yml`
# при расхождении — иначе алерты будут молча ничего не ловить.
celery-exporter:
image: danihodovic/celery-exporter:0.13.0
container_name: gendesign-celery-exporter
restart: unless-stopped
profiles: ["apps"]
command:
- "--broker-url=${METRICS_CELERY_BROKER_URL:-redis://gendesign-redis:6379/0}"
- "--queue=celery"
expose:
- "9808"
networks:
- shared
mem_limit: 128m
logging: *default-logging
# ── postgres-exporter: инфраструктурная БД (только на Beget) ─────────────────
# forgejo + glitchtip. Нужен и сам по себе, и как страховка: рост базы glitchtip
# ничем не ограничен — политики ретенции у GlitchTip нет вообще.

View file

@ -196,12 +196,6 @@ services:
# Внешний адрес попадает в кнопку. Пустой — сообщение уйдёт без кнопки,
# но уйдёт: алерт важнее подтверждения.
ALERT_ACK_PUBLIC_URL: ${ALERT_ACK_PUBLIC_URL:-https://metrics.gendsgn.ru}
# Резервный получатель GlitchTip-алертов (#3471, POST /glitchtip) — второй
# получатель наряду с основным вебхуком в продуктовый бэкенд на Selectel.
# Секрет СВОЙ, не общий с продуктовым TRADEIN_INTERNAL_AUTH_SECRET: разные
# хосты/домены безопасности. Пусто — эндпоинт отвечает 503, остальной
# функционал сервиса не затронут.
ALERT_ACK_GLITCHTIP_SECRET: ${ALERT_ACK_GLITCHTIP_SECRET:-}
volumes:
- ./ops/metrics/alert-ack/app.py:/app/app.py:ro
expose:
@ -216,74 +210,7 @@ services:
timeout: 10s
retries: 5
# ── Ретранслятор Bot API продукта через Beget (#3471) ───────────────────────
#
# Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` из контейнера
# `tradein-tgbot` на Selectel — 9 успешных из 12, три ConnectTimeout. TCP-443
# до адреса, резолвящегося на Selectel — 5/6. Тот же TCP-443 до адреса,
# резолвящегося на Beget — 8/8. За сутки 508 строк `network error` в логе
# бота, за 30 дней 92 обрыва итерации poll loop. Путь до Telegram с Selectel
# лоссовый, с Beget чистый — Alertmanager (тот же чат, живёт рядом) шлёт без
# проблем. Продуктовые sendMessage/copyMessage/getUpdates идут сюда вместо
# прямого пути; выключается пустым TELEGRAM_RELAY_BASE_URL на стороне
# продукта — это и есть откат.
#
# НЕ рядом с продуктом: смысл ретранслятора именно в том, что он живёт там,
# откуда путь до Telegram чистый, а не там, откуда он лоссовый.
#
# Образ без сборки и без зависимостей (только stdlib) — тот же принцип, что у
# alert-ack: сервис обязан подниматься даже когда сломано всё остальное.
tg-relay:
image: python:3.12-slim
container_name: gendesign-tg-relay
# #3471 (PR #3487 инцидент): без profiles сервис поднимался ВСЕГДА, а при
# пустом TG_RELAY_SECRET делает SystemExit — то есть уходит в бесконечный
# Restarting сразу после деплоя. Профиль включает deploy-metrics.yml, и
# только когда секрет реально задан (см. PROFILES там).
profiles: ["relay"]
restart: unless-stopped
user: "65534:65534"
command: ["python", "-u", "/app/app.py"]
env_file:
- path: ./backend/.env.runtime
required: false
- path: ./backend/.env
required: false
environment:
# Общий секрет с продуктовым клиентом (TELEGRAM_RELAY_SECRET на стороне
# tradein-backend/tradein-tgbot) — домен публичный, без секрета отказ.
TG_RELAY_SECRET: ${TG_RELAY_SECRET:-}
volumes:
- ./ops/metrics/tg-relay/app.py:/app/app.py:ro
expose:
- "8080"
networks:
- shared
mem_limit: 128m
logging: *default-logging
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://localhost:8080/healthz',timeout=5)"]
interval: 30s
timeout: 10s
retries: 5
# ── Grafana: витрина ─────────────────────────────────────────────────────────
# Grafana здесь ТОЛЬКО рисует — не решает, что считать инцидентом и куда его
# слать. Тревоги живут в Prometheus (правила) и Alertmanager (маршрутизация,
# Telegram); это единственный путь доставки (#3158).
#
# Встроенный Alerting выключен ЯВНО, а не просто «не настроен». Проверка на
# живом API 12.09.2026 нашла: 0 правил, единственный контакт-поинт —
# стоковый grafana-default-email на example@email.com, GF_SMTP_* не заданы.
# То есть кнопка «New alert rule» в интерфейсе есть и работает, а результат
# молча уходит в никуда — ровно та ситуация, из-за которой никто не проверяет
# второй, настоящий путь. Дублирующий движок на том же датасорсе Prometheus
# надёжности всё равно не прибавляет (общая точка отказа), только даёт второе
# место, где правило может быть заведено и забыто.
#
# Если это когда-нибудь понадобится включить обратно — сначала подключить
# реальный SMTP или другой contact point и завести хотя бы одно тестовое
# правило руками, иначе вернётся тот же капкан.
grafana:
image: grafana/grafana:11.5.1
container_name: gendesign-grafana
@ -303,12 +230,6 @@ services:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-}
GF_SERVER_ROOT_URL: https://metrics.gendsgn.ru/
GF_SERVER_SERVE_FROM_SUB_PATH: "false"
# Единственный официальный переключатель Grafana Alerting в 11.x — секция
# [unified_alerting], легаси-[alerting] удалён из Grafana ещё в 9.0 и в
# 11.5 в конфиге отсутствует (сверено с grafana.com/docs/grafana/v11.5/
# setup-grafana/configure-grafana/#unified_alerting). false здесь убирает
# раздел Alerting из UI и глушит движок правил целиком — см. #3158 выше.
GF_UNIFIED_ALERTING_ENABLED: "false"
# Телеметрия наружу — выключена. Отдельный хост, отдельный провайдер, и не
# хочется, чтобы наблюдатель сам ходил в интернет без нужды.
GF_ANALYTICS_REPORTING_ENABLED: "false"

View file

@ -313,30 +313,6 @@ services:
# /data/anton-sqlite/analysis.db).
- /opt/gendesign/site-finder:/data/anton-sqlite:ro
command: ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=8", "--queues=celery,scrape_kn,geo"]
# #3324: до этого у worker'а healthcheck'а не было ВООБЩЕ — контейнер в
# crash-loop'е (ImportError в новом коде, протухший uv.lock) уезжал зелёным
# деплоем: деплой смотрел только `curl backend /health`.
# Проба — `inspect ping` ИМЕННО В ЭТОТ узел (`-d celery@$(hostname)`, у нас
# nodename дефолтный: в command нет `-n`). Без `-d` ping вернул бы OK на
# ответ ЛЮБОГО воркера на брокере — мёртвый контейнер выглядел бы живым.
# Ответ на ping = жив parent-процесс и держится соединение с Redis, т.е.
# ровно та связность, без которой очереди не разбираются. `$$` — экранировка
# для compose (в контейнер уезжает литеральное `$(hostname)`).
# Интервал 60s (не 30s как у backend): каждая проба — отдельный запуск
# celery-CLI с импортом приложения, дешёвым его не назовёшь.
# start_period 90s: холодный старт worker'а с Chromium-образа заметно
# медленнее backend'а.
# stderr НЕ глушим: docker хранит вывод пробы в .State.Health.Log, и деплой
# печатает его в диагнозе — с `2>&1` там была бы пустота вместо причины.
# retries 5 (не 3): при interval 60s тройка промахов = 3 минуты, столько
# длится обычный флап Redis, а из unhealthy контейнер сам не выходит по
# restart-политике — следующий деплой краснел бы за исправный воркер.
healthcheck:
test: ["CMD-SHELL", "celery -A app.workers.celery_app inspect ping -d celery@$$(hostname) -t 10 >/dev/null"]
interval: 60s
timeout: 30s
retries: 5
start_period: 90s
# #976 cross-DB ETL tradein→gendesign: worker запускает etl_newbuilding_crossload task,
# которому нужен прямой TCP-доступ к tradein-postgres через gendesign_shared.
# default — обязательно явно, иначе сервис выпадет из дефолтной сети.
@ -360,25 +336,6 @@ services:
# хранит только last_run_at для periodic tasks — потеря на restart OK,
# beat перестроит из `celery_app.conf.beat_schedule` на старте.
command: ["celery", "-A", "app.workers.celery_app", "beat", "--loglevel=info", "--schedule=/tmp/celerybeat-schedule"]
# #3324: beat тоже жил без healthcheck'а. На `inspect ping` beat НЕ отвечает
# (remote control — свойство воркера, не планировщика), поэтому проба другая:
# СВЕЖЕСТЬ shelve-файла расписания. Celery beat синкует его на диск не реже
# чем раз в `Scheduler.sync_every` = 180 с — но только когда в этом окне была
# отправлена задача. У нас в расписании есть поминутная (nspd-geo-zombie-
# cleanup, `* * * * *`) и двухминутная задачи, так что живой beat обновляет
# mtime примерно каждые 3 минуты, а вставший — не обновляет вовсе. Порог 10
# минут = 3× запас к этой каденции.
# Почему glob `celerybeat-schedule*`: имя на диске зависит от того, какой
# backend выберет shelve/dbm в образе (gnu → тот же файл, dumb → .dat/.dir).
# Почему не `pgrep`/`true`: PID-1 процесс жив ровно пока жив контейнер —
# такая проба повторяет `State.Running` и не ловит подвисший планировщик.
# `grep -q .`: сам find возвращает 0 и когда не нашёл ничего.
healthcheck:
test: ["CMD-SHELL", "find /tmp -maxdepth 1 -name 'celerybeat-schedule*' -mmin -10 | grep -q ."]
interval: 60s
timeout: 10s
retries: 3
start_period: 120s
# ── infra-postgres: лёгкий кластер ОСТАЮЩЕЙСЯ инфраструктуры (#3061) ────────
# Переезд продукта Beget (46.173.16.127) → Selectel Poincare (188.124.37.140),
@ -778,9 +735,6 @@ services:
# роняя ВСЕ сайты хоста, а не только metrics.gendsgn.ru.
- ./caddy/metrics-ingest.caddy.snippet:/etc/caddy/caddy/metrics-ingest.caddy.snippet:ro
- ./caddy/metrics-ui.caddy.snippet:/etc/caddy/caddy/metrics-ui.caddy.snippet:ro
# То же самое для страницы окна деплоя (#3274): caddy/sites/apps.caddy
# импортирует её как `import ../deploy-window.caddy.snippet`.
- ./caddy/deploy-window.caddy.snippet:/etc/caddy/caddy/deploy-window.caddy.snippet:ro
# Untracked локальные site-блоки (см. import в конце Caddyfile). Каталог
# держится в git через caddy/local/.gitignore — иначе docker создал бы
# отсутствующий bind-source сам, root-owned пустышкой.

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1.7
# ---- deps ----
FROM node:24-alpine AS deps
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# --legacy-peer-deps: Tailwind 4 alpha + React 19 peer-dep mismatches.
@ -10,7 +10,7 @@ RUN --mount=type=cache,target=/root/.npm \
# ---- builder ----
FROM node:24-alpine AS builder
FROM node:20-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# NEXT_PUBLIC_* must be present at `npm run build` — Next.js inlines them into
@ -25,7 +25,7 @@ RUN npm run build
# ---- runner ----
FROM node:24-alpine AS runner
FROM node:20-alpine AS runner
WORKDIR /app
# Next.js standalone server.js binds to `process.env.HOSTNAME || '0.0.0.0'`.
# Docker auto-sets HOSTNAME to the container id, so without this the server binds

16147
frontend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -39,7 +39,7 @@
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.2.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.0.0",
"eslint-config-next": "^15.0.0",
"jsdom": "^25.0.1",
@ -48,6 +48,6 @@
"prettier": "3.9.0",
"tailwindcss": "^4.0.0",
"typescript": "5.9.3",
"vitest": "^3.2.7"
"vitest": "^2.1.9"
}
}

View file

@ -1,138 +0,0 @@
#!/bin/sh
# Применить текущий конфиг прокси к работающему Caddy (#3443).
#
# ЗАЧЕМ. Полный деплой ПТИЦЫ пересоздавал сам Caddy БЕЗУСЛОВНО
# (`up -d --force-recreate --no-deps caddy`), а вместе с контейнером исчезал
# единственный процесс, слушающий 80/443. Замер 05.09 (#3274): 67 с `code=000`
# на ВСЕХ доменах хоста — gendsgn.ru, meraocenka.ru и зеркала, включая
# публичный лендинг МЕРЫ. Это не 502/503: принимающего процесса нет вовсе,
# поэтому заглушка окна деплоя (caddy/sites/deploy-window.caddy.snippet) здесь
# бессильна по построению — её отдаёт тот же Caddy.
#
# ЧТО НА САМОМ ДЕЛЕ ТРЕБУЕТ ПЕРЕСОЗДАНИЯ. Безусловный флаг появился 17.05
# (11e78d73) ради нового bind-маунта `./preview` из docker-compose.prod.yml,
# который «не появлялся в running container». Довод неверен: `docker compose
# up -d` БЕЗ `--force-recreate` пересоздаёт контейнер сам, как только меняется
# описание сервиса или образ. Проверено на живом демоне (docker 28.4):
# добавлен volume → `Container … Starting/Started`, id контейнера новый;
# тег указан на др. образ → id новый;
# не менялось ничего → `Container … Running`, id тот же.
#
# Остаётся ровно один класс изменений, которого compose не видит: СОДЕРЖИМОЕ
# пофайлового bind-маунта. `git reset --hard` не правит файл на месте, а пишет
# новый инод; контейнер держит примонтированным прежний и продолжает читать
# его — `caddy reload` перечитает ровно тот же старый инод. Тот же механизм уже
# ловили на Alertmanager (27.08, deploy-metrics.yml) и на Alloy (#3380). У Caddy
# так смонтированы пять путей: Caddyfile и четыре сниппета. Каталоги
# (caddy/sites, caddy/local, preview) этим не страдают — правка внутри каталога
# видна контейнеру сразу, поэтому самый частый случай (caddy/sites/apps.caddy)
# пересоздания НЕ требует.
#
# ОТСЮДА ПОРЯДОК: проверить конфиг → `up -d` без `--force-recreate` → если
# контейнер остался тем же, сверить, видит ли он текущее содержимое пофайловых
# маунтов → пересоздать ТОЛЬКО при расхождении, иначе `caddy reload`, который
# не рвёт соединения.
#
# ГРАНИЦА. Сверка по СОДЕРЖИМОМУ, а не по иноду: файл, переписанный тем же
# текстом, пересоздания не требует. Не прочиталось (контейнер не запущен, в
# образе нет sha256sum) — считаем расхождением: fail-safe в сторону прежнего
# поведения, то есть пересоздания.
set -eu
# Оба вызывающих (job `deploy` и job `deploy-caddy` в .forgejo/workflows/deploy.yml)
# работают в /opt/gendesign, но не зависеть от cwd дешевле, чем это помнить.
cd "$(dirname "$0")/.."
COMPOSE="docker compose -p gendesign -f docker-compose.prod.yml"
caddy_cid() { $COMPOSE ps -aq caddy 2>/dev/null | tail -n1; }
# ── 1. Проверка ДО применения ────────────────────────────────────────────────
# Одноразовый контейнер читает файлы С ХОСТА — то есть ровно то, что поедет в
# работающий Caddy. `exec caddy validate` здесь не годится: он проверил бы
# старый инод, то есть предыдущую версию конфига. Образ и парсер те же, что на
# PR-гейте (ci.yml «Guard: Caddyfile синтаксически валиден»).
#
# ГРАНИЦА ЭТОЙ ПРОВЕРКИ. Она обрывает применение до того, как конфиг попадёт в
# работающий Caddy, — но только на быстром пути. В полном деплое ВЫШЕ по
# скрипту (deploy.yml, `up -d $UP_SERVICES`) уже прошёл общий подъём всех
# сервисов, и если правка одновременно ломает Caddyfile И меняет блок caddy в
# docker-compose.prod.yml, контейнер пересоздастся там — с непроверенным
# конфигом и раньше этой строки. Первая линия против этого — гейт на PR (#2913).
echo "→ проверяю конфиг прокси одноразовым контейнером…"
if ! docker run --rm -v "$PWD:/work:ro" -w /work caddy:2 \
caddy validate --config /work/Caddyfile --adapter caddyfile; then
echo "ОШИБКА: конфиг прокси не применён — проверка не пройдена ЛИБО не удалось"
echo " запустить проверочный контейнер (нет образа caddy:2, занят демон,"
echo " недоступен реестр). Причина — в выводе выше, не гадать по этой строке."
echo " Работающий Caddy не тронут, домены живы."
exit 1
fi
# ── 2. Описание сервиса и образ ──────────────────────────────────────────────
before=$(caddy_cid)
$COMPOSE up -d --no-deps caddy
after=$(caddy_cid)
if [ -z "$after" ]; then
echo 'ОШИБКА: после `up -d` контейнера caddy нет — смотри вывод compose выше.'
exit 1
fi
if [ "$before" != "$after" ]; then
echo "✓ Caddy пересоздан compose'ом: изменилось описание сервиса или образ (${before:-нет}${after})."
exit 0
fi
# ── 3. Доехало ли содержимое пофайловых маунтов ──────────────────────────────
# Список маунтов читается ОТДЕЛЬНОЙ командой, а не в конвейере с циклом: в
# `$(docker inspect … | while …)` статус подстановки — это статус `while`, то
# есть всегда 0 (`pipefail` в POSIX-sh нет вовсе). Провал `docker inspect`
# давал бы пустой список → «расхождений нет» → `caddy reload` → зелёная джоба с
# надписью «окна недоступности нет», а прокси работал бы по СТАРОМУ конфигу.
# Это ровно тот беззвучный отказ, ради которого написан весь скрипт.
mounts=$(docker inspect "$after" \
--format '{{range .Mounts}}{{if eq .Type "bind"}}{{.Source}}|{{.Destination}}{{println}}{{end}}{{end}}') \
|| mounts=''
if [ -z "$mounts" ]; then
echo "WARNING: список маунтов Caddy не прочитан (docker inspect молчит или упал) —"
echo " сверить нечем, считаю расхождением: fail-safe в прежнее поведение."
verdicts=""
stale="(маунты не прочитаны)"
else
verdicts=$(printf '%s\n' "$mounts" | while IFS='|' read -r src dst; do
[ -n "${dst:-}" ] || continue
# Каталог инод не держит — пропускаем. Именно `-d`, а не `-f`:
# отсутствующий/нечитаемый ФАЙЛ — не повод молча пропустить, в
# контейнере в этот момент живёт старый инод со старым текстом.
if [ -d "$src" ]; then continue; fi
host_sum=$(sha256sum "$src" 2>/dev/null | cut -d' ' -f1)
seen_sum=$(docker exec "$after" sha256sum "$dst" 2>/dev/null | cut -d' ' -f1)
if [ -n "$host_sum" ] && [ "$host_sum" = "${seen_sum:-НЕРОЧИТАНО}" ]; then
echo "OK $dst"
else
echo "STALE $dst"
fi
done)
# «Сверили пять файлов» и «сверили ноль» обязаны различаться в логе — иначе
# зелёная строка ниже одинаково означает и проверку, и её отсутствие.
checked=$(printf '%s\n' "$verdicts" | grep -c . || true)
echo "→ сверено пофайловых маунтов с тем, что видит контейнер: $checked"
stale=$(printf '%s\n' "$verdicts" | sed -n 's/^STALE //p' | tr '\n' ' ')
if [ "$checked" -eq 0 ]; then
echo "WARNING: ни одного пофайлового bind-маунта не найдено — у Caddy их пять"
echo " (Caddyfile + 4 сниппета). Считаю расхождением: fail-safe."
stale="(пофайловых маунтов не найдено)"
fi
fi
if [ -n "$stale" ]; then
echo "→ до контейнера НЕ доехали пофайловые маунты: $stale"
echo " (bind-маунт файла держит инод: reload перечитал бы старую версию — нужен recreate)"
$COMPOSE up -d --force-recreate --no-deps caddy
echo "✓ Caddy пересоздан — иначе правка осталась бы неприменённой."
exit 0
fi
# ── 4. Всё доехало — перезагрузка без разрыва соединений ─────────────────────
$COMPOSE exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
echo "✓ конфиг прокси перезагружен без пересоздания контейнера — окна недоступности нет."

View file

@ -27,28 +27,6 @@
METRICS_TELEGRAM_ONCALL кого звать поимённо (необязательна)
ALERT_ACK_PUBLIC_URL внешний адрес сервиса, попадает в кнопку
ALERT_ACK_TTL_MIN сколько минут живёт токен (по умолчанию 1440)
ALERT_ACK_GLITCHTIP_SECRET секрет резервного вебхука GlitchTip (#3471,
см. POST /glitchtip ниже); пусто 503
РЕЗЕРВНЫЙ КАНАЛ GLITCHTIP (#3471). Все три alert-правила GlitchTip (backend,
frontend, Trade-In) шлют основной вебхук в продуктовый бэкенд на Selectel
тот самый хост, за которым они следят. Если там упал сам бэкенд или Caddy,
алерт об этом теряется именно тогда, когда нужнее всего. `POST /glitchtip`
второй получатель того же алерта, зарегистрированный в GlitchTip отдельной
строкой; живёт на ЭТОМ (инфраструктурном, Beget) хосте и не зависит от
здоровья продукта. Формат тела тот же Slack-совместимый payload, что и у
продуктового приёмника (`tradein-mvp/backend/app/api/v1/glitchtip.py`):
``{"text": str, "attachments": [{"title","title_link","text","color",
"fields":[{"title","value"}]}]}``, GlitchTip заголовков не шлёт вовсе
аутентификация только через секрет в query (``?secret=``) или в заголовке
``X-GlitchTip-Secret`` (тот же выбор, что там же и по той же причине: заголовок
не течёт в access-log, query остаётся, т.к. сам GlitchTip 6.1.6 заголовков не
добавляет). Секрет намеренно СВОЙ (``ALERT_ACK_GLITCHTIP_SECRET``), а не общий
с продуктовым ``TRADEIN_INTERNAL_AUTH_SECRET`` секреты разных хостов/доменов
безопасности компрометировать вместе незачем. Сообщение уходит в ту же тему
клиентских инцидентов (``METRICS_TELEGRAM_CHAT_ID``/``_TOPIC_ID``), что и
Alertmanager-алерты через alert-ack, с явной пометкой «резервный канал», чтобы
не спутать с основным путём.
"""
from __future__ import annotations
@ -73,7 +51,6 @@ TOPIC_ID = os.environ.get("METRICS_TELEGRAM_TOPIC_ID", "")
ONCALL = os.environ.get("METRICS_TELEGRAM_ONCALL", "")
PUBLIC_URL = os.environ.get("ALERT_ACK_PUBLIC_URL", "").rstrip("/")
TTL_SEC = int(os.environ.get("ALERT_ACK_TTL_MIN", "1440")) * 60
GLITCHTIP_SECRET = os.environ.get("ALERT_ACK_GLITCHTIP_SECRET", "")
API = "https://api.telegram.org/bot{}/{}"
# token -> {"message_id": int, "title": str, "created": float, "acked_by": str|None}
@ -176,79 +153,6 @@ def _send_alert(payload: dict) -> None:
_tg("sendMessage", msg)
_GLITCHTIP_TEXT_LIMIT = 3500 # запас под баннер+имя проекта до лимита Telegram 4096
def _verify_glitchtip_secret(provided: str) -> bool:
"""Constant-time сравнение — длина/префикс секрета не утекают через время
ответа (тот же приём, что у продуктового приёмника, см. docstring модуля)."""
return bool(GLITCHTIP_SECRET) and secrets.compare_digest(provided or "", GLITCHTIP_SECRET)
def _glitchtip_field(attachment: dict, label: str) -> str | None:
for field in attachment.get("fields") or []:
if not isinstance(field, dict):
continue
if str(field.get("title", "")).strip().lower() == label.lower():
value = field.get("value")
return str(value) if value is not None else None
return None
def _render_glitchtip(payload: dict) -> str:
"""Собрать текст сообщения из Slack-совместимого payload GlitchTip.
Максимально терпимо к форме тела: GlitchTip шлёт ОДИНАКОВУЮ структуру для
issue- и uptime-алертов, но поля внутри attachments опциональны, а тестовое
сообщение из UI GlitchTip может не иметь attachments вовсе. Ничего в теле
не считаем обязательным падать сервису на резервном канале нельзя.
"""
lines = ["⚠️ РЕЗЕРВНЫЙ КАНАЛ (GlitchTip → alert-ack)"]
lines.append("Основной путь через продуктовый бэкенд мог быть недоступен.")
lines.append("")
text = payload.get("text")
lines.append(html.escape(str(text)) if text else "GlitchTip alert")
attachments = payload.get("attachments")
for attachment in attachments if isinstance(attachments, list) else []:
if not isinstance(attachment, dict):
continue
block: list[str] = []
project = _glitchtip_field(attachment, "Project")
if project:
block.append(f"Проект: {html.escape(project)}")
if attachment.get("title"):
block.append(html.escape(str(attachment["title"])))
if attachment.get("text"):
block.append(html.escape(str(attachment["text"])))
if attachment.get("title_link"):
block.append(f"Ссылка: {html.escape(str(attachment['title_link']))}")
if block:
lines.append("")
lines.extend(block)
out = "\n".join(lines)
if len(out) > _GLITCHTIP_TEXT_LIMIT:
out = out[:_GLITCHTIP_TEXT_LIMIT] + "\n… (обрезано)"
return out
def _send_glitchtip_alert(payload: dict) -> None:
"""Переслать вебхук GlitchTip в ту же тему клиентских инцидентов, что и
Alertmanager через этот сервис. Без кнопки подтверждения это не
firing/resolved инцидент с состоянием, а разовое уведомление резервного
канала."""
msg = {
"chat_id": CHAT_ID,
"text": _render_glitchtip(payload),
"parse_mode": "HTML",
"disable_web_page_preview": "true",
}
if TOPIC_ID:
msg["message_thread_id"] = TOPIC_ID
_tg("sendMessage", msg)
_PAGE = (
"<!doctype html><meta charset=utf-8>"
"<title>{t}</title>"
@ -339,24 +243,11 @@ class Handler(BaseHTTPRequestHandler):
self._reply(code, page.encode())
def do_POST(self) -> None: # noqa: N802 — имя из stdlib
# Тело читается ДО любой развилки и ветки отказа. protocol_version =
# HTTP/1.1, то есть соединение переиспользуется, а Caddy перед нами
# держит пул к апстриму. Ответить 401/404/503, не вычитав тело, значит
# оставить его в сокете — и следующий запрос по тому же соединению
# начнётся с чужих байт. Проверено на проде 12.09.2026: неавторизованный
# зонд на /glitchtip, а следом законный алерт получил
# 501 Unsupported method ('{"text":"probe"}POST'). То есть один
# отказ ронял следующий НАСТОЯЩИЙ алерт — ровно то, ради чего этот
# резервный канал и заводился.
parsed = urllib.parse.urlsplit(self.path)
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
if parsed.path == "/glitchtip":
self._handle_glitchtip(parsed, raw)
return
if parsed.path != "/alertmanager":
if self.path != "/alertmanager":
self._reply(404, b"not found", "text/plain; charset=utf-8")
return
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
payload = json.loads(raw.decode() or "{}")
except Exception: # noqa: BLE001
@ -370,36 +261,6 @@ class Handler(BaseHTTPRequestHandler):
self._reply(200, b"accepted", "text/plain; charset=utf-8")
threading.Thread(target=_send_alert, args=(payload,), daemon=True).start()
def _handle_glitchtip(self, parsed: urllib.parse.SplitResult, raw: bytes) -> None:
"""POST /glitchtip — резервный получатель GlitchTip-алертов (#3471).
Секрет из заголовка ``X-GlitchTip-Secret`` (предпочтительно, не течёт
в access-log) либо из query ``?secret=`` (fallback: GlitchTip 6.1.6
заголовков не шлёт вовсе). Несконфигурированный секрет 503, а не
тихий приём без проверки. Неразобранное/нестандартное тело НЕ роняет
запрос это резервный канал, теряться на кривом JSON ему нельзя.
"""
if not GLITCHTIP_SECRET:
self._reply(503, b"glitchtip webhook not configured", "text/plain; charset=utf-8")
return
header_secret = self.headers.get("X-GlitchTip-Secret", "")
query_secret = urllib.parse.parse_qs(parsed.query).get("secret", [""])[0]
if not _verify_glitchtip_secret(header_secret or query_secret):
log.warning("glitchtip webhook: invalid or missing secret")
self._reply(401, b"invalid or missing secret", "text/plain; charset=utf-8")
return
try:
payload = json.loads(raw.decode() or "{}")
if not isinstance(payload, dict):
payload = {"text": raw.decode(errors="replace")}
except Exception: # noqa: BLE001 — резервный канал не роняем на кривом теле
payload = {"text": raw.decode(errors="replace")}
self._reply(200, b"accepted", "text/plain; charset=utf-8")
threading.Thread(target=_send_glitchtip_alert, args=(payload,), daemon=True).start()
def main() -> None:
missing = [n for n, v in (("BOT_TOKEN", BOT_TOKEN), ("CHAT_ID", CHAT_ID)) if not v]
@ -407,11 +268,6 @@ def main() -> None:
raise SystemExit(f"не заданы обязательные переменные: {', '.join(missing)}")
if not PUBLIC_URL:
log.warning("ALERT_ACK_PUBLIC_URL пуст — сообщения уйдут БЕЗ кнопки подтверждения")
if not GLITCHTIP_SECRET:
log.warning(
"ALERT_ACK_GLITCHTIP_SECRET пуст — резервный канал GlitchTip (#3471) "
"отключён, POST /glitchtip будет отвечать 503"
)
port = int(os.environ.get("ALERT_ACK_PORT", "8080"))
log.info("alert-ack слушает :%d, тема=%s, дежурный=%s", port, TOPIC_ID or "", ONCALL or "")
ThreadingHTTPServer(("", port), Handler).serve_forever()

View file

@ -1,256 +0,0 @@
"""Тесты для резервного канала GlitchTip → alert-ack (#3471).
Продукт (tradein-backend на Selectel) САМ объект наблюдения GlitchTip. Если
он лежит, основной вебхук-получатель лежит вместе с ним, и алерт об этом не
доходит именно тогда, когда нужнее всего. `POST /glitchtip` второй
получатель на ДРУГОМ хосте (Beget, рядом с этим сервисом), не зависящий от
здоровья продукта.
Проверяем на уровне функций, а не полного HTTP-транспорта (тот же приём, что
`ops/glitchtip-auth-forwarder/test_forwarder.py`): `BaseHTTPRequestHandler`
неудобно поднимать без реального сокета, а бизнес-логика секрет, рендер,
отправка целиком вынесена в чистые функции модуля.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
# Env — ДО импорта app.py: BOT_TOKEN/CHAT_ID читаются на уровне модуля.
os.environ.setdefault("METRICS_TELEGRAM_BOT_TOKEN", "test-bot-token")
os.environ.setdefault("METRICS_TELEGRAM_CHAT_ID", "-1001234567890")
os.environ.setdefault("METRICS_TELEGRAM_TOPIC_ID", "158")
os.environ["ALERT_ACK_GLITCHTIP_SECRET"] = "correct-secret"
import app as alert_ack # noqa: E402
def test_verify_glitchtip_secret_accepts_correct_value() -> None:
assert alert_ack._verify_glitchtip_secret("correct-secret") is True
def test_verify_glitchtip_secret_rejects_wrong_value() -> None:
"""Неверный секрет — отказ."""
assert alert_ack._verify_glitchtip_secret("wrong-secret") is False
def test_verify_glitchtip_secret_rejects_empty_value() -> None:
assert alert_ack._verify_glitchtip_secret("") is False
def test_verify_glitchtip_secret_fails_closed_when_unconfigured(monkeypatch) -> None:
"""Пустой ALERT_ACK_GLITCHTIP_SECRET — отказ всем, а не тихий fail-open."""
monkeypatch.setattr(alert_ack, "GLITCHTIP_SECRET", "")
assert alert_ack._verify_glitchtip_secret("correct-secret") is False
assert alert_ack._verify_glitchtip_secret("") is False
def test_render_glitchtip_full_payload_marks_fallback_channel() -> None:
payload = {
"text": "GlitchTip Alert: Something broke",
"attachments": [
{
"title": "TypeError: boom",
"title_link": "https://errors.gendsgn.ru/issue/1",
"text": "подробности ошибки",
"color": "#ff0000",
"fields": [{"title": "Project", "value": "tradein-backend"}],
}
],
}
text = alert_ack._render_glitchtip(payload)
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
assert "Проект: tradein-backend" in text
assert "TypeError: boom" in text
assert "https://errors.gendsgn.ru/issue/1" in text
def test_render_glitchtip_survives_payload_without_attachments() -> None:
"""Тело без attachments не роняет сервис — только текст."""
text = alert_ack._render_glitchtip({"text": "просто текст без вложений"})
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
assert "просто текст без вложений" in text
def test_render_glitchtip_survives_empty_payload() -> None:
"""Пустой словарь (нет ни text, ни attachments) — тоже не должен падать."""
text = alert_ack._render_glitchtip({})
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
assert "GlitchTip alert" in text
def test_render_glitchtip_survives_malformed_attachments() -> None:
"""attachments/fields неожиданной формы (не список, не словарь, битые
типы) резервный канал не должен падать на кривом теле."""
payload = {
"text": "test",
"attachments": [
"not-a-dict",
{"fields": "not-a-list"},
{"fields": [{"title": "Project"}]}, # value отсутствует
None,
],
}
text = alert_ack._render_glitchtip(payload)
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
def test_send_glitchtip_alert_forwards_via_tg(monkeypatch) -> None:
"""Верный секрет уже проверен вызывающей стороной (do_POST) — здесь
проверяем, что отрендеренное сообщение реально уходит в тот же chat/topic,
что и Alertmanager-алерты этого сервиса, без кнопки подтверждения."""
calls = []
monkeypatch.setattr(alert_ack, "_tg", lambda method, payload: calls.append((method, payload)))
alert_ack._send_glitchtip_alert({"text": "boom", "attachments": []})
assert len(calls) == 1
method, sent = calls[0]
assert method == "sendMessage"
assert sent["chat_id"] == alert_ack.CHAT_ID
assert sent["message_thread_id"] == alert_ack.TOPIC_ID
assert "reply_markup" not in sent
assert "boom" in sent["text"]
# ── Keep-alive: отказ не должен ронять СЛЕДУЮЩИЙ запрос ──────────────────────
# Эти четыре теста — единственные, что поднимают настоящий сокет. Дефект,
# который они стерегут, живёт именно в транспорте и на уровне функций невидим:
# ветка отказа отвечала, не вычитав тело запроса, а `protocol_version` здесь
# HTTP/1.1, то есть соединение переиспользуется (и Caddy перед сервисом держит
# пул к апстриму). Непрочитанное тело оставалось в сокете, и следующий запрос
# по тому же соединению начинался с чужих байт.
#
# Поймано на проде 12.09.2026: зонд без секрета получил 401, а следующий —
# уже с верным секретом — вернул 501 Unsupported method ('{"text":"probe"}POST').
# То есть один отказ съедал следующий НАСТОЯЩИЙ алерт.
def _serve_in_background(monkeypatch):
"""Поднимает Handler на эфемерном порту, глушит отправку в Telegram."""
import threading as _threading
from http.server import ThreadingHTTPServer
sent: list[dict] = []
monkeypatch.setattr(alert_ack, "_send_glitchtip_alert", sent.append)
monkeypatch.setattr(alert_ack, "_send_alert", sent.append)
srv = ThreadingHTTPServer(("127.0.0.1", 0), alert_ack.Handler)
thread = _threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
return srv, sent
def _raw_post(sock, path: str, body: bytes, headers: str = "") -> str:
"""Шлёт POST по уже открытому сокету и возвращает статусную строку."""
req = (
f"POST {path} HTTP/1.1\r\n"
f"Host: localhost\r\n"
f"Content-Type: application/json\r\n"
f"Content-Length: {len(body)}\r\n"
f"{headers}"
f"\r\n"
).encode() + body
sock.sendall(req)
# Читаем ровно заголовки: тела короткие, Content-Length всегда проставлен.
buf = b""
while b"\r\n\r\n" not in buf:
chunk = sock.recv(4096)
if not chunk:
break
buf += chunk
head, _, rest = buf.partition(b"\r\n\r\n")
length = 0
for line in head.split(b"\r\n")[1:]:
if line.lower().startswith(b"content-length:"):
length = int(line.split(b":")[1])
while len(rest) < length:
rest += sock.recv(4096)
return head.split(b"\r\n")[0].decode()
def _pair_on_one_connection(monkeypatch, first_headers: str, first_path: str = "/glitchtip"):
import socket
srv, sent = _serve_in_background(monkeypatch)
try:
sock = socket.create_connection(srv.server_address, timeout=5)
try:
first = _raw_post(sock, first_path, b'{"text":"probe"}', first_headers)
second = _raw_post(
sock,
"/glitchtip",
b'{"text":"real alert"}',
"X-GlitchTip-Secret: correct-secret\r\n",
)
finally:
sock.close()
finally:
srv.shutdown()
srv.server_close()
return first, second, sent
def test_rejected_request_does_not_break_next_one_on_same_connection(monkeypatch) -> None:
"""401 без секрета, следом законный алерт по ТОМУ ЖЕ соединению — 200."""
first, second, sent = _pair_on_one_connection(monkeypatch, "")
assert "401" in first
assert "200" in second, f"второй запрос испорчен первым: {second}"
assert sent == [{"text": "real alert"}]
def test_unconfigured_secret_does_not_break_next_request(monkeypatch) -> None:
"""503 при пустом секрете тоже обязан вычитать тело."""
import socket
monkeypatch.setattr(alert_ack, "GLITCHTIP_SECRET", "")
srv, _sent = _serve_in_background(monkeypatch)
try:
sock = socket.create_connection(srv.server_address, timeout=5)
try:
first = _raw_post(sock, "/glitchtip", b'{"text":"probe"}')
second = _raw_post(sock, "/glitchtip", b'{"text":"again"}')
finally:
sock.close()
finally:
srv.shutdown()
srv.server_close()
assert "503" in first
assert "503" in second, f"второй запрос испорчен первым: {second}"
def test_unknown_path_does_not_break_next_request(monkeypatch) -> None:
"""404 на чужом пути — та же ветка раннего ответа, то же требование."""
first, second, sent = _pair_on_one_connection(monkeypatch, "", first_path="/nope")
assert "404" in first
assert "200" in second, f"второй запрос испорчен первым: {second}"
assert sent == [{"text": "real alert"}]
def test_bad_json_on_alertmanager_does_not_break_next_request(monkeypatch) -> None:
"""400 на неразобранном теле /alertmanager — тело уже вычитано, связь цела."""
import socket
srv, sent = _serve_in_background(monkeypatch)
try:
sock = socket.create_connection(srv.server_address, timeout=5)
try:
first = _raw_post(sock, "/alertmanager", b"{not json")
second = _raw_post(
sock,
"/glitchtip",
b'{"text":"real alert"}',
"X-GlitchTip-Secret: correct-secret\r\n",
)
finally:
sock.close()
finally:
srv.shutdown()
srv.server_close()
assert "400" in first
assert "200" in second, f"второй запрос испорчен первым: {second}"
assert sent == [{"text": "real alert"}]

View file

@ -71,16 +71,9 @@ route:
repeat_interval: 3h
inhibit_rules:
# Если хост целиком недоступен, не сыпать отдельно про каждый его
# warning-сервис. `target_matchers` НАМЕРЕННО ограничен одним warning
# (#3471): раньше сюда попадал и critical, и падение node-exporter молча
# гасило заодно PostgresLongTransactionCritical и все critical cAdvisor-
# алерты того же хоста — самое важное сообщение исчезало вместе с шумом,
# который оно должно было подавить. Warning того же хоста подавлять по-
# прежнему стоит (диск/память/своп неотличимы от «нет данных»), а critical
# обязан пережить это подавление и дойти до дежурного отдельно.
# Если хост целиком недоступен, не сыпать отдельно про каждый его сервис.
- source_matchers: [alertname = "HostAgentDown"]
target_matchers: [severity = "warning"]
target_matchers: [severity =~ "warning|critical"]
equal: ["host"]
receivers:

View file

@ -119,27 +119,6 @@ prometheus.scrape "postgres" {
scrape_interval = "60s"
}
// ═══ REDIS И ОЧЕРЕДЬ CELERY (#3471) ═════════════════════════════════════════════
// До этой правки ни одной серии redis_* / celery_* в Prometheus не было: глубина
// очереди, число живых воркеров и потеря соединения с брокером были невидимы —
// переполнение очереди и залипший воркер снаружи выглядели одинаково, тишиной.
prometheus.scrape "redis" {
targets = [
{ __address__ = "gendesign-redis-exporter:9121", job = "redis" },
]
forward_to = [prometheus.remote_write.central.receiver]
scrape_interval = "30s"
}
prometheus.scrape "celery" {
targets = [
{ __address__ = "gendesign-celery-exporter:9808", job = "celery" },
]
forward_to = [prometheus.remote_write.central.receiver]
scrape_interval = "30s"
}
// ═══ МЕТРИКИ ПРИЛОЖЕНИЙ ════════════════════════════════════════════════════════
// Эндпоинты появляются в части 3. До этого скрейп просто отдаёт `up 0` — и это
// правильно: цель видна как недоступная, а не отсутствует молча.
@ -193,26 +172,6 @@ loki.process "scrub_credentials" {
expression = "://[^:@/ ]+:([^@ ]+)@"
replace = "***"
}
// ── Секреты в query-строке (#3354) ─────────────────────────────────────────
// Прод-факт (#3154): uvicorn пишет в access-log ПОЛНЫЙ путь с query, и туда
// уезжал `?secret=<64 hex>` вебхука GlitchTip. Приложение чистит это у себя
// (tradein-mvp/backend/app/core/log_scrub.py), здесь — второй слой на случай
// строки, пришедшей мимо фильтра: другой процесс, sidecar, будущий логгер.
//
// Множество имён держим ОДИНАКОВЫМ с log_scrub.py. Префикс `[\w.-]*` перед
// альтернацией — ради суффиксных имён (`client_secret`, `refresh_token`,
// `webhook_secret`); значение обрывается на `&`, пробеле или кавычке, потому
// что access-строка uvicorn обрамляет запрос кавычками.
//
// Группа захвата стоит на ЗНАЧЕНИИ, а не на имени параметра: Alloy заменяет
// содержимое групп, а не весь совпавший фрагмент (ровно как у DSN выше — там
// группа на пароле, поэтому пользователь и хост уцелевают). Обернуть группой
// `?secret=` значило бы затереть имя и оставить сам секрет.
stage.replace {
expression = "(?i)[?&][\\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=([^&\\s\"'<>]+)"
replace = "***"
}
}
loki.relabel "journal" {

View file

@ -57,13 +57,9 @@ prometheus.scrape "cadvisor" {
prometheus.relabel "cadvisor_trim" {
forward_to = [prometheus.remote_write.central.receiver]
// `up`/`scrape_samples_scraped` — служебные ряды самого скрейпа, не
// container_*-метрики. Без явного допуска этот keep-фильтр резал их вместе
// с прочим шумом, и у cAdvisor как job'а не было своей серии `up` вообще —
// его смерть выглядела так же, как «ничего не изменилось» (#3471).
rule {
source_labels = ["__name__"]
regex = "up|scrape_samples_scraped|container_(memory_(usage_bytes|working_set_bytes|rss)|cpu_(usage_seconds_total|cfs_throttled_seconds_total)|network_(receive|transmit)_bytes_total|fs_(usage|limit)_bytes|last_seen|spec_memory_limit_bytes|start_time_seconds|processes)"
regex = "container_(memory_(usage_bytes|working_set_bytes|rss)|cpu_(usage_seconds_total|cfs_throttled_seconds_total)|network_(receive|transmit)_bytes_total|fs_(usage|limit)_bytes|last_seen|spec_memory_limit_bytes|start_time_seconds|processes)"
action = "keep"
}
@ -74,15 +70,9 @@ prometheus.relabel "cadvisor_trim" {
//
// NB: на пустые панели это правило НЕ влияло. Причина была в cAdvisor 0.52 на
// Docker 29 — до сюда доезжал ровно один ряд, корневой. Лечится версией 0.55.1.
//
// `up`/`scrape_samples_scraped` лейбла `name` не несут вовсе (они не про
// конкретный контейнер, а про сам скрейп) — фильтр по нему вырезал бы и их.
// Поэтому здесь смотрим на пару (__name__, name): для служебных рядов
// достаточно самого __name__, для контейнерных метрик по-прежнему обязателен
// непустой name.
rule {
source_labels = ["__name__", "name"]
regex = "up;.*|scrape_samples_scraped;.*|container_[^;]*;.+"
source_labels = ["name"]
regex = ".+"
action = "keep"
}
}
@ -141,18 +131,6 @@ loki.process "scrub_credentials" {
expression = "://[^:@/ ]+:([^@ ]+)@"
replace = "***"
}
// ── Секреты в query-строке (#3354) ─────────────────────────────────────────
// То же выражение, что в alloy-apps.alloy, и по той же причине — подробности
// там. Здесь оно нужно не меньше: на инфра-хосте живут Forgejo и GlitchTip,
// у обоих есть ручки с `?token=` в адресе, и их логи идут в тот же Loki.
//
// Группа захвата — на ЗНАЧЕНИИ: Alloy заменяет содержимое групп, а не весь
// совпавший фрагмент (как у DSN выше, где группа на пароле).
stage.replace {
expression = "(?i)[?&][\\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=([^&\\s\"'<>]+)"
replace = "***"
}
}
loki.relabel "journal" {

View file

@ -107,7 +107,7 @@
{
"type": "timeseries",
"title": "Запросы по классам ответов",
"description": "Классы, а не отдельные коды: форма графика важнее точного номера. Всплеск 4xx без 5xx — обычно сканер или сломанный клиент; всплеск 5xx — наша ошибка. Линии НЕ стекируются: высота красной линии — это и есть число пятисоток, а не сумма со всем, что под ней.",
"description": "Классы, а не отдельные коды: форма графика важнее точного номера. Всплеск 4xx без 5xx — обычно сканер или сломанный клиент; всплеск 5xx — наша ошибка.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 },
"targets": [
@ -117,7 +117,7 @@
{ "refId": "D", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=~\"5..\"}[5m]))", "legendFormat": "{{app}} · 5xx" }
],
"fieldConfig": {
"defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 } },
"defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 25, "stacking": { "mode": "normal" }, "showPoints": "never", "lineWidth": 1 } },
"overrides": [
{ "matcher": { "id": "byRegexp", "options": ".*2xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] },
{ "matcher": { "id": "byRegexp", "options": ".*3xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } } ] },

View file

@ -1,161 +0,0 @@
{
"uid": "gendesign-product",
"title": "Продуктовые метрики",
"description": "Числа бизнеса, а не процесса: сколько людей реально что-то сделали в «Мере» и «Птице» (#3471). Источник — те же счётчики Prometheus, что и на дашборде «Приложения», только считают не HTTP-статусы, а продуктовые события (оценка, лид, отчёт, вход, обращение в поддержку). Часовое окно нарочно грубое: при текущем трафике (сотни событий в сутки) минутные всплески — шум, а не сигнал.",
"tags": ["gendesign", "product"],
"timezone": "browser",
"editable": false,
"schemaVersion": 39,
"refresh": "5m",
"time": { "from": "now-24h", "to": "now" },
"panels": [
{ "type": "row", "title": "Мера — воронка продукта", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 } },
{
"type": "timeseries",
"title": "Оценок в час, по исходу",
"description": "insufficient_data — не ошибка: аналогов рядом с адресом не нашлось, расчёт прошёл штатно. Тревожиться стоит, если эта линия начинает расти быстрее ok — значит покрытие рынка проседает, а не то, что сломался расчёт.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 1 },
"targets": [
{
"refId": "A",
"expr": "sum by (outcome) (increase(mera_estimates_total[1h]))",
"legendFormat": "{{outcome}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "insufficient_data" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] },
{ "matcher": { "id": "byName", "options": "ok" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] }
]
}
},
{
"type": "timeseries",
"title": "Подсказки адреса в час, нашёлся ли результат",
"description": "found=no — человек напечатал адрес, а автокомплит ничего не предложил: либо адреса нет в базе, либо он вне зоны покрытия. Устойчивый рост этой линии — повод расширять покрытие, а не баг одного запроса.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 1 },
"targets": [
{
"refId": "A",
"expr": "sum by (found) (increase(mera_address_suggestions_total[1h]))",
"legendFormat": "найден: {{found}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": [
{ "matcher": { "id": "byRegexp", "options": ".*no.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }
]
}
},
{
"type": "timeseries",
"title": "Лиды и скачанные отчёты в час",
"description": "Лид — заявка с телефоном после оценки, отчёт — скачанный PDF по оценке. Оба редкие: сверяйте с недельным окном (кнопка времени вверху), часовой провал сам по себе ни о чём не говорит при таком трафике.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 9 },
"targets": [
{ "refId": "A", "expr": "increase(mera_leads_total[1h])", "legendFormat": "лиды" },
{ "refId": "B", "expr": "increase(mera_reports_exported_total[1h])", "legendFormat": "отчёты" }
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": []
}
},
{
"type": "timeseries",
"title": "Входы в час, по исходу",
"description": "Устойчивый рост failed при ровном success — либо перебор паролей, либо сломался клиент (истёкшая сессия, старый билд фронта). При подозрении на перебор смотрите заодно панель «Отказы авторизации и лимитера» на дашборде «Приложения» — там 401/429 по HTTP.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 9 },
"targets": [
{
"refId": "A",
"expr": "sum by (result) (increase(mera_logins_total[1h]))",
"legendFormat": "{{result}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "failed" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] },
{ "matcher": { "id": "byName", "options": "success" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] }
]
}
},
{
"type": "timeseries",
"title": "Обращения в поддержку в час, по каналу",
"description": "anon — обращения с экрана входа, без логина (типично «не могу войти»), web — уже залогиненные пользователи. Всплеск anon без роста web — обычно означает баг именно в форме/процессе входа, а не общий рост нагрузки на поддержку.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 17 },
"targets": [
{
"refId": "A",
"expr": "sum by (channel) (increase(mera_support_messages_total[1h]))",
"legendFormat": "{{channel}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "anon" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }
]
}
},
{ "type": "row", "title": "Птица — экспорт отчётов", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } },
{
"type": "timeseries",
"title": "Экспортов отчётов по участку в час, по формату",
"description": "Формат — то, что реально скачали: md/json/docx/pptx/pdf/tg-сводка §22-форсайта плюс best_layouts_pdf (ТЗ на проектирование). Провал всех форматов разом при живом трафике на дашборде «Приложения» значит, что сломан сам экспорт, а не рендер одного конкретного формата.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 26 },
"targets": [
{
"refId": "A",
"expr": "sum by (format) (increase(sitefinder_reports_exported_total[1h]))",
"legendFormat": "{{format}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
},
"overrides": []
}
}
]
}

View file

@ -1,16 +0,0 @@
# Эта папка сознательно пустая
Grafana умеет провижинить contact points, notification policies и alert rules
файлами отсюда (`/etc/grafana/provisioning/alerting`). Не клади их сюда.
Решение (#3158, 12.09.2026): единственный путь доставки тревог — Prometheus
(правила) + Alertmanager (маршрутизация, Telegram). Grafana только рисует.
Встроенный Alerting выключен явно (`GF_UNIFIED_ALERTING_ENABLED: "false"` в
`docker-compose.metrics.yml`, секция `grafana`) — при живом API 12.09.2026
единственным контакт-поинтом был стоковый `grafana-default-email` на
`example@email.com`, `GF_SMTP_*` не задан, правил ноль. Файл сюда работать не
заставит: движок alerting выключен на уровне сервиса, провижининг в эту папку
Grafana просто не читает.
Если понадобится включить обратно — сначала пересмотреть само решение в
`docker-compose.metrics.yml`, а не просто добавить файл в эту папку.

View file

@ -44,21 +44,6 @@ groups:
summary: "С продуктового хоста 15 минут не приходят метрики"
description: "Либо лёг агент на Poincare, либо оборван канал до metrics.gendsgn.ru, либо приёмник не принимает remote-write."
# cAdvisor как job не публикует `up` естественным образом — до фикса
# keep-фильтра в alloy-infra.alloy/alloy-apps.alloy (#3471) эта серия
# вырезалась тем же правилом, что чистит container_* от мусорных
# лейблов. Итог — смерть cAdvisor молча гасила ContainerRestartLoop и
# ContainerNearMemoryLimit: обе метрики просто переставали поступать, а
# выглядело это как «событий не было».
- alert: CadvisorDown
expr: up{job="cadvisor"} == 0 or absent(up{job="cadvisor"})
for: 5m
labels:
severity: critical
annotations:
summary: "cAdvisor не отвечает"
description: "{{ if $labels.host }}{{ $labels.host }}: {{ end }}job=\"cadvisor\" вернул up=0 либо серия пропала целиком. Без неё контейнерные алерты этого хоста молчат вне зависимости от реального состояния контейнеров."
# ── Хост ────────────────────────────────────────────────────────────────────
- name: host
interval: 60s
@ -140,19 +125,11 @@ groups:
description: "{{ $labels.host }} / {{ $labels.name }}: больше трёх стартов за полчаса."
# Подошёл к своему mem_limit — следующий шаг OOM-kill.
#
# ФОРМА ВЫРАЖЕНИЯ ВАЖНА, а не только условие. `A and B` возвращает ЗНАЧЕНИЯ
# ЛЕВОЙ части, отфильтрованные правой, — то есть в `$value` попадает именно
# A. Прежняя запись (`limit > 0 and working_set/limit > 0.90`) слала в
# Telegram лимит В БАЙТАХ, отрендеренный как процент: боевое сообщение
# 12.09 — «2.684e+11% от mem_limit» при limit = 2 684 354 560 Б. Условие
# при этом срабатывало верно, врал только текст. Поэтому отношение стоит
# СЛЕВА, а отсев нулевого лимита убран внутрь знаменателя: `(X > 0)`
# выбрасывает серии без лимита ДО деления.
- alert: ContainerNearMemoryLimit
expr: |
container_memory_working_set_bytes{name!=""}
/ (container_spec_memory_limit_bytes{name!=""} > 0) > 0.90
container_spec_memory_limit_bytes{name!=""} > 0
and container_memory_working_set_bytes{name!=""}
/ container_spec_memory_limit_bytes{name!=""} > 0.90
for: 15m
labels:
severity: warning
@ -160,135 +137,6 @@ groups:
summary: "Контейнер у своего потолка памяти"
description: "{{ $labels.host }} / {{ $labels.name }}: {{ $value | humanizePercentage }} от mem_limit. Дальше OOM-kill."
# tradein-tgbot и tradein-scraper не HTTP-сервисы — у них нет `up{}`
# вообще, поэтому крэш-без-рестарта или удаление контейнера иначе не
# поймать. `absent()` на каждое имя отдельно (не одним regex-селектором):
# regex-селектор с несколькими сериями считается «пустым» только когда
# ПРОПАЛИ ОБЕ — если жив хотя бы один из двух контейнеров, absent() по
# общему selector'у молчит и не заметит пропажу второго.
#
# ВАЖНО, чего это правило НЕ ловит: container_last_seen обновляется, пока
# Docker видит контейнер живым, — зависший, но не упавший процесс
# (внутренний цикл встал, контейнер по-прежнему числится running) эту
# метрику не тронет. Слепая зона «живой процесс с застрявшим циклом»
# остаётся открытой: подходящей метрики для неё сейчас нет.
- alert: TradeInBackgroundContainerMissing
expr: |
absent(container_last_seen{name="tradein-tgbot"})
or absent(container_last_seen{name="tradein-scraper"})
for: 5m
labels:
severity: critical
annotations:
summary: "Фоновый контейнер Меры пропал из cAdvisor"
description: "{{ $labels.name }}: серия container_last_seen исчезла — контейнер, судя по всему, не работает и не перезапускается."
# ── Приложение ──────────────────────────────────────────────────────────────
# `job="app"` — job из alloy-apps.alloy, лейбл `app` различает продукты
# (sitefinder / mera). Метрики отдаёт `MetricsMiddleware`
# (`backend/app/observability/metrics.py` у «Птицы»,
# `tradein-mvp/backend/app/observability/metrics.py` у «Меры») — счётчик
# `http_requests_total{method,route,status}` и гистограмма
# `http_request_duration_seconds{method,route}`. До этой группы доля 5xx и
# задержка были видны только постфактум в GlitchTip, без порога срабатывания
# (#3471).
#
# severity: critical + host: apps здесь ОБЯЗАТЕЛЬНЫ содержательно, не для
# красоты: именно эта пара матчится маршрутом telegram-clients в
# alertmanager.yml.tmpl — тот зовёт дежурного и напоминает каждые 30 минут.
# `host` выставлен статически: `sum by (app)` вырезает его из результата
# запроса, а job="app" в принципе существует только на продуктовом хосте.
- name: app
interval: 60s
rules:
# Гейт по RPS внутри знаменателя — тот же приём, что у
# PostgresLowHotUpdateRatio: делит только там, где трафик уже есть,
# иначе один упавший запрос при нулевой нагрузке даёт 100% и будит
# дежурного зря.
- alert: AppHighErrorRate
expr: |
sum by (app) (rate(http_requests_total{job="app", status=~"5.."}[5m]))
/ (sum by (app) (rate(http_requests_total{job="app"}[5m])) > 0.1) > 0.05
for: 5m
labels:
severity: critical
host: apps
annotations:
summary: "Доля 5xx выше 5%"
description: "{{ $labels.app }}: {{ $value | humanizePercentage }} ответов 5xx за последние 5 минут при RPS выше 0.1."
# Порог 5s — заведомо выше рабочего профиля обоих продуктов (у «Меры»
# типичный расчёт 90мс, у «Птицы» верхняя граница гистограммы — 60с под
# тяжёлую геометрию, но это единичные хвостовые запросы, не p95).
# Калибровка по реальному трафику — отдельная задача, не эта.
- alert: AppHighLatencyP95
expr: |
histogram_quantile(0.95, sum by (le, app) (rate(http_request_duration_seconds_bucket{job="app"}[10m]))) > 5
for: 10m
labels:
severity: critical
host: apps
annotations:
summary: "p95 задержки ответа выше 5 секунд"
description: "{{ $labels.app }}: p95 за 10 минут — {{ $value | humanizeDuration }}."
# ── Redis и очередь Celery (#3471) ───────────────────────────────────────────
# Слепая зона: до этих правил ни redis_*, ни celery_* не собирались вовсе.
# Redis — общий инстанс на три потребителя (celery-брокер Site Finder, кэш
# trade-in, glitchtip — см. docker-compose.prod.yml), поэтому его смерть
# клиентская, отсюда severity: critical без явного host: apps — серия
# приходит только с продуктового alloy (alloy-apps.alloy), host в неё
# проставляется через external_labels уже на месте.
#
# ⚠️ Имена метрик celery_queue_length / celery_worker_up /
# celery_task_failed_total — по документации celery-exporter на момент
# написания правил, без проверки на реальном брокере (см. комментарий у
# сервиса celery-exporter в docker-compose.metrics-agent.yml). Сверить после
# первого деплоя.
- name: redis-celery
interval: 60s
rules:
- alert: RedisDown
expr: up{job="redis"} == 0 or redis_up == 0
for: 5m
labels:
severity: critical
annotations:
summary: "Redis недоступен"
description: "redis_exporter не может достучаться до Redis (или сам процесс лёг). Разом теряют связь celery-брокер Site Finder, SearchCache trade-in и glitchtip."
# `absent()` — как у CadvisorDown: если сам celery-exporter не поднялся,
# серии celery_worker_up не будет вообще, а не будет со значением 0.
- alert: NoActiveCeleryWorkers
expr: count(celery_worker_up == 1) == 0 or absent(celery_worker_up)
for: 5m
labels:
severity: critical
annotations:
summary: "Ни одного живого воркера Celery"
description: "celery-exporter не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
# Порог 150 ПРЕДВАРИТЕЛЬНЫЙ: реальных данных по глубине очереди нет (до
# этой правки метрика не собиралась). beat_schedule.py на момент правки
# содержит 44 periodic-задачи с разным временем срабатывания — даже
# маловероятный залп всех разом даёт кратно меньше 150. Порог взят с
# запасом сознательно и требует пересмотра через неделю наблюдений по
# факту `celery_queue_length`.
#
# `delta(...) >= 0` — очередь не УМЕНЬШАЕТСЯ за 15 минут (тот же приём,
# что и "растёт и не разгребается" в тексте задачи): просто высокое
# значение без этого условия поймало бы и здоровый кратковременный всплеск.
- alert: CeleryQueueGrowing
expr: |
celery_queue_length{queue_name="celery"} > 150
and delta(celery_queue_length{queue_name="celery"}[15m]) >= 0
for: 15m
labels:
severity: warning
annotations:
summary: "Очередь Celery растёт и не разгребается"
description: "В очереди {{ $value }} задач, за 15 минут меньше не стало. Похоже на залипший воркер или устойчивый рост нагрузки."
# ── Postgres ────────────────────────────────────────────────────────────────
- name: postgres
interval: 60s
@ -324,20 +172,12 @@ groups:
# Раздутие. Не мгновенный сигнал, а тренд — но именно его отсутствие
# позволило 91 день не замечать 198 апдейтов на строку.
#
# Та же ловушка `A and B`, что и у ContainerNearMemoryLimit, и здесь она
# опаснее: в `$value` попадал `rate(tup_upd[6h])` — АПДЕЙТОВ В СЕКУНДУ, а
# текст называл это долей HOT. Боевое сообщение 12.09 — «доля HOT 75.21%»
# при пороге срабатывания «доля < 20%»: число само себе противоречило и
# выглядело правдоподобно, поэтому никто не заметил (замер 12.09 по той же
# таблице listings: rate(tup_upd[6h]) = 0.0411 → сообщение сказало бы
# «4.11%», настоящая доля HOT = 0.00%). Гейт по объёму апдейтов
# (> 0.5/с — «трафик есть, значит вопрос осмыслен») перенесён внутрь
# знаменателя: там он и фильтрует серии, и защищает от деления на ноль.
- alert: PostgresLowHotUpdateRatio
expr: |
rate(pg_table_write_amplification_tup_upd[6h]) > 0.5
and
rate(pg_table_write_amplification_tup_hot_upd[6h])
/ (rate(pg_table_write_amplification_tup_upd[6h]) > 0.5) < 0.2
/ rate(pg_table_write_amplification_tup_upd[6h]) < 0.2
for: 6h
labels:
severity: warning
@ -345,11 +185,6 @@ groups:
summary: "Обновления идут мимо HOT"
description: "{{ $labels.host }} / {{ $labels.table }}: доля HOT {{ $value | humanizePercentage }}. Каждый такой апдейт переписывает строку во все индексы и заново тостит длинные поля — так набегает раздутие."
# Третье правило того же семейства `A and B` — и единственное, где текст
# верен: `$value` тут печатается без humanize, а слева стоит ровно то, что
# описание и называет («N мёртвых»). Совпадение, а не заслуга формы: если
# когда-нибудь захочется печатать здесь ДОЛЮ, отношение придётся вынести
# влево, как в двух правилах выше.
- alert: PostgresDeadTuplesHigh
expr: |
pg_table_write_amplification_dead_tup > 1000000

View file

@ -1,162 +0,0 @@
#!/usr/bin/env python3
"""Ретранслятор Bot API продукта через инфраструктурный хост Beget (#3471).
ЗАЧЕМ. Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` из контейнера
`tradein-tgbot` на Selectel 9 успешных из 12, три `ConnectTimeout`. TCP на 443
до адреса, резолвящегося на Selectel (149.154.167.220) 5 из 6. Тот же TCP до
адреса, резолвящегося на Beget (149.154.166.110) 8 из 8. За сутки в логе бота
508 строк `network error`, за 30 дней 92 обрыва итерации poll loop. Путь до
Telegram с Selectel лоссовый, с Beget чистый: Alertmanager (живёт на Beget)
пишет в тот же чат без проблем, а бот поддержки на Selectel часть отправок
теряет. Решение не чинить сеть Selectel (вне контроля), а дать продуктовым
сервисам обходной путь через хост, с которого Telegram отвечает надёжно.
ЧТО ПРОКСИРУЕТСЯ. Метод Bot API целиком путь `/bot<TOKEN>/<method>`, тело,
query. Не только отправка: `getUpdates` (long-poll) страдает от потерь ровно
так же, как `sendMessage`, и это тот же HTTP-путь через тот же испорченный
транзит.
ТОКЕН НЕ ЛОГИРУЕТСЯ. Он приходит в пути запроса. `log_request` переопределён
ПОЛНОСТЬЮ (не вызывает `super()`): дефолт stdlib кладёт в лог `requestline`
целиком, включая токен. Здесь путь редактируется до записи в лог.
АУТЕНТИФИКАЦИЯ. Общий секрет в заголовке `X-Relay-Secret`, по образцу общего
секрета `X-Internal-Auth-Secret` в этом же стеке (`app/core/config.py`,
`tradein_internal_auth_secret`) сравнение строкой (не сравнение таймингов:
секрет не является паролем пользователя, ценность атаки по времени здесь
исчезающе мала при секрете длиной от 32 байт, а stdlib `hmac` лишняя
зависимость ради stdlib-only сервиса). Домен публичный, без секрета отказ,
а не тихий приём.
БЕЗ ЗАВИСИМОСТЕЙ. Только стандартная библиотека тот же принцип, что у
`ops/metrics/alert-ack/app.py`: сервис обязан подниматься, даже когда всё
остальное сломано, и не тащить установку пакетов.
ОТКАЗ РЕТРАНСЛЯТОРА НЕ ДОЛЖЕН РОНЯТЬ БОТА. Это реализовано НЕ здесь, а на
стороне клиента (`tradein-mvp/backend/app/services/tgbot/client.py`): при
транспортном отказе похода на ретранслятор клиент делает одну попытку
напрямую к `api.telegram.org`. Здесь достаточно не быть точкой отказа хуже
прямого пути: таймаут до апстрима подобран так, чтобы не обрубать long-poll
`getUpdates` раньше, чем это сделал бы сам Telegram.
Переменные окружения:
TG_RELAY_SECRET обязательна общий секрет, сверяется с
заголовком X-Relay-Secret
TG_RELAY_PORT порт (по умолчанию 8080)
TG_RELAY_UPSTREAM_TIMEOUT_S таймаут запроса к api.telegram.org в секундах
(по умолчанию 75 с запасом над самым долгим
long-poll getUpdates, который шлёт клиент:
timeout=30 + 10с запаса на стороне httpx = 40с)
"""
from __future__ import annotations
import logging
import os
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("tg-relay")
RELAY_SECRET = os.environ.get("TG_RELAY_SECRET", "")
UPSTREAM = "https://api.telegram.org"
UPSTREAM_TIMEOUT_S = float(os.environ.get("TG_RELAY_UPSTREAM_TIMEOUT_S", "75"))
SECRET_HEADER = "X-Relay-Secret"
def redact_path(path: str) -> str:
"""Прячет токен из `/bot<TOKEN>/method[?query]` для логов и ошибок.
Вынесена в модульную функцию (не метод), чтобы быть проверяемой напрямую
без поднятия HTTP-сервера.
"""
if not path.startswith("/bot"):
return path
rest = path[len("/bot") :]
_token_part, sep, tail = rest.partition("/")
if not sep:
# Ни `/method`, ни query — токен без хвоста (или токен+query без slash).
return "/bot<REDACTED>"
tail = tail.split("?", 1)[0]
return f"/bot<REDACTED>/{tail}" if tail else "/bot<REDACTED>"
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt: str, *args) -> None: # noqa: A003 — сигнатура из stdlib
log.info("%s", fmt % args)
def log_request(self, code="-", size="-") -> None: # noqa: A003 — сигнатура из stdlib
# ПОЛНОСТЬЮ заменяет реализацию BaseHTTPRequestHandler (не вызывает
# super()): та кладёт в лог self.requestline целиком, а он для Bot API
# содержит токен в пути.
log.info('%s "%s %s" %s', self.address_string(), self.command, redact_path(self.path), code)
def _reply(self, code: int, body: bytes, ctype: str = "application/json") -> None:
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if body:
self.wfile.write(body)
def _authorized(self) -> bool:
return bool(RELAY_SECRET) and self.headers.get(SECRET_HEADER) == RELAY_SECRET
def _proxy(self) -> None:
if self.path == "/healthz":
self._reply(200, b"ok", "text/plain; charset=utf-8")
return
if not self._authorized():
self._reply(401, b'{"ok":false,"description":"unauthorized"}')
return
if not self.path.startswith("/bot"):
self._reply(404, b'{"ok":false,"description":"not found"}')
return
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else None
req = urllib.request.Request(
UPSTREAM + self.path,
data=body,
method=self.command,
headers={"Content-Type": self.headers.get("Content-Type") or "application/json"},
)
try:
# Таймаут ЯВНО шире любого long-poll getUpdates клиента — иначе
# ретранслятор обрубит соединение раньше площадки и превратит
# штатный long-poll в вечный network error, то есть станет хуже
# прямого пути, а не лучше.
with urllib.request.urlopen(req, timeout=UPSTREAM_TIMEOUT_S) as resp:
out = resp.read()
self._reply(resp.status, out, resp.headers.get("Content-Type") or "application/json")
except urllib.error.HTTPError as exc:
# Telegram ответил ошибкой (4xx/5xx) — это НЕ отказ ретранслятора,
# передаём как есть, клиент сам решает, ретраить или нет.
out = exc.read()
ctype = exc.headers.get("Content-Type") if exc.headers else None
self._reply(exc.code, out, ctype or "application/json")
except Exception as exc: # noqa: BLE001 — любой отказ апстрима не должен уронить сервис
log.warning("upstream недоступен (%s): %s", redact_path(self.path), type(exc).__name__)
self._reply(502, b'{"ok":false,"description":"relay upstream unreachable"}')
def do_GET(self) -> None: # noqa: N802 — имя из stdlib
self._proxy()
def do_POST(self) -> None: # noqa: N802 — имя из stdlib
self._proxy()
def main() -> None:
if not RELAY_SECRET:
raise SystemExit("не задан TG_RELAY_SECRET — домен публичный, отказ на старте")
port = int(os.environ.get("TG_RELAY_PORT", "8080"))
log.info("tg-relay слушает :%d, upstream_timeout=%.0fs", port, UPSTREAM_TIMEOUT_S)
ThreadingHTTPServer(("", port), Handler).serve_forever()
if __name__ == "__main__":
main()

View file

@ -1,144 +0,0 @@
"""Тесты для tg-relay — ретранслятора Bot API продукта через Beget (#3471).
Гоняют реальный `ThreadingHTTPServer` на localhost (эфемерный порт), апстрим
`urllib.request.urlopen` подменяется моком реальный api.telegram.org НЕ
дёргаем никогда.
"""
from __future__ import annotations
import http.client
import json
import logging
import threading
from unittest import mock
import pytest
import app as relay
def _fake_upstream_response(status: int = 200, body: bytes = b'{"ok": true, "result": []}'):
resp = mock.MagicMock()
resp.status = status
resp.read.return_value = body
resp.headers.get.return_value = "application/json"
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
return resp
@pytest.fixture()
def secret(monkeypatch):
monkeypatch.setattr(relay, "RELAY_SECRET", "test-secret-value")
return "test-secret-value"
@pytest.fixture()
def server(secret):
httpd = relay.ThreadingHTTPServer(("127.0.0.1", 0), relay.Handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
yield httpd
finally:
httpd.shutdown()
thread.join(timeout=5)
def _request(server, path, headers=None, method="GET", body=None):
conn = http.client.HTTPConnection(*server.server_address, timeout=5)
try:
conn.request(method, path, body=body, headers=headers or {})
resp = conn.getresponse()
return resp.status, resp.read()
finally:
conn.close()
def test_redact_path_hides_token_from_method_path():
assert relay.redact_path("/bot123456:ABC-DEF/sendMessage") == "/bot<REDACTED>/sendMessage"
def test_redact_path_hides_bare_token():
assert relay.redact_path("/bot123456:ABC-DEF/") == "/bot<REDACTED>"
assert relay.redact_path("/bot123456:ABC-DEF") == "/bot<REDACTED>"
def test_redact_path_leaves_non_bot_paths_untouched():
assert relay.redact_path("/healthz") == "/healthz"
def test_missing_secret_rejected_without_touching_upstream(server):
with mock.patch.object(relay.urllib.request, "urlopen") as mocked:
status, _body = _request(server, "/bot123:TOK/getMe")
assert status == 401
mocked.assert_not_called()
def test_wrong_secret_rejected_without_touching_upstream(server):
with mock.patch.object(relay.urllib.request, "urlopen") as mocked:
status, _body = _request(server, "/bot123:TOK/getMe", headers={"X-Relay-Secret": "wrong"})
assert status == 401
mocked.assert_not_called()
def test_valid_secret_passes_method_path_and_body_unmodified(server, secret):
with mock.patch.object(
relay.urllib.request, "urlopen", return_value=_fake_upstream_response()
) as mocked:
status, body = _request(
server,
"/bot123:TOK/sendMessage",
headers={"X-Relay-Secret": secret, "Content-Type": "application/json"},
method="POST",
body=b'{"chat_id": 1, "text": "hi"}',
)
assert status == 200
assert json.loads(body) == {"ok": True, "result": []}
sent_request = mocked.call_args[0][0]
assert sent_request.full_url == "https://api.telegram.org/bot123:TOK/sendMessage"
assert sent_request.data == b'{"chat_id": 1, "text": "hi"}'
assert sent_request.get_method() == "POST"
def test_get_updates_uses_upstream_timeout_wider_than_longest_client_poll(server, secret):
"""Клиент шлёт getUpdates(timeout=30) → httpx ждёт ответ 40с (30 + запас
10с). Апстрим-таймаут ретранслятора обязан быть шире, иначе он обрубит
long-poll раньше площадки и превратит штатный цикл в вечный network error."""
with mock.patch.object(
relay.urllib.request, "urlopen", return_value=_fake_upstream_response()
) as mocked:
_request(
server,
"/bot123:TOK/getUpdates",
headers={"X-Relay-Secret": secret},
method="POST",
body=b'{"offset": 1, "timeout": 30}',
)
_req, kwargs = mocked.call_args
assert kwargs["timeout"] >= 40
def test_token_never_appears_in_logs(server, secret, caplog):
token = "999888777:VerySecretTokenValue"
with caplog.at_level(logging.INFO, logger="tg-relay"):
with mock.patch.object(
relay.urllib.request, "urlopen", return_value=_fake_upstream_response()
):
_request(server, f"/bot{token}/getMe", headers={"X-Relay-Secret": secret})
for record in caplog.records:
assert token not in record.getMessage()
def test_unauthorized_attempt_does_not_leak_token_either(server, caplog):
token = "999888777:VerySecretTokenValue"
with caplog.at_level(logging.INFO, logger="tg-relay"):
_request(server, f"/bot{token}/getMe")
for record in caplog.records:
assert token not in record.getMessage()

View file

@ -1,162 +0,0 @@
#!/usr/bin/env python3
"""Гейт: сервисы МЕРЫ не ссылаются друг на друга именем, которое двоится (#3282).
ПОЧЕМУ. ПТИЦА и МЕРА разные compose-проекты, но оба назвали свои сервисы
`backend`, `postgres`, `frontend`, и оба подключены к общей сети
`gendesign_shared`. Docker отдаёт ОБА адреса на такое имя, клиент берёт любой:
изнутри tradein-frontend:
backend 172.18.0.6 (МЕРА) + 172.18.0.9 (ПТИЦА)
tradein-backend 172.18.0.6
30.08.2026 это уронило публичный лендинг. `BACKEND_URL=http://backend:8000`
уводил серверный рендер в бэкенд ПТИЦЫ, тот отвечал `401 no authenticated
user`, и страница рендерилась БЕЗ витрины: без ленты сделок, без строк сверки
«прогноз против факта» и без подписи разброса. Страница про точность без
единого доказательства.
Отказ был ТИХИЙ вдвойне. Во-первых, `fetch` не бросает: приходит валидный HTTP-
ответ, просто чужой. Во-вторых, имя двоится, поэтому часть перегенераций
попадала в правильный адрес утром страница отдавалась с данными, к обеду без
них, и выглядело это как случайная поломка, а не как ошибка конфигурации.
У DSN та же болезнь и худшая цена: `@postgres:5432/tradein` мог уйти в базу
ПТИЦЫ. Там спасало лишь несовпадение кредов то есть отказ вместо тихого
чтения не тех данных. На такую защиту полагаться нельзя: она держится на том,
что у чужой базы нет пользователя с нашим паролем.
ЧТО ДЕЛАЕТ. Берёт имена сервисов ОБОИХ compose, находит пересечение (это и есть
двоящиеся имена) и проверяет, что в compose МЕРЫ нет ссылок на них как на хост
ни `http://<имя>:порт`, ни `@<имя>:порт` в DSN. Лечение всегда одно:
подставить `container_name` (`tradein-backend`, `tradein-postgres`), он
однозначен по построению.
ЧЕГО НЕ ЛОВИТ. Ссылки, собираемые в рантайме из кусков, и имена, попадающие в
контейнер иным путём (`.env` на хосте, секреты CI). Гейт читает только оба
compose-файла.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
MERA_COMPOSE = REPO / "tradein-mvp" / "docker-compose.prod.yml"
PTICA_COMPOSE = REPO / "docker-compose.prod.yml"
# Имя сервиса верхнего уровня: ровно два пробела, затем имя и двоеточие.
_SERVICE_RE = re.compile(r"^ ([a-z][a-z0-9_-]*):\s*$", re.M)
# Ссылка на хост: `//имя:порт` (URL) либо `@имя:порт` (DSN).
_HOST_RE = re.compile(r"(?://|@)([a-z][a-z0-9_-]*):\d+")
def service_names(text: str) -> set[str]:
"""Имена сервисов верхнего уровня. Тома и сети сюда не попадают: они
объявляются в своих секциях с тем же отступом, поэтому список заведомо шире
нужного и это безопасная сторона, лишнее имя даст лишнюю проверку, а не
пропуск."""
return set(_SERVICE_RE.findall(text))
def violations(mera: str, ptica: str) -> list[str]:
ambiguous = service_names(mera) & service_names(ptica)
if not ambiguous:
return []
found: list[str] = []
for num, line in enumerate(mera.splitlines(), 1):
stripped = line.strip()
if stripped.startswith("#"):
continue
for host in _HOST_RE.findall(line):
if host in ambiguous:
found.append(f"{MERA_COMPOSE.name}:{num}: хост «{host}» двоится → {stripped[:90]}")
return found
def selftest() -> int:
"""Гейт обязан уметь краснеть — иначе зелёный ничего не значит."""
ptica = "services:\n backend:\n image: x\n postgres:\n image: y\n"
# У фикстуры МЕРЫ обязан быть сервис с ИМЕНЕМ ИЗ ПТИЦЫ — иначе пересечение
# пусто, проверять нечего, и «не поймал» означало бы «нечего было ловить».
# На этом селфтест и провалился в первой редакции.
bad = (
"services:\n backend:\n image: x\n"
" frontend:\n environment:\n URL: \"http://backend:8000\"\n"
)
if not violations(bad, ptica):
print("СЕЛФТЕСТ ПРОВАЛЕН: двоящийся хост в URL не пойман", file=sys.stderr)
return 1
bad_dsn = (
"services:\n postgres:\n image: y\n"
" backend:\n environment:\n DSN: \"pg://u:p@postgres:5432/db\"\n"
)
if not violations(bad_dsn, ptica):
print("СЕЛФТЕСТ ПРОВАЛЕН: двоящийся хост в DSN не пойман", file=sys.stderr)
return 1
ok = (
"services:\n backend:\n image: x\n"
" frontend:\n environment:\n URL: \"http://tradein-backend:8000\"\n"
)
if violations(ok, ptica):
print("СЕЛФТЕСТ ПРОВАЛЕН: однозначное имя признано нарушением", file=sys.stderr)
return 1
# Закомментированная строка — не конфигурация.
commented = (
"services:\n backend:\n image: x\n"
" frontend:\n environment:\n # URL: \"http://backend:8000\"\n"
)
if violations(commented, ptica):
print("СЕЛФТЕСТ ПРОВАЛЕН: комментарий принят за ссылку", file=sys.stderr)
return 1
print("селфтест пройден: гейт краснеет на URL и на DSN, молчит на однозначном имени")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--selftest", action="store_true", help="проверить, что гейт умеет краснеть")
args = ap.parse_args()
if args.selftest:
return selftest()
for path in (MERA_COMPOSE, PTICA_COMPOSE):
if not path.exists():
print(f"НЕ НАЙДЕН: {path}", file=sys.stderr)
return 1
mera = MERA_COMPOSE.read_text(encoding="utf-8")
ptica = PTICA_COMPOSE.read_text(encoding="utf-8")
shared = service_names(mera) & service_names(ptica)
if not shared:
print("пересекающихся имён сервисов нет — проверять нечего")
return 0
found = violations(mera, ptica)
if not found:
print(f"ok: двоятся имена {sorted(shared)}, но ссылок на них как на хост нет")
return 0
print("Ссылка на ДВОЯЩЕЕСЯ имя сервиса:\n", file=sys.stderr)
for line in found:
print(f" {line}", file=sys.stderr)
print(
"\nОба продукта делят сеть gendesign_shared, поэтому такое имя резолвится\n"
"в два адреса и клиент берёт любой. Подставьте container_name\n"
"(tradein-backend, tradein-postgres) — он однозначен.",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,187 +0,0 @@
#!/usr/bin/env python3
"""Гейт: подмена tradein-frontend не возвращает окно недоступности (#3274).
ПОЧЕМУ. Публичный лендинг meraocenka.ru лежал 3090 с на КАЖДОМ деплое МЕРЫ.
Причина НЕ медленная подмена контейнера (она стоит полсекунды), а то, что
`docker compose up -d` со СПИСКОМ сервисов работает в две фазы: сначала create
(старый контейнер каждого сервиса останавливается и удаляется иначе занято
`container_name`), потом start, в порядке зависимостей и с ожиданием их
условий. Всё, что между фазами, фронт лежит.
Замер на проде 10.09 (docker inspect .Created/.StartedAt, два деплоя подряд):
пачка сервисов: tradein-backend создан 15:01:40 запущен 15:02:10 (30 с)
один сервис: tradein-frontend создан 16:42:17.5 запущен 16:42:18.0 (0,5 с)
В логе Caddy у первого деплоя три 503 на лендинге (15:01:46, 15:01:52,
15:02:06), у второго ни одного.
ЧТО ДЕРЖИТ РЕШЕНИЕ, И ПОЧЕМУ ЭТО ГЕЙТ, А НЕ КОММЕНТАРИЙ. Половинки лежат в
разных файлах и обе невидимо обратимы:
1) deploy-tradein.yml `frontend` ВЫНЕСЕН из общего `up -d $SERVICES` в
свою команду. Достаточно дописать его обратно в SERVICES «за компанию»,
и окно вернётся целиком, молча: деплой останется зелёным.
2) caddy/sites/apps.caddy каждый `reverse_proxy tradein-frontend:3000`
импортирует (tradein_frontend_retry) (lb_try_duration), который добирает
оставшиеся ~0,5 с. Новый публичный путь копируют с соседнего блока и
если копируют блок БЕЗ импорта, именно этот путь снова отдаёт 502.
Оба отказа не видны ни по статусу джобы, ни по глазам: их видно только
непрерывной пробой во время деплоя (scripts/probe-deploy-window.sh).
ЧЕГО НЕ ЛОВИТ. Это разбор текста, а не исполнение: гейт не проверяет, что
команда реально отработала и что Caddy реально ретраит (это проверяется
пробой на живом деплое). Не смотрит на сервисы, кроме frontend, бэкенду
ретрай намеренно не дан (его старт длиннее, чем разумное ожидание клиента).
Запуск: python3 scripts/check-frontend-swap-window.py [--selftest]
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
WORKFLOW = REPO / ".forgejo" / "workflows" / "deploy-tradein.yml"
APPS_CADDY = REPO / "caddy" / "sites" / "apps.caddy"
SNIPPET_NAME = "tradein_frontend_retry"
FRONTEND_UPSTREAM = "reverse_proxy tradein-frontend:3000 {"
# `SERVICES="browser backend tgbot"` / `SERVICES="$SERVICES scraper"`.
_SERVICES_RE = re.compile(r'^\s*SERVICES=(["\']?)(.*?)\1\s*$', re.M)
# Одиночная команда подмены фронта.
_FRONTEND_UP_RE = re.compile(r"up -d --no-deps frontend\s*$", re.M)
def strip_comments(text: str) -> str:
"""Убирает строки-комментарии (shell/YAML/Caddy — везде `#`).
Обязательно: разбор дефекта живёт в комментарии рядом с правкой и содержит
его же формулировку. Без этого гейт спорил бы с собственным объяснением.
"""
return "\n".join(ln for ln in text.splitlines() if not ln.lstrip().startswith("#"))
def check_workflow(text: str) -> list[str]:
body = strip_comments(text)
errors: list[str] = []
for m in _SERVICES_RE.finditer(body):
value = m.group(2)
if re.search(r"(^|\s)frontend(\s|$)", value):
errors.append(
f'SERVICES={value!r} снова содержит `frontend`: он вернётся в общий '
f"`up -d` со списком сервисов, а это и есть окно 3090 с (#3274). "
f"Фронт подменяется отдельной командой `up -d --no-deps frontend`."
)
if not _FRONTEND_UP_RE.search(body):
errors.append(
"в deploy-tradein.yml нет отдельной команды `up -d --no-deps frontend` — "
"подмена фронта либо пропала, либо снова уехала в общую пачку (#3274)."
)
return errors
def check_caddy(text: str) -> list[str]:
errors: list[str] = []
lines = text.splitlines()
body = strip_comments(text)
if f"({SNIPPET_NAME}) {{" not in body:
errors.append(
f"снипет ({SNIPPET_NAME}) не объявлен в apps.caddy — импортировать нечего."
)
elif "lb_try_duration" not in body:
errors.append(
f"снипет ({SNIPPET_NAME}) есть, но без `lb_try_duration` — он больше "
"ничего не добирает, оставшиеся ~0,5 с подмены снова видны как 502."
)
# Блок апстрима фронта: от строки `reverse_proxy tradein-frontend:3000 {`
# до закрывающей скобки на её же отступе. Импорт должен быть внутри.
for i, line in enumerate(lines):
if line.strip().startswith("#") or FRONTEND_UPSTREAM not in line:
continue
indent = len(line) - len(line.lstrip())
block: list[str] = []
for nxt in lines[i + 1 :]:
if nxt.strip() == "}" and (len(nxt) - len(nxt.lstrip())) == indent:
break
block.append(nxt)
if not any(f"import {SNIPPET_NAME}" in b for b in block if not b.strip().startswith("#")):
errors.append(
f"apps.caddy:{i + 1} — `reverse_proxy tradein-frontend:3000` без "
f"`import {SNIPPET_NAME}`: на этом пути подмена контейнера снова "
f"видна посетителю как 502 (#3274)."
)
return errors
def selftest() -> None:
good_wf = 'SERVICES="browser backend tgbot"\n docker compose up -d --no-deps frontend\n'
assert check_workflow(good_wf) == [], check_workflow(good_wf)
bad_wf = 'SERVICES="browser backend frontend tgbot"\n docker compose up -d --no-deps frontend\n'
assert any("SERVICES" in e for e in check_workflow(bad_wf)), "не поймал frontend в SERVICES"
missing_wf = 'SERVICES="browser backend tgbot"\n docker compose up -d --no-deps $SERVICES\n'
assert any("отдельной команды" in e for e in check_workflow(missing_wf)), (
"не поймал пропажу отдельной команды"
)
# Комментарий с той же формулировкой не должен ронять гейт.
commented = '# SERVICES="browser backend frontend tgbot" # так было до #3274\n' + good_wf
assert check_workflow(commented) == [], "гейт спорит с собственным комментарием"
good_caddy = (
"(tradein_frontend_retry) {\n lb_try_duration 2s\n}\n"
"handle {\n reverse_proxy tradein-frontend:3000 {\n"
" import tradein_frontend_retry\n header_up -X-Y\n }\n}\n"
)
assert check_caddy(good_caddy) == [], check_caddy(good_caddy)
bad_caddy = (
"(tradein_frontend_retry) {\n lb_try_duration 2s\n}\n"
"handle {\n reverse_proxy tradein-frontend:3000 {\n"
" header_up -X-Y\n }\n}\n"
)
assert any("без `import" in e for e in check_caddy(bad_caddy)), "не поймал блок без импорта"
no_snippet = (
"handle {\n reverse_proxy tradein-frontend:3000 {\n"
" import tradein_frontend_retry\n }\n}\n"
)
assert any("не объявлен" in e for e in check_caddy(no_snippet)), "не поймал пропажу снипета"
print("SELFTEST OK")
def main() -> int:
if "--selftest" in sys.argv:
selftest()
return 0
errors = check_workflow(WORKFLOW.read_text(encoding="utf-8"))
errors += check_caddy(APPS_CADDY.read_text(encoding="utf-8"))
if errors:
print("Гейт #3274 (окно подмены фронта) НЕ ПРОЙДЕН:\n")
for e in errors:
print(f"{e}")
print(
"\nЧем проверять эффект на живом деплое: scripts/probe-deploy-window.sh "
"(запускать НА хосте прода, см. шапку скрипта)."
)
return 1
print("Гейт #3274: фронт подменяется отдельной командой, все 9 апстримов с ретраем — OK")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,99 +0,0 @@
#!/bin/sh
# Проба окна недоступности при деплое (#3274).
#
# ЗАЧЕМ. Статус джобы деплоя зелёный независимо от того, видел ли посетитель
# ошибку: подмена контейнера происходит ВНУТРИ успешного прогона. Единственный
# честный замер — непрерывный опрос публичного адреса во время деплоя и подсчёт
# САМОЙ ДЛИННОЙ СЕРИИ подряд идущих не-200. Приёмка #3274 сформулирована именно
# так (комментарий от 05.09): «серия не-2xx/000 на / в окне мержа < 2 с».
#
# ГДЕ ЗАПУСКАТЬ — НА САМОМ ХОСТЕ (ssh poincare), не с ноутбука. Боевой периметр
# отбивает частые серии с одного внешнего адреса: замер 11.09 дал 3050 %
# `000` с внешнего IP при интервале 0 и ноль при 2 с, а с самого хоста — 0/10
# без пауз. Иначе защита периметра читается как простой сервиса.
#
# ПОЧЕМУ 000 СЧИТАЕТСЯ ОТДЕЛЬНО. curl отдаёт `000`, когда HTTP-ответа не было
# вообще (TCP/TLS не встал, таймаут). Это может быть и простой (Caddy
# пересоздан), и отбой периметра — сваливать его в одну кучу с 502/503 нельзя,
# иначе своя же защита попадёт в числа простоя.
#
# Запуск:
# scripts/probe-deploy-window.sh [URL] [ИНТЕРВАЛ_С] [ДЛИТЕЛЬНОСТЬ_С] [ФАЙЛ]
# scripts/probe-deploy-window.sh --summary <ФАЙЛ> # пересчитать сводку
# scripts/probe-deploy-window.sh --selftest # проверка счётчика серий
#
# Пример (окно деплоя, 10 минут с шагом 200 мс):
# ssh poincare 'nohup /opt/gendesign/scripts/probe-deploy-window.sh \
# https://meraocenka.ru/ 0.2 600 /tmp/probe-mera.tsv >/dev/null 2>&1 &'
set -eu
# Сводка по TSV (epoch<TAB>ISO-время<TAB>код<TAB>секунды): сколько чего, и главное — самая
# длинная серия подряд идущих не-200 в СЕКУНДАХ (по меткам времени самих
# образцов, а не «число образцов × интервал»: curl с таймаутом растягивает шаг,
# и умножение занизило бы реальную длину окна).
summarize() {
awk -F'\t' '
# Серия закрывается первым 200. Длина в секундах считается ТОЛЬКО в END:
# средний шаг известен лишь после прохода, а внутри цикла он ещё 0 — из-за
# этого первая версия занижала окно ровно на один шаг (поймал --selftest).
{ total++; code[$3]++
if ($3 == "200") {
if (streak > max_n) { max_n = streak; max_first = first_bad_ts; max_last = last_bad_ts; max_at = first_bad_at }
streak = 0
} else {
if (!streak) { first_bad_ts = $1; first_bad_at = $2 }
streak++; last_bad_ts = $1
}
if (prev_ts && $1 - prev_ts < 60) { gaps += $1 - prev_ts; gapn++ }
prev_ts = $1
}
END {
step = (gapn ? gaps / gapn : 0)
if (streak > max_n) { max_n = streak; max_first = first_bad_ts; max_last = last_bad_ts; max_at = first_bad_at }
printf "образцов: %d, шаг ~%.2f с\n", total, step
for (c in code) printf " %s: %d\n", c, code[c]
printf "максимальная серия не-200: %d образцов ≈ %.1f с (начало %s)\n", \
max_n, (max_n ? max_last - max_first + step : 0), (max_at ? max_at : "-")
}
' "$1"
}
if [ "${1:-}" = "--summary" ]; then
summarize "$2"
exit 0
fi
if [ "${1:-}" = "--selftest" ]; then
tmp=$(mktemp)
# 10 образцов с шагом 1 с: три подряд не-200 (2-я…4-я секунды) → серия ≈ 3 с.
printf '1000\tT0\t200\t0.01\n1001\tT1\t502\t0.01\n1002\tT2\t000\t5.00\n1003\tT3\t503\t0.01\n1004\tT4\t200\t0.01\n1005\tT5\t200\t0.01\n1006\tT6\t502\t0.01\n1007\tT7\t200\t0.01\n' >"$tmp"
out=$(summarize "$tmp")
rm -f "$tmp"
echo "$out"
echo "$out" | grep -q "максимальная серия не-200: 3 образцов ≈ 3.0 с" || {
echo "SELFTEST FAILED: серия посчитана неверно" >&2
exit 1
}
echo "$out" | grep -q " 000: 1" || { echo "SELFTEST FAILED: 000 не выделен отдельно" >&2; exit 1; }
echo "SELFTEST OK"
exit 0
fi
URL=${1:-https://meraocenka.ru/}
INTERVAL=${2:-1}
DURATION=${3:-900}
OUT=${4:-/tmp/probe-deploy-window.tsv}
: >"$OUT"
echo "проба: $URL каждые ${INTERVAL}с в течение ${DURATION}с$OUT"
end=$(( $(date +%s) + DURATION ))
while [ "$(date +%s)" -lt "$end" ]; do
# --max-time 5: зависший запрос не должен растягивать шаг пробы на минуты.
# -o /dev/null: тело не нужно, важны код и время.
line=$(curl -sS -o /dev/null --max-time 5 -w '%{http_code}\t%{time_total}' "$URL" 2>/dev/null || echo "000 0")
printf '%s\t%s\t%s\n' "$(date +%s.%N)" "$(date -u +%H:%M:%SZ)" "$line" >>"$OUT"
sleep "$INTERVAL"
done
echo "── сводка ─────────────────────────────────────────"
summarize "$OUT"

View file

@ -38,137 +38,12 @@ BASE_MERA="${SMOKE_MERA_BASE:-https://meraocenka.ru}"
BASE_MAIN="${SMOKE_MAIN_BASE:-https://gendsgn.ru}"
fail=0
noresp=0
# --- ТЕМП ЗАПРОСОВ И ПОВТОРЫ (11.09.2026) --------------------------------
#
# ЧТО СЛУЧИЛОСЬ. Прогон на голове main (a659b187) покраснел на двух последних
# проверках списка с кодом `000`. `000` у curl — это НЕ «пришёл неверный код»,
# а «ответа не было вовсе» (таймаут/обрыв). Периметр при этом был цел: те же
# пути, запрошенные вручную, отдавали 503 и 405 от приложения.
#
# ЗАМЕР (внешний IP, https://gendsgn.ru/trade-in/api/v1/me):
# серия без пауз — 5 обрывов из 12
# серия с паузой 0.5 c — 6 обрывов из 12
# серия с паузой 2 c — 0 обрывов из 8
# с самого хоста прода — 0 обрывов из 10 (все 401)
# То есть приложение отвечает, а частую серию запросов с одного внешнего
# адреса отбивает ВХОД (на хосте активен fail2ban). Смоук шлёт ~43 запроса
# подряд без пауз и попадает ровно под этот эффект — и падают именно
# ПОСЛЕДНИЕ проверки, потому что к концу серии счётчик уже набран.
#
# ОТСЮДА ДВА МЕХАНИЗМА, И НИ ОДИН НЕ ТРОГАЕТ САМИ ОЖИДАНИЯ:
# 1. Повтор ТОЛЬКО там, где ответа не было вовсе. Ответ с «не тем» кодом —
# это результат проверки, он не повторяется никогда: иначе ретрай
# маскировал бы настоящий регресс периметра, ради которого всё написано.
# 2. Пауза между проверками, чтобы серия не выглядела флудом.
#
# ПОЧЕМУ ПАУЗА 2 c, А НЕ МЕНЬШЕ. Это наименьшая величина, у которой есть
# замер: 0.5 c измеренно НЕ помогает (6 обрывов из 12 — не лучше, чем без
# пауз), 2 c даёт ноль обрывов, промежуточные значения никто не мерил, и
# взять их означало бы выдумать число. Цена ИЗМЕРЕНА, а не оценена: прогон
# целиком занимал 89 c и стал занимать 160 c (оба замера 11.09 с локальной
# машины, 43 проверки, все зелёные). Под это в воркфлоу периметра поднят
# timeout-minutes: неотвечающая проверка сетевого класса стоит 3×15 c таймаута
# + 2 c и 4 c пауз ретрая + 2 c паузы между проверками = 53 c, а худший случай
# (прод не отвечает вовсе — мертвы все 43) = 43 × 53 ≈ 2279 c ≈ 38 мин.
# Ни 5, ни 10 минут на него не хватало: job убивали ДО печати FAIL-строк и
# итога, то есть ровно там, где лог нужнее всего.
# Обе величины переопределяются из окружения — для отладки локально
# (`SMOKE_PAUSE=0 bash scripts/smoke-mera-perimeter.sh` даёт прежний темп).
SMOKE_PAUSE="${SMOKE_PAUSE:-2}"
SMOKE_ATTEMPTS="${SMOKE_ATTEMPTS:-3}"
# curl_try: запрос с повтором, если ответа не пришло ВООБЩЕ, и с паузой после.
#
# Признак «ответа не было» берём у самого curl — код возврата из СЕТЕВОГО
# класса (перечислен в константе NETWORK_RC ниже). Он строго эквивалентен
# `%{http_code}` = 000, но доступен ВСЕМ проверкам, включая те, которые
# http_code вообще не запрашивают: до этой правки обрыв в
# check_redirect_location читался как «Location не тот», а обрыв при загрузке
# лэндинга — как «сам лэндинг сломан». Один сторож в общей обёртке чинит все
# места сразу, а не только те две проверки, что покраснели.
#
# СЕТЕВОЙ КЛАСС — И ТОЛЬКО ОН. Коды живут в константе, а не в тексте
# комментария, чтобы описание и поведение не разъехались:
# 6 — DNS не разрешился
# 7 — соединение отвергнуто (вход лежит; REJECT у fail2ban выглядит так же)
# 28 — таймаут
# 35 — обрыв на TLS-хендшейке
# 52 — сервер закрыл соединение, не ответив
# 56 — обрыв при приёме ответа
# Общее у них ровно одно: ответа не получено, и повтор имеет шанс помочь —
# тот самый эффект входа, ради которого повтор и заведён.
#
# ОСТАЛЬНЫЕ КОДЫ НЕ ПОВТОРЯЕМ И НЕ ЗОВЁМ «ответа нет». Протухший, чужой или
# самоподписанный сертификат даёт rc=60 (замер 12.09: expired.badssl.com,
# self-signed.badssl.com, wrong.host.badssl.com — все три). Это ИЗМЕРЕННЫЙ
# отказ периметра, а не потерянный запрос: см. проверку 5b в шапке — без
# site-блока Caddy не выпускает сертификат, и клиент видит обрыв TLS вместо
# редиректа, ради этого проверка и написана. Отказ детерминирован: три попытки
# дадут тот же rc, потратив 6 c пауз, а результат уехал бы в колонку «не
# измеряли» — то есть регресс спрятался бы ровно там, где его надо показать.
NETWORK_RC=" 6 7 28 35 52 56 "
# Ответ, пришедший с «неправильным» кодом, для curl — успех (rc=0), повтора не
# будет; проверка отработает ровно так же, как до правки.
curl_try() {
local attempt=1 rc out
while :; do
out=$(curl "$@" 2>/dev/null)
rc=$?
{ [ "$rc" -eq 0 ] || [ "$attempt" -ge "$SMOKE_ATTEMPTS" ]; } && break
# Код вне сетевого класса — повторять нечего, отдаём rc наверх (см. NETWORK_RC).
case "$NETWORK_RC" in *" $rc "*) ;; *) break ;; esac
# Пауза растёт: 2 c, затем 4 c — ниже 2 c смысла нет (см. замер выше).
echo " RETRY: ответа нет (curl rc=$rc), попытка $((attempt + 1)) из $SMOKE_ATTEMPTS через $((attempt * 2)) c: $*" >&2
sleep "$((attempt * 2))"
attempt=$((attempt + 1))
done
[ "$SMOKE_PAUSE" = "0" ] || sleep "$SMOKE_PAUSE"
printf '%s' "$out"
return "$rc"
}
# curl_failed: красная строка, когда ответа не удалось получить у самого curl.
#
# За одним «rc != 0» прячутся ДВА разных диагноза, и путать их нельзя:
# - сетевой класс → «ответа нет»: цел ли периметр, мы не знаем, проверка НЕ
# измерилась (плюс счётчик noresp и отдельная строка в итоге);
# - всё остальное, прежде всего cert-класс (rc=60) → обычный FAIL: отказ
# ИЗМЕРЕН, это регресс периметра, искать надо конфиг, а не флап входа.
#
# rc печатается в ОБЕИХ ветках: строка RETRY при SMOKE_ATTEMPTS=1 не выводится
# вовсе, и без rc читатель красного лога не отличит «домена нет» (6) от
# «сертификат протух» (60) — а это диагнозы из разных отделов.
curl_failed() {
local desc="$1" url="$2" rc="$3" reason
fail=1
case "$NETWORK_RC" in
*" $rc "*)
echo "FAIL (ответа нет): $desc ($url — вход не отдал ответ после $SMOKE_ATTEMPTS попыток, curl rc=$rc;" \
"это НЕ измеренный код ответа, периметр этой проверкой НЕ проверен — повторите URL вручную)"
noresp=$((noresp + 1))
return
;;
esac
case "$rc" in
60|51|83) reason="TLS-сертификат отвергнут" ;;
58|77) reason="проблема с клиентским сертификатом/CA" ;;
*) reason="curl не выполнил запрос" ;;
esac
echo "FAIL: $desc ($url -> $reason (curl rc=$rc); ответ измерен как отказ, это не потерянный" \
"запрос — повтора не было, код вне сетевого класса «ответа нет»)"
}
check() {
local desc="$1" url="$2" expected="$3"
local code rc
code=$(curl_try -s -o /dev/null -w '%{http_code}' --max-time 15 "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
curl_failed "$desc" "$url" "$rc"
elif [ "$code" = "$expected" ]; then
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)"
@ -176,122 +51,15 @@ check() {
fi
}
# check_any: PASS, если код ответа — ЛЮБОЙ из перечисленных.
#
# Нужен там, где проверяемое свойство — «этого адреса наружу нет», а каким
# именно отказом это выражено, зависит от того, какой рубеж ответил первым.
# На gendsgn.ru таких рубежа два: пилотный basic_auth (401) стоит выше
# allowlist'а site-блока (404), и какой сработает — вопрос порядка директив, а
# не безопасности. Фиксировать один конкретный код значило бы ронять смоук при
# снятии пилотного гейта, то есть при изменении, которое к предмету проверки
# отношения не имеет.
check_any() {
local desc="$1" url="$2"
shift 2
local code rc expected="$*"
code=$(curl_try -s -o /dev/null -w '%{http_code}' --max-time 15 "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
curl_failed "$desc" "$url" "$rc"
return
fi
for want in "$@"; do
if [ "$code" = "$want" ]; then
echo "PASS: $desc ($url -> $code)"
return
fi
done
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected one of: $expected)"
fail=1
}
check_post() {
local desc="$1" url="$2" body="$3" expected="$4" reject="${5:-}"
local out code head_and_body rc
# -i: заголовки попадают в вывод вместе с телом — по ним отличаем ответ
# приложения от заглушки Caddy (см. $reject у вызова payments/notify).
out=$(curl_try -s -i -w '\n%{http_code}' --max-time 15 \
-X POST -H 'Content-Type: application/json' -d "$body" "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
curl_failed "$desc" "$url" "$rc"
return
fi
code=${out##*$'\n'}
head_and_body=${out%$'\n'*}
if [ "$code" != "$expected" ]; then
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
fail=1
elif [ -n "$reject" ] && printf '%s' "$head_and_body" | grep -qEi "$reject"; then
echo "FAIL: $desc ($url -> $code, но ответ от заглушки окна деплоя, а не от приложения)"
fail=1
else
local desc="$1" url="$2" body="$3" expected="$4"
local code
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \
-X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null)
if [ "$code" = "$expected" ]; then
echo "PASS: $desc ($url -> $code)"
fi
}
# check_redirect_location: сверяет буквальный заголовок Location редиректа,
# а не только код ответа — нужен там, где регресс не меняет код (301
# остаётся 301), а меняет ТОЛЬКО цель (баг 10.09.2026 у @meraShortSlash в
# apps.caddy: query терялась молча, код ответа был зелёным всю дорогу).
# curl без -L (редирект не проходим), -D - дампит заголовки в stdout.
#
# ФОРМА LOCATION НЕ ФИКСИРУЕТСЯ. Caddy отдаёт цель редиректа так, как её
# собрала директива: для `redir * {uri}` это относительный путь
# (`/articles?utm_source=vc`), а для веток с явным хостом — абсолютный URL
# (`https://meraocenka.ru/articles?utm_source=vc`). Обе формы валидны по
# RFC 7231, браузер разрешает относительную сам, и переписывание одной ветки
# конфига на другую — не регресс, ради которого стоит ронять смоук. Поэтому
# сверяется ХВОСТ: путь с query, с какого бы префикса Location ни начинался.
# Ровно это и есть предмет проверки — что query дожила до цели.
check_redirect_location() {
local desc="$1" url="$2" expected_suffix="$3"
local location headers rc
# Заголовки сначала забираем целиком, и только потом разбираем: при обрыве
# соединения grep по пустому выводу дал бы «Location: <none>» — красную
# строку про подмену цели редиректа там, где ответа не было вовсе.
headers=$(curl_try -s -o /dev/null -D - --max-time 15 "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
curl_failed "$desc" "$url" "$rc"
return
fi
location=$(printf '%s' "$headers" \
| grep -i '^location:' | tr -d '\r' | sed 's/^[Ll]ocation: *//')
case "$location" in
"$expected_suffix"|*"$expected_suffix")
echo "PASS: $desc ($url -> Location: $location)"
;;
*)
echo "FAIL: $desc ($url -> got Location '${location:-<none>}', expected ending with '$expected_suffix')"
fail=1
;;
esac
}
# check_content_type: 200 И заявленный Content-Type начинается с ожидаемого.
#
# Заведён под og:image. Для картинки превью «200» само по себе ничего не
# доказывает: перепутанный rewrite приводит к 200 с HTML-страницей фронта, и
# обходчик мессенджера её молча отбросит — ссылка развернётся без картинки, а
# смоук останется зелёным. Проверять надо ровно то, ради чего адрес открыт.
check_content_type() {
local desc="$1" url="$2" expected_prefix="$3"
local out code ctype rc
out=$(curl_try -s -o /dev/null -D - -w '%{http_code}' --max-time 15 "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
curl_failed "$desc" "$url" "$rc"
return
fi
code=${out##*$'
'}
ctype=$(printf '%s' "$out" | grep -i '^content-type:' | tr -d '
' | sed 's/^[Cc]ontent-[Tt]ype: *//' | head -1)
if [ "$code" = "200" ] && [ "${ctype#"$expected_prefix"}" != "$ctype" ]; then
echo "PASS: $desc ($url -> $code, $ctype)"
else
echo "FAIL: $desc ($url -> got '${code:-<no response>}' / '${ctype:-<no content-type>}', expected 200 + $expected_prefix)"
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
fail=1
fi
}
@ -322,27 +90,6 @@ check "meraocenka.ru/privacy — public 200" "$BASE_MERA/privacy" 200
# лэндинга. Отвалится handle — кнопки «Проверить» станут ссылками в 404.
check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200
# 1d2. Страница «МЕРА для бизнеса» — короткий адрес, на который ведут пункт
# шапки и подвала. До 31.08.2026 они вели в закрытый контур и приводили
# человека на форму входа; отвалится handle — вернётся 404 вместо неё.
check "meraocenka.ru/business — public 200" "$BASE_MERA/business" 200
# 1d3. robots.txt / sitemap.xml — первое, что запрашивает поисковый краулер
# при индексации. robots.txt отдаётся ОТДЕЛЬНЫМ handle (Next-конвенция:
# файл в корне app/, а не под mera-public/), sitemap.xml — той же строкой
# что и остальные @meraPages (см. комментарии в apps.caddy). Отвалится
# любой из handle — сайт не проиндексируется вовсе.
check "meraocenka.ru/robots.txt — public 200" "$BASE_MERA/robots.txt" 200
check "meraocenka.ru/sitemap.xml — public 200" "$BASE_MERA/sitemap.xml" 200
# 1d4. og:image — картинка превью ссылок. Её просят обходчики мессенджеров и
# соцсетей, анонимно и без Referer; отвалится handle — ссылки на сайт
# начнут разворачиваться без картинки, и заметить это по логам
# приложения нельзя (запрос туда просто не доходит). Content-Type
# проверяется вместе с кодом: 200 с HTML вместо PNG выглядел бы так же.
check_content_type "meraocenka.ru/og-mera.png — public 200 + image/png" "$BASE_MERA/og-mera.png" "image/png"
check_content_type "meraocenka.ru/logo-mera.png — public 200 + image/png" "$BASE_MERA/logo-mera.png" "image/png"
# 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один
# канонический адрес, а старые ссылки и закладки продолжают работать.
# ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер
@ -351,16 +98,6 @@ check_content_type "meraocenka.ru/logo-mera.png — public 200 + image/png" "$BA
check "meraocenka.ru длинный корень — 301 на /" "$BASE_MERA/trade-in/mera-public" 301
check "meraocenka.ru длинная оферта — 301 на /oferta" "$BASE_MERA/trade-in/mera-public/oferta" 301
# 1f. Регресс-тест на баг 10.09.2026 (замер на живом проде): редирект со
# слэшем терял query-строку — статьи публикуются с UTM-метками, а
# мессенджеры и автолинкификаторы дописывают слэш к скопированной ссылке,
# то есть именно этот трафик терял атрибуцию молча (код ответа
# оставался 301, поэтому предыдущая версия смоука проблему не ловила).
# Проверяем сам факт 301 (уже покрыт проверками check выше по коду) И
# буквальный Location — только вторая половина ловит регресс.
check_redirect_location "meraocenka.ru/articles/?utm_source=vc — query сохраняется на редиректе" \
"$BASE_MERA/articles/?utm_source=vc" "/articles?utm_source=vc"
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
@ -377,64 +114,6 @@ check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API)
# Ловит расширение матчера обратно до `/trade-in/_next/*`.
check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404
# 2c-bis. Внутри разрешённой статики закрыто поддерево ЧУЖИХ маршрутов (#3324):
# App Router кладёт код постранично в chunks/app/<маршрут>/, и до этой
# правки аноним скачивал с публичного домена бандлы /admin, /team,
# /scrapers — с именами внутренних ручек внутри.
#
# КОД 404 ЗДЕСЬ НЕДОСТАТОЧЕН: несуществующий чанк Next тоже отдаёт 404,
# поэтому проверка не отличила бы «Caddy отсёк» от «Caddy проксировал, а
# файла нет» — и осталась бы зелёной после отката матчера. Отличаем по
# ТЕЛУ: `respond 404` Caddy пустой (0 байт), 404 от Next — непустой
# (замер на проде 02.09.2026: 9 байт).
check_caddy_404() {
local desc="$1" url="$2"
local out code size rc
out=$(curl_try -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 15 "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
curl_failed "$desc" "$url" "$rc"
return
fi
code=${out%% *}
size=${out##* }
if [ "$code" = "404" ] && [ "$size" = "0" ]; then
echo "PASS: $desc ($url -> 404, пустое тело = отсёк Caddy)"
else
echo "FAIL: $desc ($url -> got '${out:-<no response>}', expected '404 0')"
fail=1
fi
}
check_caddy_404 "meraocenka.ru — чанки /admin не раздаются" \
"$BASE_MERA/trade-in/_next/static/chunks/app/admin/page-smoke.js"
check_caddy_404 "meraocenka.ru — чанки /admin/analytics не раздаются" \
"$BASE_MERA/trade-in/_next/static/chunks/app/admin/analytics/page-smoke.js"
check_caddy_404 "meraocenka.ru — чанки /team не раздаются" \
"$BASE_MERA/trade-in/_next/static/chunks/app/team/page-smoke.js"
# Обратная сторона того же матчера: статика САМОГО лэндинга обязана остаться
# живой. Без этой строки «починка» вида «404 на весь chunks/app/» выглядела бы
# успешной, а публичный сайт молча остался бы без JS.
layout_html=$(curl_try -s --max-time 15 "$BASE_MERA/")
layout_rc=$?
if [ "$layout_rc" -ne 0 ]; then
# Обрыв на загрузке лэндинга раньше попадал в ветку «не нашёл чанк» и читался
# как «сам лэндинг сломан» — диагноз, которого никто не измерял.
curl_failed "HTML лэндинга (ищем в нём layout-чанк)" "$BASE_MERA/" "$layout_rc"
else
layout_chunk=$(printf '%s' "$layout_html" \
| grep -o '/trade-in/_next/static/chunks/app/layout-[^"]*\.js' | head -1)
if [ -z "$layout_chunk" ]; then
# Пустая строка вместо пути дала бы запрос к корню и зелёную проверку ни о
# чём — поэтому это FAIL, а не «пропустим».
echo "FAIL: не нашёл layout-чанк в HTML лэндинга (сам лэндинг сломан?)"
fail=1
else
check "meraocenka.ru — корневой layout-чанк лэндинга жив (200)" "$BASE_MERA$layout_chunk" 200
fi
fi
# 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/*
# доступны анонимно на обоих доменах; ВЕСЬ /api/v1/* на публичном домене
# по-прежнему 404.
@ -472,43 +151,8 @@ check_post "gendsgn.ru public suggest — 200 anonymous" "$BASE_MAIN/trade-in/ap
# Гейт данных переехал на API — там и проверяем, иначе тест зелёный при
# открытом наружу бэкенде.
check "trade-in /api/v1/me — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/me" 401
# Путь именно `/api/v1/trade-in/history`: роутер trade_in подключён в main.py с
# префиксом `/api/v1/trade-in`, а сама ручка объявлена как `@router.get("/history")`.
# До 05.09.2026 здесь стоял несуществующий `/api/v1/history` — проверка была
# зелёной только потому, что guard отвечал 401 на ЛЮБОЙ путь; после #3352
# несуществующий путь даёт 404, и проверка честно покраснела.
check "trade-in /api/v1/trade-in/history — 401 anonymous (чужие оценки)" "$BASE_MAIN/trade-in/api/v1/trade-in/history" 401
# #3360: admin-префикс анониму отвечает 404, а НЕ 401. Периметр здесь не срезан
# (Caddy-блок /trade-in/api/* стоит выше basic_auth-снипета, и срезать нельзя —
# admin-UI зовёт эти же пути из браузера), поэтому существование ручки прячет сам
# guard. Пара путей взята намеренно разная: `/proxies` — РЕАЛЬНЫЙ роут
# (app/api/v1/admin.py), `/users` — несуществующий. Одинаковый код на обоих и
# означает, что перебором имён admin-API снаружи ничего не узнать; 401 на первом
# = регресс маскировки (app/core/rbac.py::_unauthenticated).
check "trade-in /api/v1/admin/proxies — 404 anonymous (существующая ручка скрыта)" \
"$BASE_MAIN/trade-in/api/v1/admin/proxies" 404
check "trade-in /api/v1/admin/users — 404 anonymous (несуществующая — тот же ответ)" \
"$BASE_MAIN/trade-in/api/v1/admin/users" 404
# 3b. sitemap.xml публичного лэндинга МЕРЫ не должен просачиваться через
# закрытый B2B-контур gendsgn.ru — корень gendsgn.ru принадлежит Site
# Finder, у него нет своего route на /sitemap.xml под МЕРУ, allowlist-by-
# default этого site-блока (как и у meraocenka.ru) отдаёт 404 на всё
# незаявленное.
#
# ДВА ДОПУСТИМЫХ КОДА, А НЕ ОДИН. Первая редакция этой проверки ждала 404 и
# упала на первом же прогоне против прода: корень gendsgn.ru закрыт
# пилотным basic_auth, и гейт отвечает 401 РАНЬШЕ, чем запрос доходит до
# allowlist'а. Оба ответа означают ровно проверяемое — карты сайта МЕРЫ на
# B2B-домене нет; какой именно рубеж сработал первым, к предмету проверки
# отношения не имеет, а вот снятие пилотного гейта (оно запланировано)
# переключит 401 на 404 и уронило бы жёсткое ожидание на пустом месте.
# Красная строка здесь — только 200: он означал бы, что sitemap реально
# отдаётся из закрытого контура.
#
# Если у Site Finder когда-нибудь появится СВОЙ /sitemap.xml, ожидание
# придётся пересмотреть на «200 с другим содержимым».
check_any "gendsgn.ru/sitemap.xml — must NOT serve MERA sitemap (закрытый контур)" "$BASE_MAIN/sitemap.xml" 401 404
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/* перехватывается
@ -530,57 +174,28 @@ check "www.meraocenka.ru — 301 to canonical" "https://www.meraocenka.ru/" 301
check "www.merahome.ru — 301 to canonical" "https://www.merahome.ru/" 301
check "www.meraotsenka.ru — 301 to canonical" "https://www.meraotsenka.ru/" 301
# 6. Платёжный периметр. Заведён в PR-D2 как канарейка «ожидания обновляют
# вместе с тем PR, который путь открывает». Канарейка отработала: PR-D3
# (#3231, роутер) смержен 29.08 и внёс notify в `_PUBLIC_PATHS`, ожидание
# здесь обновлено тем же днём — но уже ПОСЛЕ красного деплоя, а не вместе с
# ним. Кто будет делать PR-D4 — правьте ожидания в одном PR с ним.
#
# Состояние на 29.08, чего ждём сейчас:
# 6. Платёжный периметр (PR-D2) — готовит почву под PR-D3 (роутер) и PR-D4
# (Caddy), но САМ НИЧЕГО НЕ ОТКРЫВАЕТ. Ожидаем закрытое состояние С ОБЕИХ
# СТОРОН прямо сейчас:
# - meraocenka.ru вообще не проксирует /trade-in/api/* (allowlist-by-default,
# см. проверку 2) — 404 от Caddy, до бэкенда не доходит. Это ВСЁ ЕЩЁ
# канарейка: PR-D4 не смержен, 404 должен держаться;
# - gendsgn.ru: `checkout` по-прежнему требует X-Authenticated-User → 401
# анониму (он не публичный и не станет им);
# - gendsgn.ru: `notify` — публичный по построению (вебхук банка), см.
# комментарий у самой проверки ниже.
# см. проверку 2) — 404 от Caddy, до бэкенда не доходит;
# - gendsgn.ru проксирует /trade-in/api/* в tradein-backend, но rbac_guard
# (`_PUBLIC_PATHS` в app/core/rbac.py — ЭТОТ PR её не трогает) не знает
# платёжные пути и требует X-Authenticated-User → 401 анониму.
# Если один из этих чек-ов вдруг перестанет быть 404/401 РАНЬШЕ мержа
# PR-D3/PR-D4 — это и есть преждевременная утечка периметра, которую ловит
# этот смоук (канарейка: осознанно станет красной, когда PR-D3/PR-D4 явно
# откроют эти пути — тогда ожидания здесь надо обновить вместе с ними).
check "meraocenka.ru payments/notify — must 404 (Caddy не проксирует, PR-D4)" \
"$BASE_MERA/trade-in/api/v1/trade-in/payments/notify" 404
check "meraocenka.ru payments/checkout — must 404 (Caddy не проксирует, PR-D4)" \
"$BASE_MERA/trade-in/api/v1/trade-in/payments/checkout" 404
# notify — ЕДИНСТВЕННЫЙ платёжный путь, который PR-D3 (#3231) открыл анониму
# осознанно: это вебхук банка, он обязан быть достижим без наших заголовков, и
# потому внесён в `_PUBLIC_PATHS`. Канарейка выше отработала как задумано — 401
# здесь стал бы теперь признаком ПОЛОМКИ (банк получил бы отказ), а не защиты.
#
# Проверяем POST и ждём 503: это строже прежнего 401 — утверждает сразу, что
# маршрут существует (не 404 — старый образ) И что приём платежей выключен
# (`payments_enabled=False`). 200 здесь означал бы, что флаг включили, не тронув
# этот смоук. GET → 405 закрепляет, что путь принимает только POST.
#
# С #3274 голый код 503 больше не доказывает ничего: Caddy сам отдаёт 503 на
# окне деплоя (`handle_errors` -> caddy/deploy-window.caddy.snippet), и тогда
# запрос до приложения не дошёл вовсе. Отличаем по признакам заглушки —
# заголовок `Retry-After: 30` и `"error":"service_unavailable"` в теле; у
# приложения тело `{"detail":"payments are disabled"}` и никакого Retry-After.
# Совпал код, но пришла заглушка → FAIL, а не молчаливый PASS.
check_post "trade-in payments/notify — 503 anonymous (маршрут есть, приём выключен)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" '{}' 503 \
'service_unavailable|^retry-after: *30'
check "trade-in payments/notify — 405 на GET (только POST)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 405
check "trade-in payments/checkout — 401 anonymous (не публичный по построению)" \
check "trade-in payments/notify — 401 anonymous (rbac закрыт до PR-D3)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 401
check "trade-in payments/checkout — 401 anonymous (rbac закрыт до PR-D3)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/checkout" 401
echo "========================================"
if [ "$noresp" -gt 0 ]; then
# Отдельная строка в итоге, а не только у самой проверки: читатель красного
# лога должен сразу видеть, что часть проверок НЕ ИЗМЕРИЛАСЬ, и не искать
# регресс периметра там, где ответа просто не было.
echo "БЕЗ ОТВЕТА: $noresp проверок не получили ответа даже после $SMOKE_ATTEMPTS попыток."
echo " Это не измеренный код ответа. Повторите эти URL вручную (и учтите, что"
echo " вход отбивает частые серии запросов с одного адреса — см. шапку скрипта)."
fi
if [ "$fail" -eq 0 ]; then
echo "ALL CHECKS PASSED"
else

View file

@ -8,15 +8,6 @@ backend/*.egg-info/
backend/.pytest_cache/
backend/.ruff_cache/
backend/.mypy_cache/
# backend/uv.lock — ИГНОРИРУЕТСЯ НАМЕРЕННО, это не забытый файл.
# Мера — uv-workspace: единственный лок живёт в корне (tradein-mvp/uv.lock), и
# именно его берёт сборка (context: ./tradein-mvp, `COPY pyproject.toml uv.lock ./`
# + `uv sync --frozen`). `uv lock`, запущенный из backend/, создаёт ВТОРОЙ лок,
# который никто не читает и который тихо расходится с рабочим: на 2026-08-29
# в нём висели pillow 12.2.0, starlette 1.0.1, python-multipart 0.0.29,
# pydantic-settings 2.14.1 и weasyprint 68.1 — пять пакетов с открытыми
# advisory, которых в реальной сборке Меры нет вообще.
# Локи обновлять из корня: `cd tradein-mvp && uv lock`.
backend/uv.lock
# Frontend Node

View file

@ -1,4 +1,4 @@
"""Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный.
"""Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный, ровно две ручки.
ЗАЧЕМ ОТДЕЛЬНЫЙ ПРЕФИКС, А НЕ ОТКРЫТИЕ КУСКА /api/v1/*
-------------------------------------------------------
@ -26,29 +26,20 @@ API, нужно было выбрать одно из двух:
АНОНИМНОСТЬ
-----------
`rbac_guard` (app/core/rbac.py) требует `X-Authenticated-User` для любого
non-public пути. Все ручки перечислены в `_PUBLIC_PATHS` ТОЧНЫМИ строками
non-public пути. Обе ручки перечислены в `_PUBLIC_PATHS` ТОЧНЫМИ строками
не префиксом: множество там frozenset с проверкой `path in ...`, и
добавление префиксной ветки расширило бы механизм, которым пользуется весь
бэкенд, ради одной фичи. Поэтому и чтение по токену POST с постоянным
путём `/estimate/read`, а не `GET /estimate/{token}`: переменный сегмент
пути потребовал бы ровно такой префиксной ветки (плюс сам токен уехал бы в
access-лог Caddy, см. `PublicEstimateTokenInput`).
добавление префиксной ветки ради двух путей расширило бы механизм, которым
пользуется весь бэкенд, ради одной фичи.
ЧТО ЭТИ РУЧКИ ДЕЛАЮТ С ДАННЫМИ
------------------------------
`/suggest` и `/coverage` не пишут в БД ничего: первый прокси автокомплита,
второй один SELECT. Это по-прежнему так и меняться не должно.
`/estimate` единственная, которая ПИШЕТ строку с адресом физлица (issue
#2895), и поэтому устроена иначе: она закрыта флагом
`settings.public_estimate_enabled` (дефолт false 404) и требует явного
согласия 152-ФЗ в payload'е строго `True` — на уровне схемы, то есть 422
прилетает до входа в хендлер и до любого обращения к БД. Пути удаления
данных в бэкенде всё ещё нет; включать флаг на проде вместе с ним и с
правкой п.5.5 политики обработки ПДн.
`/estimate/read` возвращает по капабилити-токену ТОЛЬКО бесплатную часть
(`PublicEstimateResult`) цену и прогнозы продаёт платный контур.
ЧТО ЭТИ РУЧКИ НЕ ДЕЛАЮТ
-----------------------
Ни одна из них не пишет в БД строк с адресом пользователя: `/coverage`
чистое чтение (один SELECT), `/suggest` прокси автокомплита. Это не
случайность, а условие, при котором публичная форма может работать ДО того,
как появится контур согласия 152-ФЗ (issue #2895: сегодня адрес физлица
попадает в `trade_in_estimates` раньше любого согласия, а пути удаления
данных в бэкенде нет). Платный расчёт, который писать будет, открывается
отдельно и только вместе с этим контуром.
БЮДЖЕТЫ
-------
@ -67,29 +58,19 @@ access-лог Caddy, см. `PublicEstimateTokenInput`).
from __future__ import annotations
import asyncio
import hashlib
import logging
import secrets
from collections.abc import Callable
from datetime import datetime
from typing import Annotated, Literal
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api.v1.geocode import SuggestResponse, effective_region_code, suggest_addresses
from app.api.v1.trade_in import coverage_probe, estimate
from app.core.config import settings
from app.api.v1.geocode import SuggestResponse, suggest_addresses
from app.api.v1.trade_in import coverage_probe
from app.core.db import get_db
from app.core.public_request import install_address_log_redaction, public_request_scope
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.schemas.trade_in import (
CoverageProbeInput,
CoverageProbeResponse,
TradeInEstimateInput,
)
from app.schemas.trade_in import CoverageProbeInput, CoverageProbeResponse
logger = logging.getLogger(__name__)
@ -110,26 +91,10 @@ router = APIRouter()
# нажал ещё раз».
_SUGGEST_LIMIT = 20
_COVERAGE_LIMIT = 15
# Витрина — один SELECT по своей же маленькой таблице, внешних вызовов нет,
# поэтому бюджет шире соседних: он здесь против перебора-в-цикле, а не против
# денежных трат. Лэндинг дёргает ручку один раз на загрузку страницы.
_SHOWCASE_LIMIT = 60
# Расчёт — самый дорогой шаг публичного контура (геокодер + десяток SQL по
# листингам + запись строки). Бюджет намеренно ниже пробы покрытия: живой
# человек нажимает «рассчитать» единицы раз, а анонимная месячная квота
# (settings.anon_estimate_quota_limit) обходится сменой IP — минутное окно
# делает такой обход дорогим по времени.
_ESTIMATE_LIMIT = 5
# Чтение по токену дешевле расчёта (один SELECT по уникальному индексу), но
# ослаблять его до бесконечности нельзя: перебор токенов — это перебор.
_ESTIMATE_READ_LIMIT = 30
_WINDOW_S = 60.0
_suggest_limiter = SlidingWindowLimiter(limit=_SUGGEST_LIMIT, window_s=_WINDOW_S)
_coverage_limiter = SlidingWindowLimiter(limit=_COVERAGE_LIMIT, window_s=_WINDOW_S)
_showcase_limiter = SlidingWindowLimiter(limit=_SHOWCASE_LIMIT, window_s=_WINDOW_S)
_estimate_limiter = SlidingWindowLimiter(limit=_ESTIMATE_LIMIT, window_s=_WINDOW_S)
_estimate_read_limiter = SlidingWindowLimiter(limit=_ESTIMATE_READ_LIMIT, window_s=_WINDOW_S)
# ── Общий суточный потолок публичных подсказок ──────────────────────────────
#
@ -186,33 +151,6 @@ def _enforce(limiter: SlidingWindowLimiter, request: Request, what: str) -> None
limiter.record(ip)
def _budget(limiter: SlidingWindowLimiter, what: str) -> Callable[[Request], None]:
"""Per-IP бюджет КАК ЗАВИСИМОСТЬ, а не первой строкой тела.
Ровно та же поправка места, что уже сделана у
`_require_public_estimate_enabled` (см. его докстринг): FastAPI решает
зависимости РАНЬШЕ, чем валидирует тело, поэтому проверка в теле не
срабатывает на запросах, которые падают на разборе тела до неё просто не
доходит.
Для флага это стоило утечки схемы, для лимитера неограниченного потока
отказов: 12 запросов подряд с некорректным телом на `/coverage` дали
двенадцать ответов и ни одного 429 (замер 30.08.2026). Пока такой вход
ронял сериализацию 422 (чинится в app/core/http_errors.py), это был поток
500 с одного адреса; после починки поток 422, но всё так же мимо бюджета.
ЧЕГО ЭТА ЗАВИСИМОСТЬ НЕ ЛОВИТ: тело, которое не разбирается как JSON
вообще там `RequestValidationError` летит до решения зависимостей. Такой
запрос стоит один `json.loads` и остаётся под общим `RateLimitMiddleware`
(300/60с на IP); заводить ради него middleware поверх middleware смысла нет.
"""
def _dep(request: Request) -> None:
_enforce(limiter, request, what)
return _dep
class PublicSuggestInput(BaseModel):
"""Вход публичного автокомплита.
@ -222,9 +160,6 @@ class PublicSuggestInput(BaseModel):
q: str = Field(min_length=2, max_length=200)
limit: int = Field(default=8, ge=1, le=10)
city_hint: str | None = Field(default=None, max_length=100)
# #3051: явный регион с фронта (если он его когда-нибудь пришлёт) —
# приоритетнее вывода из city_hint, см. effective_region_code.
region_code: int | None = Field(default=None)
def _fold(text: str) -> str:
@ -262,12 +197,9 @@ def _query_with_city(query: str, city_hint: str | None) -> str:
return f"{city_hint}, {query}"
@router.post(
"/suggest",
response_model=SuggestResponse,
dependencies=[Depends(_budget(_suggest_limiter, "suggest"))],
)
@router.post("/suggest", response_model=SuggestResponse)
async def public_suggest(
request: Request,
payload: PublicSuggestInput,
db: Annotated[Session, Depends(get_db)],
) -> SuggestResponse:
@ -294,6 +226,8 @@ async def public_suggest(
десятка в публичном UI не показывается, а каждый лишний кандидат может
стоить внешнего вызова.
"""
_enforce(_suggest_limiter, request, "suggest")
# Суточный потолок — ПОСЛЕ per-IP: сначала отсекаем одиночного абузера его
# собственным лимитом, и только оставшееся считаем в общий бюджет.
daily_retry = _daily_suggest_limiter.retry_after(_GLOBAL_KEY)
@ -329,18 +263,14 @@ async def public_suggest(
limit=payload.limit,
db=db,
city_hint=payload.city_hint,
region_code=effective_region_code(payload.region_code, payload.city_hint),
)
finally:
_suggest_slots.release()
@router.post(
"/coverage",
response_model=CoverageProbeResponse,
dependencies=[Depends(_budget(_coverage_limiter, "coverage"))],
)
@router.post("/coverage", response_model=CoverageProbeResponse)
def public_coverage(
request: Request,
payload: CoverageProbeInput,
db: Annotated[Session, Depends(get_db)],
) -> CoverageProbeResponse:
@ -354,511 +284,5 @@ def public_coverage(
Ответ не содержит ни одной цены (см. `CoverageProbeResponse`) бесплатный
шаг доказывает наличие данных, цену продаёт платный.
"""
_enforce(_coverage_limiter, request, "coverage")
return coverage_probe(payload=payload, db=db)
class LandingStat(BaseModel):
"""Одна витринная величина лэндинга.
`sample_n` и `note` едут наружу вместе со значением намеренно: цифра без
размера выборки и без описания измеренного это ровно тот литерал, который
лежал во фронте до появления landing_stats. Пусть фронт решает, показывать
ли их мелким шрифтом, но получить число БЕЗ них он не может.
"""
value: float | str | None
sample_n: int | None
note: str | None
computed_at: datetime
_STATS_LIMIT = 30
_stats_limiter = SlidingWindowLimiter(limit=_STATS_LIMIT, window_s=_WINDOW_S)
# Все строки витрины — их единицы, LIMIT не нужен, но потолок пусть будет:
# таблица наполняется только ночной задачей, и если она когда-нибудь начнёт
# писать метрику на город, ручка не должна молча вырасти в мегабайты.
_STATS_SQL = text("""
SELECT metric, value_num, value_text, sample_n, note, computed_at
FROM landing_stats
ORDER BY metric
LIMIT 200
""")
@router.get(
"/stats",
response_model=dict[str, LandingStat],
dependencies=[Depends(_budget(_stats_limiter, "stats"))],
)
def public_stats(
db: Annotated[Session, Depends(get_db)],
) -> dict[str, LandingStat]:
"""Витринные метрики лэндинга — готовый ночной срез (issue: числа по проду).
GET, в отличие от соседей: здесь в запросе нет ни адреса, ни чего-либо
относящегося к посетителю, поэтому довод «URI попадает в access-лог» не
работает, а кэшируемость GET'а для страницы, которую открывают все, полезна.
Читает готовые строки, НЕ считает на лету: агрегаты по offer_price_history с
подзапросами на листинг секунды, а анонимная ручка, которая стоит секунду
CPU, это рычаг DoS. Считает их app/tasks/landing_stats.py раз в сутки.
Пустая таблица валидные `{}` и 200. Это штатное состояние сразу после
накатки миграции (задача ещё не отработала) и оно же состояние «данных для
метрики нет»: задача не пишет строку, когда мерить нечего. Фронт обязан это
пережить и не рисовать блок, а не получить 500 и сломанную страницу.
`value` числовое value_num, если оно есть; иначе value_text (для метрик,
у которых значение не число). Оба NULL отдаём null, а не выдуманный ноль.
"""
rows = db.execute(_STATS_SQL).fetchall()
return {
row.metric: LandingStat(
value=(float(row.value_num) if row.value_num is not None else row.value_text),
sample_n=row.sample_n,
note=row.note,
computed_at=row.computed_at,
)
for row in rows
}
class ShowcaseDeal(BaseModel):
"""Одна строка витрины «МЕРА сказала X — продали за Y».
`district` / `floor` / `total_floors` НУЛЛАБЕЛЬНЫ намеренно: этих величин в
ДКП-данных может не быть, и фронт обязан пережить null, а не получить
правдоподобную подстановку.
`street_name` / `street_scheme` УЛИЦА, А НЕ ДОМ. Номер дома есть у 2.7%
сделок (разбор в миграции 276), поэтому дома в витрине нет и не будет.
Оба поля НУЛЛАБЕЛЬНЫ, и null штатный случай: название сматчилось с OSM у
550 названий из 654 (92.3% сделок, замер 2026-08-29), остальным схемы нет и
фронт показывает район.
`street_scheme` уже спроецированные SVG-пути окна 840×840 м вокруг центра
улицы::
{"street": "улица Краснолесья", "w": 1000, "h": 1000,
"target": ["M…L…"], # подсвеченная улица
"roads": [{"c": "primary", "d": "M…L…"}], # фон, c = класс дороги
"water": ["M…L…"],
"labels": [{"t": "Чкалова", "x": 431.2, "y": 88.0}]}
Координат окна и констант проекции в схеме НЕТ намеренно: по ней нельзя
положить (lon, lat) в её систему координат, то есть нарисовать точку дома
невозможно даже случайно. Схема готовая, а не геометрия, потому что GeoJSON
того же окна 7-8 КБ на строку против 2.8-2.9 КБ схемы (замер 2026-08-29).
правдоподобную подстановку. Улицы и дома в модели нет вовсе номер дома
есть у 2.7% сделок (разбор в миграции 276).
`lat` / `lon` ЦЕНТРОИД УЛИЦЫ, не дом: 991 различная координата на 34 017
сделок выборки (34 сделки в одной точке) при тех же 2.7% известных домов
(замер 2026-08-29, миграция 280). Точка честна на масштабе района и улицы
и НЕ честна на масштабе дома то же самое написано в `note` строки,
которая едет рядом. Тоже нуллабельны: у части сделок координаты нет, и
такая строка остаётся на витрине без точки, а не выбрасывается.
"""
district: str | None
rooms: int
area_m2: float
floor: int | None
total_floors: int | None
deal_quarter: str
predicted_rub: int
fact_rub: int
err_pct: float
n_analogs: int
note: str
street_name: str | None
street_scheme: dict | None
lat: float | None = None
lon: float | None = None
class ShowcaseStats(BaseModel):
"""Итог прогона, который дал показанные строки. Подпись под витриной.
Без этих чисел «20 отличных строк» неотличимо от «столько и было»:
посетитель не может отличить выборку из работы оценщика от её лучшего
хвоста. `eligible` сколько строк прогон СОБРАЛ (данных хватило),
`written` сколько из них показано; `rejection_rule` по какому правилу
отобраны показанные, записанное ТЕМ прогоном, который их посчитал.
`eligible` минус `written` НЕ «столько не поместилось»: с 2026-09-12
витрина показывает полосу расхождения 5 %..+20 %, и в разницу входят
строки, отсеянные полосой. Что это именно отбор, а не вся сверка, говорит
`rejection_rule` поэтому счётчики и правило показываются вместе, одной
подписью, а не порознь.
"""
considered: int
priced: int
no_prediction: int
incomplete: int
eligible: int
written: int
with_district: int
rejection_rule: str
class ShowcaseResponse(BaseModel):
"""Витрина целиком: когда считали, что показываем и из чего это отобрано.
`stats` = None только до первого пересчёта тогда и `deals` пуст.
"""
computed_at: str | None
deals: list[ShowcaseDeal]
stats: ShowcaseStats | None = None
# Последний прогон — единственная точка отсчёта: и `computed_at`, и счётчики, и
# набор строк берутся ИЗ НЕГО. Брать строки по своему max(computed_at) значило
# бы, что прогон, не давший ни одной строки, показывает вчерашние строки под
# сегодняшними счётчиками.
_SHOWCASE_RUN_SQL = text(
"""
SELECT computed_at, considered, priced, no_prediction, incomplete,
eligible, written, with_district, rejection_rule
FROM landing_showcase_runs
ORDER BY computed_at DESC, id DESC
LIMIT 1
"""
)
_SHOWCASE_SQL = text(
"""
SELECT district, rooms, area_m2, floor, total_floors, deal_quarter,
predicted_rub, fact_rub, err_pct, n_analogs, note,
lat, lon, street_name, street_scheme
FROM landing_showcase_deals
WHERE computed_at = CAST(:computed_at AS timestamptz)
ORDER BY id
"""
)
@router.get(
"/showcase",
response_model=ShowcaseResponse,
dependencies=[Depends(_budget(_showcase_limiter, "showcase"))],
)
def public_showcase(
db: Annotated[Session, Depends(get_db)],
) -> ShowcaseResponse:
"""Витрина лэндинга: реальные ДКП-сделки против прогноза МЕРЫ.
Читает готовый батч из `landing_showcase_deals` (пересчёт
`app/tasks/landing_showcase_deals.py`), а не считает прогноз на лету:
один прогноз это несколько пространственных SELECT'ов, двадцать штук на
анонимный GET были бы рычагом для DoS.
Пустой список штатный ответ, а не ошибка: до первого пересчёта показывать
нечего, и это ровно то, что фронт должен увидеть вместо выдуманных строк.
Вместе со строками едет `stats` сколько сделок рассмотрено, сколько
строк прогон собрал и по какому правилу из них отобраны показанные. Числа
считает пересчёт; без них витрина не имеет права подписаться честно.
"""
run = db.execute(_SHOWCASE_RUN_SQL).mappings().first()
if run is None:
return ShowcaseResponse(computed_at=None, deals=[], stats=None)
rows = db.execute(_SHOWCASE_SQL, {"computed_at": run["computed_at"]}).mappings().all()
return ShowcaseResponse(
computed_at=run["computed_at"].isoformat(),
stats=ShowcaseStats(
considered=int(run["considered"]),
priced=int(run["priced"]),
no_prediction=int(run["no_prediction"]),
incomplete=int(run["incomplete"]),
eligible=int(run["eligible"]),
written=int(run["written"]),
with_district=int(run["with_district"]),
rejection_rule=run["rejection_rule"],
),
deals=[
ShowcaseDeal(
district=r["district"],
rooms=int(r["rooms"]),
area_m2=float(r["area_m2"]),
floor=(int(r["floor"]) if r["floor"] is not None else None),
total_floors=(int(r["total_floors"]) if r["total_floors"] is not None else None),
deal_quarter=r["deal_quarter"],
predicted_rub=int(r["predicted_rub"]),
fact_rub=int(r["fact_rub"]),
err_pct=float(r["err_pct"]),
n_analogs=int(r["n_analogs"]),
note=r["note"],
street_name=r["street_name"],
street_scheme=r["street_scheme"],
# Порядок не менять: lat — широта (~56.8 в ЕКБ), lon — долгота
# (~60.6). Перепутанные местами координаты остаются валидными
# float и уедут на карту точкой в другой стране.
lat=(float(r["lat"]) if r["lat"] is not None else None),
lon=(float(r["lon"]) if r["lon"] is not None else None),
)
for r in rows
],
)
# ── Анонимный расчёт и повторное чтение его бесплатной части ────────────────
#
# ФЛАГ. Обе ручки ниже мертвы, пока `settings.public_estimate_enabled` не
# включён в .env.runtime: это первая пара публичных ручек, которая ПИШЕТ в
# `trade_in_estimates` адрес физлица, а такое включение — продуктовое решение
# владельца (нужна правка п.5.5 политики обработки ПДн, она сегодня анонимный
# расчёт не описывает), а не следствие мержа кода. Тот же приём, что у
# `payments_enabled`. Отвечаем 404, а не 403: выключенная ручка не должна
# подтверждать, что она существует.
#
# ОБЩИЙ СУТОЧНЫЙ ПОТОЛОК. Per-IP окно ограничивает одного клиента, но не сумму:
# 5/мин с адреса — это 7200 расчётов в сутки, каждый из которых дёргает
# геокодер и пишет строку. Потолок ниже — про защиту закрытого контура и
# квоты геокодера (тот же довод, что у `_DAILY_SUGGEST_BUDGET`), исчерпание —
# сигнал абуза, не штатный режим.
_DAILY_ESTIMATE_BUDGET = 300
_daily_estimate_limiter = SlidingWindowLimiter(limit=_DAILY_ESTIMATE_BUDGET, window_s=86_400.0)
_ESTIMATE_GLOBAL_KEY = "public-estimate"
# Срок жизни капабилити-ссылки. Ссылка — единственный способ анонима вернуться
# к своему расчёту (аккаунта у него нет), поэтому недели мало не будет для
# «оплатил, закрыл вкладку, вернулся вечером», а бесконечной она быть не может:
# это ссылка на данные о конкретной квартире конкретного человека.
_TOKEN_TTL = "7 days"
def _require_public_estimate_enabled() -> None:
"""404, пока публичный расчёт не включён владельцем явно.
ВЕШАЕТСЯ ЧЕРЕЗ `dependencies=[Depends(...)]` НА ДЕКОРАТОР, А НЕ ВЫЗЫВАЕТСЯ
ПЕРВОЙ СТРОКОЙ ТЕЛА. Разница не косметическая: FastAPI решает зависимости
РАНЬШЕ, чем валидирует тело запроса. Пока проверка стояла в теле, до неё
просто не доходило невалидное тело отбивалось 422 ещё на разборе, и
выключенная ручка отвечала так:
POST /api/public/mera/estimate {} 422 + перечень полей
POST /api/public/mera/estimate {валидное} 404
POST /api/public/mera/nosuchthing 401 (rbac)
То есть 422 подтверждал существование ручки (несуществующий путь даёт 401)
и заодно выдавал её схему: address, area_m2, rooms, consent. Замер на проде
29.08.2026, ровно то, что этот докстринг обещал не делать.
Как зависимость проверка срабатывает до разбора тела, и выключенная ручка
неотличима от отсутствующей при ЛЮБОМ входе.
"""
if not settings.public_estimate_enabled:
raise HTTPException(status_code=404, detail="Not Found")
def _token_hash(token: str) -> str:
"""sha256(hex) — в БД лежит только это, сам токен не хранится нигде.
Без соли намеренно: вход `secrets.token_urlsafe(32)` (256 бит), перебор
по словарю невозможен, а соль сделала бы невозможным поиск по равенству.
"""
return hashlib.sha256(token.encode()).hexdigest()
class PublicEstimateInput(TradeInEstimateInput):
"""Вход анонимного расчёта: тот же payload, что у закрытого контура, но
согласие 152-ФЗ ОБЯЗАТЕЛЬНОЕ и строго True.
В базовой схеме `consent: bool | None = None`: сделать его обязательным там
нельзя B2B-пилоты согласия в UI не дают, у них договор, и их фронт поля
не шлёт. Здесь же анонимный посетитель единственный источник согласия,
поэтому `Literal[True]`: без него Pydantic отвечает 422 ДО входа в
хендлер, то есть до первого обращения к БД. Это не дубль гейта в
`estimate_quality` (тот ловит любых вызывающих), а его сдвиг на самую
раннюю возможную границу «согласие фиксируется до первого INSERT»
перестаёт зависеть от порядка строк внутри эстиматора.
"""
consent: Literal[True]
class PublicEstimateTokenInput(BaseModel):
"""Токен едет ТЕЛОМ, а ручка чтения — POST, а не GET /{token}.
Причина ровно та же, по которой POST'ом сделан `/suggest`: access-лог Caddy
на публичном домене пишет URI целиком, поэтому капабилити-ссылка в пути
легла бы в файл рядом с IP посетителя и любой, у кого есть доступ к
логам (или их бэкапу), открыл бы чужой расчёт. Токен это пароль; пароли
в URL не кладут.
"""
token: str = Field(min_length=16, max_length=128)
class PublicEstimateResult(BaseModel):
"""БЕСПЛАТНАЯ часть расчёта — ровно то, что можно показать до оплаты.
Здесь СОЗНАТЕЛЬНО нет ни одного поля из `AggregatedEstimate` с ценой,
прогнозом или списком аналогов (`median_price_rub`, `range_*`,
`expected_sold_*`, `analogs`, `actual_deals`, `market_percentile`,
`est_days_on_market`, `cian_valuation`, `avito_imv`, `dkp_corridor`).
Модель отдельная, а не `AggregatedEstimate` с `exclude`: список исключений
надо помнить и дополнять при каждом новом поле эстиматора, а отдельная
модель молчит по умолчанию новое платное поле не утечёт само.
`coverage` тот же ответ, что у бесплатной пробы `/coverage` (в нём цен
нет по построению, см. `CoverageProbeResponse`): число похожих квартир,
возраст объявлений, вердикт покрытия. null координаты дома не
разрезолвились, честнее отдать «неизвестно», чем правдоподобное число.
"""
token: str
token_expires_at: datetime
n_analogs: int
coverage: CoverageProbeResponse | None = None
def _coverage_for(
db: Session, lat: float | None, lon: float | None, rooms: int, area_m2: float
) -> CoverageProbeResponse | None:
"""Вердикт покрытия для уже посчитанной оценки — через ту же `coverage_probe`.
Своей копии порогов здесь нет намеренно: разъедься она с бесплатной пробой,
один и тот же адрес получил бы «есть данные» на одном экране и «мало» на
соседнем.
"""
if lat is None or lon is None:
return None
return coverage_probe(
payload=CoverageProbeInput(lat=lat, lon=lon, rooms=rooms, area_m2=area_m2),
db=db,
)
@router.post(
"/estimate",
response_model=PublicEstimateResult,
# Порядок несущий: флаг ПЕРВЫМ. Лимитер впереди него отвечал бы 429 на
# выключенной ручке, а несуществующий путь даёт 401 — то есть 429 снова
# подтверждал бы существование ручки, ровно то, что чинил флаг-гейт.
dependencies=[
Depends(_require_public_estimate_enabled),
Depends(_budget(_estimate_limiter, "estimate")),
],
)
async def public_estimate(
request: Request,
response: Response,
payload: PublicEstimateInput,
db: Annotated[Session, Depends(get_db)],
) -> PublicEstimateResult:
"""Анонимный расчёт: считает полную оценку, отдаёт только бесплатную часть.
Делегирует в `app.api.v1.trade_in.estimate` ту же функцию, что обслуживает
закрытый контур, а не копию её тела. Оттуда же бесплатно достаётся всё
анти-абузное хозяйство: анонимная квота на связку (подписанная cookie + IP)
с лимитом `settings.anon_estimate_quota_limit`, семафор одновременности,
503 вместо непрозрачного 502 при сбое и consent-гейт (`require_consent`
включается ровно потому, что мы зовём её без `X-Authenticated-User`).
Полный расчёт при этом СОХРАНЯЕТСЯ в `trade_in_estimates` целиком платный
контур (сосед, `/api/v1/trade-in/r/{token}`) открывает его после оплаты по
своему токену. Наружу здесь уезжает только `PublicEstimateResult`.
"""
daily_retry = _daily_estimate_limiter.retry_after(_ESTIMATE_GLOBAL_KEY)
if daily_retry is not None:
logger.error(
"публичные расчёты исчерпали суточный бюджет (%d) — закрытый контур "
"защищён, но форма на лэндинге сейчас не считает",
_DAILY_ESTIMATE_BUDGET,
)
raise HTTPException(
status_code=429,
detail="Расчёт временно недоступен. Попробуйте позже.",
headers={"Retry-After": str(int(daily_retry) + 1)},
)
_daily_estimate_limiter.record(_ESTIMATE_GLOBAL_KEY)
# Пометка публичного запроса — как у `/suggest`: внутри цепочки геокодер
# печатает введённый адрес, а публичная форма обещает обратное.
with public_request_scope():
result = await estimate(
payload=payload,
request=request,
response=response,
db=db,
x_authenticated_user=None,
)
# Токен выдаём ПОСЛЕ успешного расчёта: ссылка на несуществующий результат
# не нужна никому, а строка в БД уже есть — estimate() её закоммитил.
token = secrets.token_urlsafe(32)
row = db.execute(
text(
"""
UPDATE trade_in_estimates
SET public_token_hash = :token_hash,
public_token_expires_at = NOW() + CAST(:ttl AS interval)
WHERE id = CAST(:id AS uuid)
RETURNING public_token_expires_at
"""
),
{"token_hash": _token_hash(token), "ttl": _TOKEN_TTL, "id": str(result.estimate_id)},
).fetchone()
db.commit()
if row is None: # pragma: no cover — оценка только что записана этой же транзакцией
raise HTTPException(status_code=503, detail="estimate temporarily unavailable")
return PublicEstimateResult(
token=token,
token_expires_at=row.public_token_expires_at,
n_analogs=result.n_analogs,
coverage=_coverage_for(
db, result.target_lat, result.target_lon, payload.rooms, payload.area_m2
),
)
@router.post(
"/estimate/read",
response_model=PublicEstimateResult,
# Флаг первым — по той же причине, что у `/estimate`.
dependencies=[
Depends(_require_public_estimate_enabled),
Depends(_budget(_estimate_read_limiter, "estimate-read")),
],
)
def public_estimate_read(
payload: PublicEstimateTokenInput,
db: Annotated[Session, Depends(get_db)],
) -> PublicEstimateResult:
"""Повторное чтение бесплатной части по капабилити-токену.
Существует потому, что у анонима нет аккаунта: без этой ручки результат
жил бы ровно в теле POST-ответа и не переживал бы перезагрузку страницы
(`_assert_estimate_access` в закрытом контуре отдаёт 404 на оценку с
`created_by IS NULL` всем, кроме админа и это правильно, менять его
ради анонима значило бы ослабить IDOR-гейт для всех).
Просрочка и «нет такого токена» отвечают ОДИНАКОВО (404): различать их
значит подтверждать существование расчёта тому, кто угадал токен.
"""
row = db.execute(
text(
"""
SELECT n_analogs, lat, lon, rooms, area_m2, public_token_expires_at
FROM trade_in_estimates
WHERE public_token_hash = :token_hash
AND public_token_expires_at > NOW()
"""
),
{"token_hash": _token_hash(payload.token)},
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Расчёт не найден или ссылка устарела.")
return PublicEstimateResult(
token=payload.token,
token_expires_at=row.public_token_expires_at,
n_analogs=row.n_analogs,
coverage=_coverage_for(db, row.lat, row.lon, row.rooms, row.area_m2),
)

View file

@ -40,6 +40,7 @@ from pydantic import BaseModel, Field, field_validator
# убрал legacy app.services.scheduler.scheduler_loop fallback) — тот же orchestrator,
# что и debug-роуты этого файла.
from scraper_kit.base import save_listings
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.orchestration.pipeline import (
DEFAULT_REGION_CODE,
run_avito_city_sweep,
@ -48,7 +49,6 @@ from scraper_kit.orchestration.pipeline import (
run_yandex_city_sweep,
run_yandex_full_load,
)
from scraper_kit.providers._base import build_browser_fetcher
from scraper_kit.providers.avito.detail import fetch_detail, save_detail_enrichment
from scraper_kit.providers.avito.houses import fetch_house_catalog, save_house_catalog_enrichment
from scraper_kit.providers.avito.imv import (
@ -268,7 +268,6 @@ async def geocode_missing(
db: Annotated[Session, Depends(get_db)],
limit: int = 100,
target: Literal["listings", "deals"] = "listings",
region_code: int = 66,
) -> dict:
"""Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
@ -280,10 +279,6 @@ async def geocode_missing(
geocode_tried_at: после КАЖДОЙ попытки (успех/провал) ставим NOW(). Failed-
адреса не выбираются повторно 7 дней cron-loop завершается, не зацикливается.
geom обновляется автоматически триггером.
region_code (дефолт 66, #3051) — фильтрует обе таблицы (`listings`/`deals`
несут колонку) и прокидывается в `known_city_hint`/`geocode`. Дефолт 66
прежнее поведение без изменений (cron не меняется, follow-up ниже).
"""
# Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса.
extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else ""
@ -296,14 +291,13 @@ async def geocode_missing(
WHERE lat IS NULL
AND COALESCE(address, '') != ''
{extra_filter}
AND region_code = CAST(:region_code AS int)
AND (geocode_tried_at IS NULL
OR geocode_tried_at < NOW() - interval '7 days')
ORDER BY geocode_tried_at NULLS FIRST
LIMIT :limit
"""
),
{"limit": limit, "region_code": region_code},
{"limit": limit},
)
.mappings()
.all()
@ -338,8 +332,8 @@ async def geocode_missing(
# хинт закрывает EKB-локальные тиры и уезжает префиксом в запрос
# провайдеру, т.е. вреднее отсутствия хинта. Общий хелпер, тот же, что у
# scripts/geocode_deals_nominatim.py и tasks/geocode_missing.py.
city = known_city_hint(row.get("city"), region_code)
result = await geocode(clean, db, city_hint=city, region_code=region_code)
city = known_city_hint(row.get("city"))
result = await geocode(clean, db, city_hint=city)
if result is None:
# Помечаем что пробовали — иначе ретрай на каждом cron.
db.execute(
@ -368,12 +362,10 @@ async def geocode_missing(
WHERE lat IS NULL
AND COALESCE(address, '') != ''
{extra_filter}
AND region_code = CAST(:region_code AS int)
AND (geocode_tried_at IS NULL
OR geocode_tried_at < NOW() - interval '7 days')
"""
),
{"region_code": region_code},
)
).scalar()
return {
@ -516,16 +508,9 @@ async def cian_auto_login(
)
try:
# #3197 (хвост): через фабрику (endpoint/environment из одного места), но
# НАМЕРЕННО без proxy_provider. `/login` сайдкара proxy-override не принимает
# (browser/server.py:2814-2817 — `_no_live_proxy(provider, None)`; и сам
# `_post_login` не кладёт payload["proxy"], это делают только fetch/fetch_json) —
# логин идёт с env-узла сайдкара. Аренда здесь была бы холостой и при пустом пуле
# блокировала бы ручку восстановления (`_acquire_lease` → NoProxyAvailableError →
# 502 ровно во время инцидента с пулом). Пул для логина — отдельная задача сайдкара.
# `use_pool` без провайдера фетчер игнорирует (`_acquire_lease`: use_pool AND
# provider is not None), поэтому передавать его тут безвредно, но и бесполезно.
async with build_browser_fetcher(RealScraperConfig(), "cian") as fetcher:
async with BrowserFetcher(
source="cian", endpoint=settings.browser_http_endpoint
) as fetcher:
raw_cookies = await fetcher.login(
url=settings.cian_login_url,
email=email,
@ -714,12 +699,7 @@ async def debug_domclick_detail_fetch(
# построен и verified live именно 2026-07-04 (815КБ __SSR_STATE__ через свежий IP).
# Заменяет прежний source="cian" (docstring domclick/detail.py — устаревшая
# рекомендация от 2026-06-27, до появления выделенного пула).
# #3197 (хвост): фабрика вместо прямой конструкции — она и есть то место, где
# proxy_provider/use_pool/environment попадают в тело POST /fetch. Без них узел
# выбирал сайдкар из своего env, а не пул с provider_affinity='domclick'.
async with build_browser_fetcher(
RealScraperConfig(), "domclick", proxy_provider=_kit_proxy_provider()
) as bf:
async with BrowserFetcher(source="domclick", endpoint=settings.browser_http_endpoint) as bf:
try:
enrichment = await domclick_fetch_detail(
body.card_url, browser_fetcher=bf, cookies=cookies
@ -1126,10 +1106,6 @@ async def scrape_avito_imv(
has_balcony=has_balcony,
has_loggia=has_loggia,
config=RealScraperConfig(),
# #3386: без provider'а curl_proxy_url считает use_pool=False (флаг AND
# provider is not None) → env-прокси SCRAPER_PROXY_URL, мёртвый узел (#2613).
# _kit_proxy_provider() отдаёт None при выключенных флагах — ship-dark цел.
proxy_provider=_kit_proxy_provider(),
)
except IMVAddressNotFoundError as e:
# Ожидаемое клиентское условие (адрес не в базе Avito), НЕ сбой — logger.warning
@ -1968,10 +1944,7 @@ async def scrape_yandex_newbuilding(
"""
# fetch_jk использует внутренний BrowserFetcher, httpx-клиент BaseScraper не нужен.
# config= обязателен (#2322 fix) — иначе BrowserFetcher строится без endpoint=.
# proxy_provider (#3197): без него сайдкар уходит на свой env-узел мимо пула.
scraper = YandexNewbuildingScraper(
config=RealScraperConfig(), proxy_provider=_kit_proxy_provider()
)
scraper = YandexNewbuildingScraper(config=RealScraperConfig())
result = await scraper.fetch_jk(jk_slug=slug, jk_id=id, city=city)
if result is None:
raise HTTPException(404, f"Could not parse Yandex JK: {slug}-{id} in {city}")

View file

@ -73,7 +73,6 @@ from app.core.password import (
verify_slots_saturated,
)
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.observability.metrics import LOGINS
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
@ -325,7 +324,6 @@ async def _reject_invalid_credentials(
fails = _USERNAME_FAIL_LIMITER.record(username)
delay_s = _throttle_delay_s(fails)
LOGINS.labels(result="failed").inc()
schedule_event(
event_type="login_failed",
username=username,
@ -454,7 +452,6 @@ async def login(
path="/",
)
LOGINS.labels(result="success").inc()
schedule_event(
event_type="login_success",
username=user["username"],

View file

@ -7,37 +7,16 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import get_db, run_db_thread
from app.observability.metrics import ADDRESS_SUGGESTIONS
from app.services.estimator import _lookup_house_facts
from app.core.db import get_db
from app.services.geocoder import GeocodeResult, geocode, reverse_geocode, suggest
from app.services.regions import DEFAULT_REGION_CODE, region_by_city
logger = logging.getLogger(__name__)
router = APIRouter()
def effective_region_code(region_code: int | None, city_hint: str | None) -> int:
"""Регион для геокодера (#3051): явный `region_code` > вывод из `city_hint` > 66.
До этого хелпера `/suggest` всегда уходил в геокодер с регионом 66
по умолчанию московский `city_hint` («Москва») молча получал
свердловский bbox-констрейнт и терял подсказки. Реестр `app.services.regions`
уже знает, каким городам какой регион соответствует (REGIONS[77].cities
содержит «москва») используем его вместо повторного захардкоженного списка.
"""
if region_code is not None:
return region_code
region = region_by_city(city_hint)
if region is not None:
return region.code
return DEFAULT_REGION_CODE
@router.get("/lookup", response_model=GeocodeResult)
async def lookup(
address: Annotated[str, Query(min_length=3, max_length=500)],
@ -99,21 +78,9 @@ async def suggest_addresses(
),
),
] = None,
region_code: Annotated[
int | None,
Query(
description=(
"Регион покрытия (#3051). None (дефолт) — выводится из `city_hint` "
"через реестр регионов, иначе 66 (Свердловская область, прежнее "
"поведение). 77 — Москва: без него DaData и Nominatim получают "
"свердловский hard-констрейнт и молча возвращают ПУСТО на "
"московском адресе."
),
),
] = None,
) -> SuggestResponse:
"""Автокомплит адресов в регионе `region_code` (дефолт 66 — Свердловская область;
ЕКБ основной трафик, остаётся быстрым fast-path).
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
остаётся быстрым fast-path).
Используется в EstimateForm для подсказок пока пользователь печатает.
Bounded viewbox генеральный по всей области (см. geocoder.OBLAST66_VIEWBOX),
@ -123,17 +90,8 @@ async def suggest_addresses(
/api/v1/geocode/suggest?q=Малышева
/api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет
/api/v1/geocode/suggest?q=Ленина+1&city_hint=Нижний+Тагил
/api/v1/geocode/suggest?q=Тверская+6&region_code=77 # Москва
"""
resolved_region_code = effective_region_code(region_code, city_hint)
try:
items = await suggest(
q, db=db, limit=limit, city_hint=city_hint, region_code=resolved_region_code
)
except ValueError as exc:
# Регион вне реестра покрытия — 422, а не 500: это ошибка ввода клиента.
raise HTTPException(status_code=422, detail=str(exc)) from exc
ADDRESS_SUGGESTIONS.labels(found="yes" if items else "no").inc()
items = await suggest(q, db=db, limit=limit, city_hint=city_hint)
return SuggestResponse(
items=[
SuggestItem(
@ -195,103 +153,3 @@ async def reverse(
precision=result.precision,
provider=result.provider,
)
class HouseFactsResponse(BaseModel):
"""Ответ /house-facts."""
found: bool
total_floors: int | None = None
year_built: int | None = None
house_type: str | None = None
source: str | None = Field(
default=None, description="'houses' когда факты найдены в справочнике, иначе None"
)
def _resolve_house_id_by_fias(db: Session, fias_id: str) -> int | None:
"""Ищет id дома в `houses` по ФИАС/ГАР guid — по трём алиасам guid-полей
справочника (заполняются из разных источников загрузки: DOM.РФ/ГИС ЖКХ/ГАР).
Best-effort: не нашли вызывающий молча падает на geo-фолбэк по lat/lon.
"""
try:
row = (
db.execute(
text(
"""
SELECT id FROM houses
WHERE gar_house_guid = CAST(:g AS text)
OR house_fias_id = CAST(:g AS text)
OR zhkh_house_guid = CAST(:g AS text)
LIMIT 1
"""
),
{"g": fias_id},
)
.mappings()
.first()
)
except Exception as exc: # pragma: no cover — defensive
# Тот же best-effort, что и у _lookup_house_facts ниже по цепочке:
# предзаполнение формы НЕ должно ронять запрос в 500. Откатываем
# транзакцию, чтобы не отравить сессию для последующего geo-фолбэка.
logger.warning("house-facts: fias lookup failed (graceful): %s", exc)
try:
db.rollback()
except Exception: # pragma: no cover — defensive
pass
return None
if row is None:
return None
house_id = row["id"]
return house_id if isinstance(house_id, int) else None
@router.get("/house-facts", response_model=HouseFactsResponse)
async def house_facts(
lat: Annotated[float, Query(ge=-90, le=90)],
lon: Annotated[float, Query(ge=-180, le=180)],
db: Annotated[Session, Depends(get_db)],
fias_id: Annotated[str | None, Query(max_length=64)] = None,
) -> HouseFactsResponse:
"""Предзаполнение формы оценки (этажность/год/тип дома) из справочника `houses`.
Покрытие справочника: total_floors 98.3%, year_built 86.8% (прод, 2026-08).
Переиспользует ту же логику поиска, что и estimate_quality() (#3234): по
target_house_id если резолвился ФИАС, иначе ближайший дом в радиусе 60м от
lat/lon (ST_DWithin).
Отдельная ручка, а не поле в /suggest: suggest дёргается на КАЖДОЕ нажатие
клавиши автокомплита (debounced, но всё равно несколько запросов на ввод
адреса) тащить туда ещё один запрос к houses на каждый из 8 кандидатов
на каждый keystroke было бы неоправданной нагрузкой на БД. /house-facts
вызывается ОДИН раз, после того как пользователь выбрал конкретный адрес
из подсказок.
found=false (200, НЕ 404) означает «дома нет в справочнике» это не
ошибка запроса, а честный ответ об отсутствии данных.
Пример:
/api/v1/geocode/house-facts?lat=56.838&lon=60.595
/api/v1/geocode/house-facts?lat=56.838&lon=60.595&fias_id=...
"""
target_house_id: int | None = None
if fias_id is not None:
target_house_id = await run_db_thread(_resolve_house_id_by_fias, db, fias_id)
facts = await run_db_thread(
_lookup_house_facts,
db,
target_house_id=target_house_id,
lat=lat,
lon=lon,
)
if facts is None:
return HouseFactsResponse(found=False)
return HouseFactsResponse(
found=True,
total_floors=facts.total_floors,
year_built=facts.year_built,
house_type=facts.house_type,
source="houses",
)

View file

@ -13,11 +13,9 @@ _send_uptime_generic``) в итоге идут через ОДНУ И ТУ ЖЕ
1) тело запроса для issue и uptime алертов структурно ОДИНАКОВОЕ
``{"text": str, "attachments": [{"title","title_link","text","color",
"fields",...}]}`` просто у uptime пустые/отсутствующие ``fields``/``color``;
2) единственный канал для аутентификации от САМОГО GlitchTip сам URL (как и
у Slack-вебхуков): заголовок GlitchTip-сторона не добавляет. Поэтому
хендлер принимает секрет и из заголовка ``X-GlitchTip-Secret``
(предпочтительно не течёт в access-log, #3154), и из query-параметра
``?secret=`` как fallback для текущего отправителя.
2) единственный канал для аутентификации сам URL (как и Slack-вебхуки).
Секрет ОБЯЗАН ехать query-параметром, HTTP-заголовок здесь поставить
нечем (GlitchTip-сторона его не добавляет).
Переиспользуем существующий ``TRADEIN_INTERNAL_AUTH_SECRET`` (#2213
defense-in-depth, см. ``app.core.rbac``) вместо нового секрета тот же
@ -49,15 +47,11 @@ import secrets
from datetime import UTC, datetime
from typing import Annotated, Any
from fastapi import APIRouter, Header, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, ValidationError
from starlette.background import BackgroundTask
from app.core.config import settings
from app.services.tgbot.client import TelegramError
from app.services.tgbot.shared import get_telegram_client
from app.tasks.glitchtip_alert_retry import retry_forward_alert
from app.services.tgbot.client import TelegramApiError, TelegramClient
logger = logging.getLogger(__name__)
@ -181,58 +175,33 @@ def _alerts_configured() -> bool:
def _verify_secret(provided: str) -> None:
expected = settings.tradein_internal_auth_secret
# constant-time: длина/префикс секрета не утекают через время ответа.
if not secrets.compare_digest(provided or "", expected):
logger.warning("glitchtip webhook: invalid or missing secret")
logger.warning("glitchtip webhook: invalid or missing secret query param")
raise HTTPException(status_code=401, detail="invalid or missing secret")
@router.post("/ops/glitchtip-webhook", response_model=None)
@router.post("/ops/glitchtip-webhook")
async def glitchtip_webhook(
request: Request,
secret: Annotated[str, Query()] = "",
header_secret: Annotated[str, Header(alias="X-GlitchTip-Secret")] = "",
) -> dict[str, str] | JSONResponse:
) -> dict[str, str]:
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля).
Отказ синхронной попытки (#3471) отвечает 502 как и раньше (#3456 — честный
сигнал отправителю), но ставит доставку в фон
(``app.tasks.glitchtip_alert_retry.retry_forward_alert`` через
``starlette.background.BackgroundTask`` на самом ответе) GlitchTip вебхуки
не ретраит (#3157), без этого текст алерта терялся бы безвозвратно.
``BackgroundTask`` привязан НАПРЯМУЮ к возвращаемому ``JSONResponse``, а не
к ``BackgroundTasks``-зависимости: FastAPI прикрепляет задачи из
``BackgroundTasks`` только к ответу, который вернул сам хендлер, а `raise
HTTPException` строит ОТДЕЛЬНЫЙ ответ в exception-мидлваре задачи,
поставленные до `raise`, в реальности молча терялись бы вместе с ним (это
воспроизведено тестом, не гипотеза).
Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) этот
хендлер сам делает единственную проверку секрета.
Секрет принимается ИЗ ЗАГОЛОВКА ``X-GlitchTip-Secret``, а query-параметр
``?secret=`` остаётся fallback'ом (#3154). Заголовок предпочтителен потому,
что query едет в access-log и оттуда в Loki открытым текстом; query оставлен,
т.к. САМ GlitchTip 6.1.6 заголовков не шлёт вовсе (``send_webhook()``
``session.post(url, json=...)`` без headers, см. docstring модуля), и убрать
query можно только когда заголовок начнёт подставлять кто-то перед нами
(Caddy ``header_up`` на маршруте вебхука) либо после смены отправителя.
хендлер сам делает единственную проверку (``secret`` query-параметр).
"""
if not _alerts_configured():
raise HTTPException(status_code=503, detail="glitchtip alerts webhook not configured")
_verify_secret(header_secret or secret)
_verify_secret(secret)
raw_body = await request.body()
received_at = datetime.now(UTC)
text = _build_message(raw_body, received_at)
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
client = get_telegram_client()
client = TelegramClient(settings.telegram_bot_token)
try:
await client.send_message(
chat_id=settings.telegram_alerts_chat_id,
@ -243,30 +212,8 @@ async def glitchtip_webhook(
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
)
except TelegramError:
# Ловим общий предок, а не `TelegramApiError`: недоступность Telegram —
# тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не
# 500 из необработанного исключения (#3456). 502 ОСТАЁТСЯ — это честный
# сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого
# текст алерта пропал бы бесследно, поэтому доставку ставим в фон
# (#3471, см. app.tasks.glitchtip_alert_retry).
#
# `raise HTTPException` здесь НЕ подходит: FastAPI прикрепляет
# background-задачи только к ответу, который вернул сам хендлер, а
# исключение строит СВОЙ отдельный JSONResponse в exception-мидлваре —
# задача, поставленная до `raise`, никогда бы не выполнилась. Поэтому
# 502 собран и возвращён вручную, с задачей на этом же объекте ответа.
except TelegramApiError:
logger.exception("glitchtip webhook: не удалось переслать алерт в Telegram")
return JSONResponse(
status_code=502,
content={"detail": "failed to forward alert to telegram"},
background=BackgroundTask(
retry_forward_alert,
client,
chat_id=settings.telegram_alerts_chat_id,
text=text,
message_thread_id=settings.telegram_alerts_topic_id or None,
),
)
raise HTTPException(status_code=502, detail="failed to forward alert to telegram") from None
return {"status": "ok"}

View file

@ -33,7 +33,6 @@ from sqlalchemy.orm import Session
from app.api.v1.trade_in import _assert_estimate_access
from app.core.config import settings
from app.core.db import get_db
from app.observability.metrics import LEADS
logger = logging.getLogger(__name__)
@ -54,19 +53,12 @@ _PHONE_MAX_DIGITS = 15
# только в audit-лог (#2497 TODO, теперь закрыт).
#
# Значение = дата утверждения политики (PRIVACY_APPROVAL в frontend/src/app/
# mera-public/content.ts: «приказом директора № 2 от 10 сентября 2026 г.» →
# "2026-09-10"), а не дата этого коммита — версия обязана указывать на редакцию
# mera-public/content.ts: «приказом директора № 1 от 13 августа 2026 г.» →
# "2026-08-13"), а не дата этого коммита — версия обязана указывать на редакцию
# ДОКУМЕНТА, на который согласие фактически ссылается (чекбокс теперь линкует
# именно на /mera-public/privacy). test_consent_text_frontend_sync.py проверяет
# это соответствие автоматически, так что рассинхронизация здесь падает в CI.
#
# Редакция № 2 от 10.09.2026: в политику добавлен раздел 9 «Файлы cookie и
# веб-аналитика» (на публичный сайт поставлены Яндекс.Метрика и GA4), прежний
# раздел «Контакты» стал десятым. Согласия, собранные до этой даты, ссылаются
# на редакцию № 1 и остаются с версией "2026-08-13" — в этом и смысл хранить
# версию per-row: снимок согласия должен указывать на тот документ, который
# человек видел, а не на текущий.
_CONSENT_POLICY_VERSION = "2026-09-10"
_CONSENT_POLICY_VERSION = "2026-08-13"
# Снимок точного текста согласия, который видит пользователь при отправке лида
# (ПЛОСКИЙ текст — без разметки ссылки на политику, которая в LeadForm.tsx рядом
@ -174,7 +166,6 @@ async def create_trade_in_lead(
)
db.commit()
LEADS.inc()
logger.info(
"trade_in_lead saved id=%s estimate_id=%s source=%s ip=%s policy=%s",

View file

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

View file

@ -14,6 +14,7 @@ module docstring (честно про то, что не всегда разре
from __future__ import annotations
import asyncio
import logging
from typing import Annotated
from uuid import UUID
@ -22,7 +23,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.db import get_db, run_db_thread
from app.core.db import get_db
logger = logging.getLogger(__name__)
@ -66,7 +67,7 @@ async def erase_person_data_endpoint(
from app.services.data_erasure import erase_person_data
counters = await run_db_thread(
counters = await asyncio.to_thread(
erase_person_data,
db,
username=payload.username,

View file

@ -62,23 +62,18 @@ import hashlib
import logging
import re
import secrets
import time
from datetime import UTC, datetime
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.observability.metrics import SUPPORT_MESSAGES
from app.services.tgbot import web_support_storage as storage
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
from app.services.tgbot.client import TelegramError
from app.services.tgbot.shared import get_telegram_client
from app.services.tgbot.client import TelegramApiError, TelegramClient
logger = logging.getLogger(__name__)
@ -99,86 +94,16 @@ _send_limiter = SlidingWindowLimiter(limit=_SEND_RATE_LIMIT, window_s=_SEND_RATE
# #tgsupport-web review H1: интерактивный HTTP-запрос НЕ МОЖЕТ наследовать
# воркерную политику ретраев `TelegramClient` (по умолчанию — до 5 попыток, на
# 429 спит `retry_after` Telegram'а — для группы штатно 30-60с, на 5xx backoff до
# 30с — легальный суммарный бюджет минуты). Узкий бюджет здесь: интерактивный
# клиент должен получить ответ (даже если это ошибка) за секунды, а не висеть до
# исчерпания воркерных ретраев.
#
# Числа подобраны по замеру прода 01.09.2026 (#tgsupport-retry), а не на глаз.
# Что измерено на самом хосте, из контейнера бота:
# - канал до api.telegram.org рвётся постоянно: 353 ConnectTimeout за сутки в
# логе long-polling'а; доля отказов на попытку 15-38% всплесками (пять проб:
# 3/8, 15/40, 5/20, 3/20, 1/25). Транспорт ни при чём — httpx и сырой сокет
# отваливаются одинаково (25% против 35% в чередующемся замере);
# - успешный запрос отвечает за 0.13с, максимум из 25 проб — 0.18с;
# - неудачный НИКОГДА не отваливается быстро: все отказы упираются в таймаут
# целиком (10.02с при timeout=10.0), быстрых — ноль;
# - через `SCRAPER_PROXY_URL` не легчает, а хуже: 0 из 20. Прокси не решение.
#
# Отсюда три следствия. Таймаут 10с был чистой платой за неудачу (успех не занимает
# и половины секунды) — снижен до 5с, это ~28-кратный запас к измеренному максимуму.
# Одного повтора мало: при 30% отказов на попытку до пользователя доходило ~9%
# отказов, то есть каждое одиннадцатое сообщение терялось с 502; три повтора уводят
# это к ~0.8%. Экспоненциальная пауза 2→4→8с осмысленна против троттлинга, но здесь
# отказ — неустановленное соединение, паузе нечего пережидать, поэтому потолок 1с:
# он же не даёт `retry_after` из 429 (для группы штатные 30-60с) умножиться на число
# попыток. Худший случай: 4 попытки × 5с + 3 паузы × 1с = 23с, и он требует четырёх
# отказов подряд. Типичный случай не меняется — 0.13с.
_INTERACTIVE_SEND_TIMEOUT_S = 5.0
_INTERACTIVE_SEND_MAX_RETRIES = 3
_INTERACTIVE_SEND_MAX_BACKOFF_S = 1.0
# Счётчик ОТКАЗОВ отправки — отдельный от бюджетов выше (#tgsupport-fail-cooldown).
#
# Зачем вообще второй счётчик. `_send_limiter`/`_anon_ip_limiter` расходуются
# ТОЛЬКО на успехе (review L3, non-destructive peek выше) — это верно для
# «не наказывать за чужую аварию», но имеет обратную сторону: пока Telegram
# недоступен, лимита нет ВООБЩЕ. Каждый повтор пользователя при этом стоит до
# `1 + _INTERACTIVE_SEND_MAX_RETRIES` = 4 попыток к api.telegram.org и не
# расходует ни один бюджет. Двух-трёх вкладок с авто-ретраем хватает, чтобы
# выесть лимиты группы ровно в тот момент, когда канал и так еле жив.
#
# Отсюда — дешёвый gate ПЕРЕД походом в Telegram, на своих ключах (тех же, что
# у основных лимитеров: username / anon-thread-key и client IP).
#
# N=5. Отказ одной отправки — не событие: по замеру #tgsupport-retry доля отказов
# на попытку 15-38%, но после 4 попыток до пользователя доходит ~0.8-2% отказов.
# Пять подряд на живом канале — вероятность порядка 1e-10, то есть cooldown
# физически не может сработать на «просто не повезло»; он срабатывает только на
# настоящей недоступности. Плюс `reset()` на успехе: считаем именно ПОДРЯД, одна
# успешная отправка стирает историю.
#
# 30с окна (оно же длительность cooldown: блок держится, пока самый старый из
# N отказов не выпадет из окна). Верхняя граница осмысленности — реальная
# недоступность Telegram по тому же замеру длится минутами, так что 30с заведомо
# короче и пользователя после восстановления канала не наказывают. Нижняя —
# один отказавший интерактивный запрос сам по себе занимает до 23с
# (4 попытки × 5с + 3 паузы × 1с), cooldown короче этого просто не имел бы смысла.
_SEND_FAILURE_LIMIT = 5
_SEND_FAILURE_WINDOW_S = 30.0
_send_failure_limiter = SlidingWindowLimiter(
limit=_SEND_FAILURE_LIMIT, window_s=_SEND_FAILURE_WINDOW_S
)
# Отдельный экземпляр для IP-ключей — ровно как `_anon_ip_limiter` отдельный от
# `_send_limiter`: ключи разных пространств (IP vs thread-key) в одном словаре
# смешивать нельзя.
_anon_ip_failure_limiter = SlidingWindowLimiter(
limit=_SEND_FAILURE_LIMIT, window_s=_SEND_FAILURE_WINDOW_S
)
_SEND_UNAVAILABLE_DETAIL = "Telegram сейчас недоступен. Попробуйте через полминуты."
# 30с — легальный суммарный бюджет минуты). Узкий бюджет здесь: 1 повтор, короткий
# timeout — интерактивный клиент должен получить ответ (даже если это ошибка)
# за секунды, а не висеть до исчерпания воркерных ретраев.
_INTERACTIVE_SEND_TIMEOUT_S = 10.0
_INTERACTIVE_SEND_MAX_RETRIES = 1
# #tgsupport-web review M5: без LIMIT каждое монтирование виджета на старом
# треде отдавало бы ВЕСЬ лог переписки. См. `web_support_storage.list_messages`.
_LIST_MESSAGES_LIMIT = 200
# Идемпотентность отправки (#3471 retry-storm) — см. `_resolve_idempotency_key`.
_IDEMPOTENCY_HEADER = "idempotency-key"
# Форма клиентского ключа — как у anon-токена (`_ANON_TOKEN_RE`): произвольная
# opaque-строка клиента, без пробелов/спецсимволов, которые попали бы в SQL-параметр
# как есть. Не матчится — считаем заголовок отсутствующим и уходим на fallback,
# а не пытаемся его "починить" (тот же принцип, что у `_read_anon_token`).
_CLIENT_IDEMPOTENCY_KEY_RE = re.compile(r"^[A-Za-z0-9_.-]{8,128}\Z")
def _require_username(request: Request) -> str:
"""Достаёт X-Authenticated-User. rbac_guard (app/main.py) уже гарантирует его
@ -218,18 +143,6 @@ class SupportMessageOut(BaseModel):
text_body: str
operator_tg_id: int | None = None
created_at: str
# Дошло ли сообщение до БД. `True` по умолчанию — все существующие пути
# (`GET /support/messages`, успешный POST) строят модель из storage-строки и
# ничего про флаг не знают, контракт для них не меняется.
#
# `False` ставит ТОЛЬКО `_unpersisted_message_out`: доставлено оператору, но
# не записано. Флаг нужен потому, что без него деградация неотличима от
# тишины: фронт выбрасывает тело POST и рендерит транскрипт исключительно из
# `GET /support/messages` (`useSupportChat.ts`), где этого сообщения нет —
# поле ввода очищается, сообщение не появляется, ошибки нет. Пользователь
# решает, что не отправилось, и шлёт снова — ровно тот дубль в топике,
# против которого вся эта ветка и сделана.
persisted: bool = True
@field_validator("created_at", mode="before")
@classmethod
@ -255,150 +168,8 @@ def _format_mirror_text(username: str, message_text: str) -> str:
return f"[С САЙТА] {username}:\n{message_text}"
def _too_many_failures_error(retry_after: float) -> HTTPException:
"""429 вместо похода в Telegram — канал только что отказал N раз подряд."""
return HTTPException(
status_code=429,
detail=_SEND_UNAVAILABLE_DETAIL,
headers={"Retry-After": str(int(retry_after) + 1)},
)
def _unpersisted_message_out(text_body: str) -> SupportMessageOut:
"""Синтетический ответ для случая «в топик доставлено, а в БД не записано».
Почему ответ вообще УСПЕШНЫЙ. Сообщение оператору реально доставлено
отдать на это ошибку значит соврать: пользователь повторит, и в топике
окажется дубль (плюс второе предупреждение оператору). Успех здесь честнее
отказа, но он неполный, и об этом клиенту надо сказать явно.
Что сообщает `persisted=False`: «доставлено, но ответить тебе через чат не
смогут; повторять не надо». Именно флаг контракт для клиента, а НЕ `id=0`:
по идентификатору клиент отличить деградацию не обязан и не будет.
`id` при этом всё равно нужен модели, и 0 сознательный сентинел, а не
выдумка: реальные id в `web_support_messages` начинаются с 1 (serial),
поэтому 0 ни с чем не столкнётся, а `GET /support/messages` принимает
`since>=0`. Даже если фронт когда-нибудь начнёт курсорить по возвращённому
id (сейчас он перечитывает тред целиком с `since=0`, `useSupportChat.ts`),
нулевой курсор не перепрыгнет ни одного реального сообщения: занижение
курсора безопасно, завышение нет.
"""
return SupportMessageOut(
id=0,
direction="in",
text_body=text_body,
operator_tg_id=None,
created_at=datetime.now(UTC),
persisted=False,
)
async def _warn_operator_message_not_recorded(*, label: str, topic_message_id: int | None) -> None:
"""Реплаем к только что доставленному зеркалу предупреждает оператора, что
ответ на ЭТО сообщение не смаршрутизируется: треда в БД нет, на реплай к
осиротевшему зеркалу `bridge._handle_group_reply` напишет только WARNING, а
клиент не увидит ничего. Без предупреждения оператор отвечает в пустоту и
считает, что помог.
Текст обращения сюда НЕ дублируется (ПДн) оператор видит его в сообщении,
к которому это реплай.
Формулировка учитывает, что клиенту ответили успехом (`persisted=False`,
«принято, повторять не надо»): рассчитывать на «клиент напишет снова»
оператору нельзя, поднимать тред придётся иначе.
Любой отказ гасится логом: основное сообщение УЖЕ доставлено, превращать
неудачу служебного уведомления в 500 поверх успеха нельзя.
"""
text = (
f"ВНИМАНИЕ · {label}: сообщение доставлено в топик, но НЕ записано в базу "
"(сбой БД). Треда у этого обращения нет — ответить клиенту через бота "
"НЕЛЬЗЯ: реплай на это сообщение никуда не уйдёт. Повтора тоже не ждите, "
"клиенту показано, что сообщение принято и отправлять его снова не нужно. "
"Если в обращении есть контакт — свяжитесь напрямую; иначе передайте "
"дежурному и сообщите об отказе БД."
)
try:
client = get_telegram_client()
await client.send_message(
chat_id=settings.telegram_support_chat_id,
text=text,
message_thread_id=settings.telegram_support_topic_id or None,
# reply_to может отсутствовать (sendMessage не вернул message_id) —
# тогда уведомление уходит отдельным сообщением в топик: хуже, чем
# реплай, но несравнимо лучше тишины.
reply_to_message_id=topic_message_id,
# Тот же узкий интерактивный бюджет (review H1): клиент ждёт ответа
# ручки, а не доставки служебного уведомления.
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
)
except Exception:
# Шире `TelegramError` намеренно: это best-effort хвост уже успешного
# запроса, любой отказ здесь обязан остаться в логе, а не у клиента.
logger.exception(
"web support: не удалось предупредить оператора о несохранённом сообщении (%s)",
label,
)
def _rollback_quietly(db: Session) -> None:
"""Откат после `SQLAlchemyError`. Сам rollback на мёртвом соединении тоже
может бросить, а мы уже решили отдать клиенту успех `get_db` в любом
случае закроет сессию в `finally`."""
try:
db.rollback()
except SQLAlchemyError:
logger.warning("web support: rollback после сбоя БД тоже не удался", exc_info=True)
def _resolve_idempotency_key(request: Request, *, identity_key: str, text: str) -> str:
"""Ключ идемпотентности inbound-отправки (#3471, миграция 301).
Почему заголовок + fallback, а не что-то одно. Явный `Idempotency-Key`
ЕДИНСТВЕННЫЙ надёжный путь: клиент генерирует ключ ОДИН раз на "намерение
отправить" и переиспользует его на любом ретрае (fetch retry / переотправка
после таймаута) независимо от того, что именно менялось в UI между
попытками (см. `useSendSupportMessage` во фронтенде реальный трафик
обязан идти этим путём). Fallback без заголовка детерминированный
отпечаток sha256(identity, текст, минутное окно) существует ТОЛЬКО для
клиентов, которые заголовок не прислали (п.5 требования старое поведение
не должно сломаться), и у него два честных изъяна, оба снимаются самим
фактом использования заголовка:
1. два РАЗНЫХ по смыслу сообщения с одинаковым текстом от одного и того
же человека в течение одной минуты ("да" и ещё раз "да", "+", "ок")
СХЛОПНУТСЯ в одно второе будет молча проглочено, клиент получит id
первого, оператор не увидит второе сообщение вообще;
2. окно не скользящие 60 секунд, а округление `time.time() // 60` вниз:
фактический срок дедупликации случаен от 0 до 60с в зависимости от
момента внутри минуты, и часть настоящих повторов (ретрай ровно на
границе окна) эту защиту не получит.
Оба пункта перестают быть важны, когда клиент реально шлёт заголовок
(см. `useSendSupportMessage`).
Хранит и потенциально логирует (см. вызовы в этом файле) только САМ ключ
он либо непрозрачный клиентский токен, либо хэш. Текст сообщения сюда
попадает ТОЛЬКО как вход в sha256, в открытом виде не сохраняется и не
возвращается жёсткое правило проекта "текст не в логах" (ПДн) при этом
не нарушается, даже если бы этот ключ где-то залогировали.
Префиксы `client:`/`auto:` разводят два namespace'а ключей (защита от
случайного совпадения клиентского токена с fallback-хэшем) дёшево и не
требует отдельной колонки.
"""
header = (request.headers.get(_IDEMPOTENCY_HEADER) or "").strip()
if header and _CLIENT_IDEMPOTENCY_KEY_RE.match(header):
return f"client:{header}"
window = int(time.time() // 60)
fingerprint = f"{identity_key}|{text}|{window}"
return "auto:" + hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()
@router.post("/support/messages", response_model=SupportMessageOut)
async def send_support_message(
request: Request,
payload: SupportMessageInput,
username: Annotated[str, Depends(_require_username)],
db: Annotated[Session, Depends(get_db)],
@ -426,47 +197,7 @@ async def send_support_message(
headers={"Retry-After": str(int(retry_after) + 1)},
)
# Cooldown по ОТКАЗАМ (см. `_SEND_FAILURE_LIMIT`): канал только что отказал
# N раз подряд — не тратим на этот запрос ещё четыре попытки к Telegram.
cooldown = _send_failure_limiter.retry_after(username)
if cooldown is not None:
raise _too_many_failures_error(cooldown)
# Идемпотентность (#3471). Что именно закрывает этот pre-check — важно не
# переоценить: строка в БД (и её idempotency_key) появляется ТОЛЬКО ПОСЛЕ
# успешной доставки в Telegram (H1 ниже), поэтому потерю самого запроса на
# плече Selectel -> api.telegram.org этот механизм НЕ дедуплицирует — если
# `send_message` не удался, ключ нигде не записан, и повтор клиента после
# неудачи это законная первая попытка. Реально закрывается другой, тоже
# частый случай: доставка УЖЕ состоялась (Telegram принял, строка
# закоммичена), но ответ до клиента не дошёл (обрыв на обратном пути,
# клиентский таймаут) или пользователь кликнул "отправить" второй раз по
# той же ещё не отрисовавшейся отправке — тогда pre-check находит уже
# записанное сообщение по ключу и отдаёт его же, не уходя в Telegram снова.
# Тред может ещё не существовать (это первая попытка этого ключа) — тогда
# сравнивать не с чем, идём в Telegram как обычно; сам факт "нет треда"
# здесь безопасен, т.к. тред создаётся ТОЛЬКО в этой же ручке (см. H1) —
# если бы сообщение под этим ключом уже было записано, тред уже был бы.
idempotency_key = _resolve_idempotency_key(request, identity_key=username, text=payload.text)
existing_thread_id = storage.find_thread_id(db, username)
if existing_thread_id is not None:
existing = storage.find_inbound_by_idempotency_key(
db, thread_id=existing_thread_id, idempotency_key=idempotency_key
)
if existing is not None:
logger.info(
"web support: repeat send (idempotency key already recorded) "
"username=%s thread_id=%d message_id=%s",
username,
existing_thread_id,
existing["id"],
)
return SupportMessageOut(**existing)
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
client = get_telegram_client()
client = TelegramClient(settings.telegram_bot_token)
try:
mirrored = await client.send_message(
chat_id=settings.telegram_support_chat_id,
@ -475,24 +206,17 @@ async def send_support_message(
# review H1: узкий интерактивный бюджет — НЕ воркерные 5 ретраев/минуты.
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
)
except TelegramError:
except TelegramApiError:
# НЕ логируем payload.text (переписка — ПДн) и НЕ логируем токен (его в
# TelegramApiError и не бывает — см. client.py docstring про redaction).
# Предок, а не `TelegramApiError`: при таймауте до Telegram пользователь
# должен увидеть тот же «сервис недоступен», а не 500 (#3456).
logger.exception(
"web support: не удалось отправить зеркало в топик (username=%s)", username
)
_send_failure_limiter.record(username)
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
# Отправка удалась — теперь и только теперь расходуем rate-limit бюджет.
_send_limiter.record(username)
SUPPORT_MESSAGES.labels(channel="web").inc()
# Канал жив — счётчик отказов считает именно ПОДРЯД идущие отказы.
_send_failure_limiter.reset(username)
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
if topic_message_id is None:
@ -506,35 +230,15 @@ async def send_support_message(
)
# review H1: БД-операция ПОСЛЕ успешной отправки — см. docstring модуля.
#
# Оборотная сторона этого порядка: отказ БД здесь означает, что сообщение
# оператору УЖЕ доставлено. Отдавать на это 500 (как было) — худший из
# вариантов: клиент видит ошибку, шлёт повторно, в топике дубль, а на
# осиротевшее зеркало оператор отвечает в пустоту. Поэтому отвечаем успехом
# (доставка правда состоялась) и отдельным сообщением предупреждаем
# оператора, что отвечать на это зеркало бесполезно.
try:
thread_id = storage.get_or_create_thread(db, username)
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,
idempotency_key=idempotency_key,
)
db.commit()
except SQLAlchemyError:
# Ни текста обращения (ПДн), ни токена в логе — только кто и что сломалось.
logger.exception(
"web support: сообщение доставлено в топик, но не записано в БД (username=%s)",
username,
)
_rollback_quietly(db)
await _warn_operator_message_not_recorded(
label=f"[С САЙТА] {username}", topic_message_id=topic_message_id
)
return _unpersisted_message_out(payload.text)
thread_id = storage.get_or_create_thread(db, username)
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()
logger.info("web support: message sent username=%s thread_id=%d", username, thread_id)
return SupportMessageOut(**row)
@ -681,46 +385,8 @@ async def send_anon_support_message(
headers={"Retry-After": str(int(retry_after) + 1)},
)
# Cooldown по ОТКАЗАМ (см. `_SEND_FAILURE_LIMIT`) — оба ключа, как и у
# бюджетов выше: per-token ловит одну вкладку с авто-ретраем, per-IP — ту же
# петлю после сброса куки.
for cooldown in (
_send_failure_limiter.retry_after(thread_key),
_anon_ip_failure_limiter.retry_after(ip),
):
if cooldown is not None:
raise _too_many_failures_error(cooldown)
display_id = _anon_display_id(token)
# Идемпотентность (#3471) — см. развёрнутый комментарий в авторизованной
# ветке. Ограничение специфично для анонимной ветки: если предыдущая
# попытка сама провалилась ДО выдачи куки (`is_new_token=True` тогда и
# сейчас), identity_key каждый раз новый (случайный токен) и pre-check
# структурно не может найти прошлую попытку — это тот же класс проблемы,
# что и потеря куки в сети, вне scope этой правки.
idempotency_key = _resolve_idempotency_key(request, identity_key=thread_key, text=payload.text)
existing_thread_id = storage.find_thread_id(db, thread_key)
if existing_thread_id is not None:
existing = storage.find_inbound_by_idempotency_key(
db, thread_id=existing_thread_id, idempotency_key=idempotency_key
)
if existing is not None:
logger.info(
"web support (anon): repeat send (idempotency key already recorded) "
"%s thread_id=%d message_id=%s",
display_id,
existing_thread_id,
existing["id"],
)
if is_new_token:
_set_anon_cookie(response, token)
return SupportMessageOut(**existing)
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
client = get_telegram_client()
client = TelegramClient(settings.telegram_bot_token)
try:
mirrored = await client.send_message(
chat_id=settings.telegram_support_chat_id,
@ -728,24 +394,16 @@ async def send_anon_support_message(
message_thread_id=settings.telegram_support_topic_id or None,
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
)
except TelegramError:
except TelegramApiError:
# Ни текст сообщения (ПДн), ни токен (bearer треда) в лог не попадают.
# Про предок вместо `TelegramApiError` — см. комментарий в парной ручке.
logger.exception(
"web support (anon): не удалось отправить зеркало в топик (%s)", display_id
)
_send_failure_limiter.record(thread_key)
_anon_ip_failure_limiter.record(ip)
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
_send_limiter.record(thread_key)
_anon_ip_limiter.record(ip)
SUPPORT_MESSAGES.labels(channel="anon").inc()
# Канал жив — счётчики отказов считают именно ПОДРЯД идущие отказы.
_send_failure_limiter.reset(thread_key)
_anon_ip_failure_limiter.reset(ip)
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
if topic_message_id is None:
@ -755,37 +413,15 @@ async def send_anon_support_message(
display_id,
)
# Отказ БД после успешной отправки — см. развёрнутый комментарий в парной
# (авторизованной) ручке: сообщение оператору уже доставлено, 500 тут создаёт
# дубли в топике и «осиротевшее» зеркало, на которое оператор отвечает зря.
try:
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,
idempotency_key=idempotency_key,
)
db.commit()
except SQLAlchemyError:
# Ни текста обращения (ПДн), ни токена (bearer треда) в логе — только
# несекретный ярлык треда.
logger.exception(
"web support (anon): сообщение доставлено в топик, но не записано в БД (%s)",
display_id,
)
_rollback_quietly(db)
await _warn_operator_message_not_recorded(
label=f"[С САЙТА · БЕЗ ВХОДА] {display_id}", topic_message_id=topic_message_id
)
# Куку ставим ВСЁ РАВНО: тред в БД не создан, но идентичность посетителя
# обязана пережить этот сбой — иначе следующее сообщение (когда база
# поднимется) заведёт ВТОРОЙ тред, и переписка разъедется на два.
if is_new_token:
_set_anon_cookie(response, token)
return _unpersisted_message_out(payload.text)
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)

View file

@ -78,7 +78,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from sqlalchemy.sql.elements import TextClause
from app.core.auth import get_role, yaml_role
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
@ -288,9 +288,7 @@ def _upsert_quota_override(
)
def _batch_quota_status(
db: Session, usernames: list[str], known_roles: dict[str, str] | None = None
) -> dict[str, dict[str, Any]]:
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).
@ -353,21 +351,13 @@ def _batch_quota_status(
result: dict[str, dict[str, Any]] = {}
for username in usernames:
override = override_by_username.get(username)
# #3316: get_role ходит в реестр, а вызывающий уже прочитал роли этих
# же строк — иначе батч снова стал бы N+1 (ловит
# test_list_employees_query_count_is_not_n_plus_1). Роль реестра —
# ровно то, что вернул бы get_role: он спрашивает реестр первым.
role: str | None
if known_roles is not None and username in known_roles:
role = known_roles[username]
else:
try:
role = get_role(username)
except KeyError:
role = None
try:
role = get_role(username)
except KeyError:
role = None
if role == "admin":
unlimited = True
elif yaml_role(username) is not None:
elif role is not None:
unlimited = bool(override is not None and override["unlimited"])
else:
# username не в roles.yaml — is_unlimited() короткое замыкание на
@ -442,20 +432,6 @@ async def create_employee(
`identity_db` реестр (строка сотрудника), `db` продуктовая квота;
в дефолтном режиме это одна и та же сессия и одна транзакция.
"""
# #3316 defense-in-depth: имя, за которым в roles.yaml уже числятся права
# (admin/pilot/analyst), занять нельзя. Роль резолвится из реестра первой
# (app.core.auth.get_role), так что эскалации не было бы и без этой
# проверки — но совпадение имён само по себе означает двух разных людей с
# одним логином, и дешевле отказать на входе, чем разбирать это в логах.
legacy = yaml_role(body.username)
if legacy is not None and legacy != "employee":
logger.warning(
"create_employee: %r refused — username занят в roles.yaml (role=%s)",
body.username,
legacy,
)
raise HTTPException(status_code=409, detail="username reserved in roles config")
schema = identity_schema()
existing = identity_db.execute(
text(f"SELECT id FROM {schema.users_table} WHERE username = :u"),
@ -786,11 +762,7 @@ async def list_employees(
.all()
)
quota_by_username = _batch_quota_status(
db,
[row["username"] for row in rows],
known_roles={row["username"]: row["role"] for row in rows},
)
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]

View file

@ -22,7 +22,6 @@ from app.core.anon_session import get_or_create_anon_session_id
from app.core.config import settings
from app.core.db import get_db
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.observability.metrics import ESTIMATES, REPORTS_EXPORTED
from app.schemas.trade_in import (
AggregatedEstimate,
AnalogLot,
@ -51,7 +50,6 @@ from app.schemas.trade_in import (
TradeInEstimateInput,
)
from app.services import account_quota
from app.services import regions as regions_mod
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
from app.services.user_events import schedule_event
@ -80,10 +78,8 @@ _estimate_limiter = SlidingWindowLimiter(
# внешние тиры; пила одновременных оценок выедает пул и тормозит весь /api/v1/*.
# Образец — public/mera.py::_suggest_slots (4 слота на секундное автодополнение).
#
# 4 слота: вместе с 4 слотами suggest — 8 одновременно удерживаемых соединений.
# Пул под это заведомо шире: 5+15=20 на процесс, и потолок пула держится не
# меньше СУММЫ объявленных потолков одновременности, включая 8 фоновых догрузок
# (core/db.py + tests/test_3408_pool_ceiling.py, #3408). Ожидание слота 5с ≈ две
# 4 слота: вместе с 4 слотами suggest — 8 одновременно удерживаемых соединений
# из 15 возможных, остаток пула остаётся прочим ручкам. Ожидание слота 5с ≈ две
# длительности оценки: если за это время слот не освободился, очередь глубока и
# честный ответ — быстрый 429 с Retry-After, а не растущая очередь (очередь под
# нагрузкой — те же занятые соединения плюс таймаут у клиента; mera.py:117-127).
@ -561,13 +557,6 @@ async def estimate(
# #3082: слот возвращаем сразу после дорогой части — инкремент квоты и
# сериализация ответа ниже дёшевы и слот держать не должны.
_estimate_slots.release()
# #3471: считаем оценку "успешно посчитанной" здесь — до квоты и до 429
# ниже, потому что расчёт (дорогая часть) уже прошёл. insufficient_data —
# тоже успех расчёта: медиана не нашлась не потому что что-то сломалось, а
# потому что аналогов не было, это отдельный, а не ошибочный исход.
ESTIMATES.labels(outcome="insufficient_data" if result.insufficient_data else "ok").inc()
# #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise
# выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение
# тут: при гонке двух /estimate на used=lim-1 второй получит False.
@ -638,31 +627,6 @@ def get_estimate(
Возвращает 404 если оценка не найдена или TTL истёк.
"""
return load_estimate(db, estimate_id, x_authenticated_user=x_authenticated_user)
def load_estimate(
db: Session,
estimate_id: UUID,
*,
x_authenticated_user: str | None,
capability_granted: bool = False,
) -> AggregatedEstimate:
"""Тело GET /estimate/{id} без FastAPI-обвязки — чтобы у ВТОРОГО права
доступа был тот же самый загрузчик, а не его копия.
`capability_granted=True` вызывающая сторона уже доказала право доступа
ДРУГИМ способом, чем `X-Authenticated-User` + roles.yaml: capability-ссылка
`/r/<token>` (app/api/v1/payments.py) отдаёт оплаченный отчёт анониму,
у которого идентичности нет и не будет там правом является сам
непредсказуемый токен, сверенный по `payment_entitlements`.
Флаг ИМЕННО параметр обычной функции, а не поле запроса: у route-хендлера
`get_estimate` выше его нет, поэтому подобрать его снаружи (query/заголовком)
невозможно включить его может только код в этом процессе. Обратное
(добавить параметр в сам хендлер с default=False) сделало бы обход IDOR-
гварда #690 доступным любому клиенту через `?capability_granted=true`.
"""
row = db.execute(
text(
f"""
@ -690,8 +654,7 @@ def load_estimate(
if row is None:
raise HTTPException(status_code=404, detail="estimate not found or expired")
if not capability_granted:
_assert_estimate_access(row.created_by, x_authenticated_user)
_assert_estimate_access(row.created_by, x_authenticated_user)
# #incident-2026-08-10: строка «мертва» (median_price<=0/NULL) — посчитана
# ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825). Пробуем
@ -774,22 +737,8 @@ def load_estimate(
# когда raw payload.address начнёт персиститься — follow-up). Для ЕКБ ок; reorder
# ниже безвреден (оба source city-stripped для не-ЕКБ, оба екб для ЕКБ).
target_city = _resolve_target_city(row.address) or _resolve_target_city(row.canonical_address)
# #3051 PR-A: тот же region_code-скоуп, что и POST /estimate — гарантирует
# «регион не резолвится → DEFAULT_REGION_CODE (66)», не NULL (NULL в SQL
# обнулил бы фильтр). row.lat/row.lon персистятся с estimate-time.
target_region = (
regions_mod.region_for_point(row.lat, row.lon)
if row.lat is not None and row.lon is not None
else None
)
target_region_code = target_region.code if target_region else regions_mod.DEFAULT_REGION_CODE
dkp_raw = _fetch_dkp_corridor(
db,
address=row.address,
rooms=row.rooms,
area=area_f,
city=target_city,
region_code=target_region_code,
db, address=row.address, rooms=row.rooms, area=area_f, city=target_city
)
dkp_corridor = DkpCorridor(**dkp_raw) if dkp_raw else None
@ -801,16 +750,7 @@ def load_estimate(
recommended_price=int(imv_raw["recommended_price"]),
lower_price=int(imv_raw["lower_price"]) if imv_raw.get("lower_price") else None,
higher_price=int(imv_raw["higher_price"]) if imv_raw.get("higher_price") else None,
# #3323: `is not None` (0 — самый тонкий рынок, не «неизвестно») + thin_market
# считаем тем же порогом, что POST-путь в estimator, иначе одна и та же
# оценка при переоткрытии по ссылке / в PDF теряла флаг тонкого рынка.
market_count=(
int(imv_raw["market_count"]) if imv_raw.get("market_count") is not None else None
),
thin_market=(
imv_raw.get("market_count") is not None
and int(imv_raw["market_count"]) < settings.avito_imv_thin_market_threshold
),
market_count=int(imv_raw["market_count"]) if imv_raw.get("market_count") else None,
)
if imv_raw is not None and imv_raw.get("recommended_price")
else None
@ -1021,7 +961,6 @@ def estimate_pdf(
brand_obj = _resolve_brand(owner_brand_slug, db)
pdf_bytes = generate_trade_in_pdf(estimate, input_snapshot, brand=brand_obj)
filename = f"trade-in-{brand_obj.slug}-{estimate_id}.pdf"
REPORTS_EXPORTED.inc()
logger.info(
"PDF generated estimate_id=%s brand=%s size=%d",
estimate_id,
@ -2196,11 +2135,8 @@ def get_street_deals(
"""ДКП-сделки Росреестра по улице целевого адреса.
Open dataset Росреестра агрегирует адреса до улицы (без номера дома).
Поэтому это per-street view, не per-house. До квартир-аналогов выборку
сужает полоса площади ±area_tolerance; комнатность клиента в фильтр НЕ
входит `deals.rooms` не комнатность, а синтетика из той же площади
(#3256, см. estimator._fetch_dkp_corridor). `rooms` остаётся параметром
ручки: он описывает запрос и попадает в лог, но не в WHERE.
Поэтому это per-street view, не per-house. Фильтр по rooms + area
сужает выборку до квартир-аналогов.
После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована
в import-rosreestr.sh).
@ -2210,7 +2146,6 @@ def get_street_deals(
_percentile,
_resolve_target_city,
extract_street_name,
region_code_for_address,
)
now = datetime.now(tz=UTC)
@ -2254,17 +2189,6 @@ def get_street_deals(
target_city = _resolve_target_city(address)
city_filter = "AND LOWER(city) = CAST(:target_city AS text)" if target_city else ""
# #dkp-corridor-scope (2026-09-12): region_code — обязательный фильтр, в отличие
# от city_filter выше (который применяется, только если _resolve_target_city
# узнал город обл.66). extract_street_name после фикса московского порядка
# ("Название улица") стал возвращать имя улицы и для Москвы, а
# _resolve_target_city знает ТОЛЬКО города обл.66 → для Москвы target_city=None,
# city_filter пуст, и без region_code одноимённая улица другого региона
# подмешалась бы в выборку (прод-замер: "Ясная" — 168 сделок в 66 и 80 в 77,
# "Советская" — 1202 и 17). region_code заполнен у всех deals (66→108 623,
# 77→212 937, NULL нет) — фильтр не отрезает ни одной существующей строки.
region_code = region_code_for_address(address)
rows = (
db.execute(
text(
@ -2276,11 +2200,7 @@ def get_street_deals(
AND address ILIKE :street_pattern
AND address ~* :street_regex
{city_filter}
AND region_code = CAST(:region_code AS integer)
-- #3256: фильтра по rooms нет — deals.rooms синтезирована из площади
-- (тот же CASE 30/44/62/85, что area_bucket), т.е. это был второй
-- ступенчатый фильтр по площади поверх полосы ±15% ниже. Развёрнуто
-- в комментарии estimator._fetch_dkp_corridor.
AND rooms = CAST(:rooms AS integer)
AND area_m2 BETWEEN :area_min AND :area_max
AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
AND price_rub > 0
@ -2291,7 +2211,7 @@ def get_street_deals(
"street_pattern": "%" + street_name + "%",
"street_regex": r"\m" + street_name + r"\M",
"target_city": target_city.lower() if target_city else None,
"region_code": region_code,
"rooms": rooms,
"area_min": area_min,
"area_max": area_max,
"period_months": period_months,
@ -2302,16 +2222,12 @@ def get_street_deals(
)
if not rows:
# #3256: лог называет ТОТ ключ, которым искали. Комнатность клиента в
# выборку не входит (deals.rooms — синтетика из площади), поэтому она
# печатается как контекст запроса, а не как параметр фильтра.
logger.info(
"street-deals: no rows found street=%r area=%.1f±%.0f%% "
"(ключ по комнатам не применяется, #3256; комнатность клиента=%d)",
"street-deals: no rows found street=%r rooms=%d area=%.1f±%.0f%%",
street_name,
rooms,
area_m2,
area_tolerance * 100,
rooms,
)
return StreetDealsResponse(
street=street_name,
@ -2339,14 +2255,12 @@ def get_street_deals(
top10 = [_deal_to_analog(dict(r)) for r in rows[:10]]
logger.info(
"street-deals: street=%r area=%.1f±%.0f%% count=%d median_ppm2=%.0f "
"(ключ по комнатам не применяется, #3256; комнатность клиента=%d)",
"street-deals: street=%r rooms=%d area=%.1f count=%d median_ppm2=%.0f",
street_name,
rooms,
area_m2,
area_tolerance * 100,
count,
median_ppm2,
rooms,
)
return StreetDealsResponse(
@ -2511,12 +2425,8 @@ def get_sales_vs_listings(
"""Pairs (ДКП-сделка, listing) для улицы целевого адреса (PR K / #564).
Для каждой ДКП-сделки Росреестра в окне `period_months` пытаемся найти
matching listing на той же улице с близкой area_m2 / listing_date в окне
[deal_date - window_days, deal_date + 30d grace]. Комнатность в ключе стоит
ТОЛЬКО на стороне объявлений (`l.rooms = p_rooms`, комнатность клиента): у
сделок Росреестра `rooms` синтетика из площади, предикат по ней снят
миграцией 300 (#3451/#3256). Поэтому `deal_rooms` в паре может не совпадать
с запрошенным `rooms`.
matching listing на той же улице с такими же rooms / близкой area_m2 /
listing_date в окне [deal_date - window_days, deal_date + 30d grace].
Возвращаем LEFT JOIN: сделки без listing match сохраняются (listing_* = None),
чтобы вычислить linkage_rate.
@ -2526,12 +2436,7 @@ def get_sales_vs_listings(
Per-street view: Росреестр open dataset агрегирует адреса до улицы.
"""
from app.services.estimator import (
_percentile,
_resolve_target_city,
extract_street_name,
region_code_for_address,
)
from app.services.estimator import _percentile, _resolve_target_city, extract_street_name
def _empty(reason_street: str | None = None) -> SalesVsListingsResponse:
return SalesVsListingsResponse(
@ -2561,36 +2466,16 @@ def get_sales_vs_listings(
# известная H1) → фильтр не применяется на TVF-стороне (см. миграцию 205).
target_city = _resolve_target_city(address)
# #dkp-corridor-scope (2026-09-12): region_code — фильтр ДОПОЛНИТЕЛЬНО к
# target_city выше, нужен по той же причине, что и в /street-deals: для
# Москвы _resolve_target_city (словарь городов ТОЛЬКО обл.66) возвращает
# None → target_city-фильтр внутри TVF (миграция 205) не срабатывает ни
# для deals, ни для listings, и одноимённая улица другого региона
# подмешивается в пары (см. estimator.region_code_for_address).
#
# TVF street_sales_vs_listings() (миграция 205) параметра региона не
# знает — сама TVF не трогается (это отдельная миграция, вне текущего
# фикса), фильтр применён СНАРУЖИ: оборачиваем вызов в JOIN на deals по
# deal_id и фильтруем region_code ТОЛЬКО на стороне сделок. Сторона
# listings внутри TVF остаётся НЕ отфильтрованной по региону — это
# известный узкий компромисс, не побочный эффект: у listings нет
# общего для всех источников поля региона (city заполнен частично, см.
# комментарий 205 выше), а сам JOIN внутри TVF уже требует совпадения
# street_pattern + rooms + area + date proximity, что резко сужает шанс
# чужого региона на listing-стороне. Полный фикс (передать region_code
# внутрь TVF седьмым/восьмым параметром) — отдельная миграция.
region_code = region_code_for_address(address)
rows = (
db.execute(
text(
"""
SELECT
sv.deal_id, sv.deal_date, sv.deal_price_rub, sv.deal_price_per_m2,
sv.deal_area_m2, sv.deal_rooms, sv.deal_floor, sv.deal_address,
sv.listing_id, sv.listing_source, sv.listing_source_url,
sv.listing_date, sv.listing_price_rub, sv.listing_price_per_m2,
sv.listing_area_m2, sv.days_listing_to_deal, sv.discount_pct
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 street_sales_vs_listings(
CAST(:street_pattern AS text),
CAST(:area_m2 AS numeric),
@ -2599,9 +2484,7 @@ def get_sales_vs_listings(
CAST(:area_tolerance AS numeric),
CAST(:period_months AS integer),
CAST(:target_city AS text)
) sv
JOIN deals d ON d.id = sv.deal_id
WHERE d.region_code = CAST(:region_code AS integer)
)
"""
),
{
@ -2612,7 +2495,6 @@ def get_sales_vs_listings(
"area_tolerance": area_tolerance,
"period_months": period_months,
"target_city": target_city,
"region_code": region_code,
},
)
.mappings()
@ -2844,206 +2726,15 @@ COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Ураль
COVERAGE_GREEN_MIN_N = 8
COVERAGE_YELLOW_MIN_N = 12
# Москва добавлена 10.09.2026 — ОТДЕЛЬНОЙ константой, а не в
# COVERAGE_YELLOW_CITIES. Причина структурная: пара GREEN/YELLOW_CITIES выше —
# это контракт со свердловским дропдауном на сайте (city-registry.ts, сверяется
# тестом test_offered_cities_match_coverage_cities), а Москвы в том дропдауне
# нет и быть не должно — там выбирают город Свердловской области. Порог при
# этом нужен, и живёт он здесь, в том же _COVERAGE_CITY_THRESHOLDS.
#
# Тир — ЖЁЛТЫЙ (12), не зелёный. Замер на проде 10.09.2026: 200 случайных
# московских объявлений, когорта считалась тем же предикатом, что сама проба
# (см. SQL в coverage_probe ниже) — медиана когорты 14, p25 = 8, доля точек с
# когортой >= 12 равна 0.57, с когортой >= 8 равна 0.77. Контроли той же
# метрикой: Екатеринбург (зелёный, порог 8) — медиана 37, доля >= 12 равна
# 0.865; Нижний Тагил (жёлтый, порог 12) — медиана 11, доля >= 12 равна 0.473.
# То есть по плотности когорты Москва втрое реже зелёного эталона и стоит
# вплотную к жёлтому: с порогом 8 «ok» получали бы 77% адресов, но за этим «ok»
# стояла бы заметно менее надёжная оценка. Плюс модельная сторона для Москвы
# ещё не откалибрована (время правится свердловским рядом СберИндекса, полоса
# цен одна на весь город), так что 43% адресов честнее показать как thin.
COVERAGE_MOSCOW_MIN_N = COVERAGE_YELLOW_MIN_N
# Ключ центроида и display-имя города — РАЗНЫЕ вещи, и здесь это видно яснее
# всего: у Москвы 67 центроидов (сетка, см. `_MOSCOW_GRID_DEG` ниже), а наружу,
# в поле `city` ответа, обязано уходить одно имя «Москва» и один порог. Иначе
# повторится ровно тот баг, что был с Берёзовским: человек получает в ответе
# название района вместо своего города.
COVERAGE_MOSCOW_DISPLAY = "Москва"
# СЕТКА МОСКОВСКИХ ЦЕНТРОИДОВ (11.09.2026).
#
# Откуда координаты. Это не справочник районов и не ручной подбор: точки
# получены кластеризацией НАШЕГО ЖЕ корпуса объявлений средствами PostGIS —
# `ST_ClusterKMeans(ST_Transform(geom, 32637), K) OVER ()` по 35 552 активным
# вторичным объявлениям region_code=77 (geom заполнена у 100%), центроид
# кластера = `ST_Centroid(ST_Collect(...))` в UTM 37N, округление до 4 знаков.
# Поэтому точки стоят там, где реально живут объявления, а не там, где на карте
# нарисован центр района. Подписи в ключах — человеческие ярлыки для читаемости
# (ближайший район/поселение), на резолв они не влияют никак.
#
# Почему точек 67. Кластеризация дала K=56: порог качества был «меньше 2%
# московских объявлений уходит к подмосковному центроиду», свип по K на тех же
# данных — 24 → 6.06%, 30 → 4.02%, 36 → 3.67%, 42 → 3.46%, 48 → 2.23%,
# 54 → 2.12%, 56 → 1.79%, 60 → 1.64%, 75 → 0.84%, 90 → 0.45%, и K=56 — первое
# значение, берущее 2%. Из этих 56 один кластер выброшен как артефакт геокода,
# и 12 точек добавлены вручную по замеру 11.09.2026 (жилые районы, целиком
# проигрывавшие конкурс подмосковной точке): 56 1 + 12 = 67.
#
# Выброшенный призрак. «Москва (Восточный, эксклав)» 56.0087/37.7960: n=1,
# отрыв от остальной сетки 17.9 км. Адрес объявления — «ВАО, р-н Восточный, 4»,
# а настоящий район Восточный лежит на 55.81/37.85: геокодер промахнулся
# на 22 км. Точка стояла в 3.06 км от Пушкино и раздавала ему имя «Москва».
# Взамен добавлена «Москва (Восточный)» по фактическому центру девяти
# объявлений района. Проверены ВСЕ кластеры с n<60 либо изоляцией >12 км
# (9 штук, адреса подняты с прода) — артефакт ровно один. Второй кандидат,
# «Москва (юг ТАО)» 55.2643/37.1033 (n=1, адрес без улицы и дома =
# settlement-fallback геокодера), ОСТАВЛЕН намеренно: точка стоит внутри ТАО,
# при радиусе 8 км не достаёт ни до одного города области, а снос стоил бы
# покрытия анклава.
#
# Где проходит граница с областью. Двумя механизмами сразу, и нужны оба.
# (1) Конкурс ближайшего центроида: 32 подмосковные точки
# (`_COVERAGE_NEGATIVE_CENTROIDS_DEG` ниже) участвуют в поиске ближайшего, но
# порога не имеют, поэтому адрес, для которого выиграл подмосковный центр,
# получает «город не определён». (2) Радиус, который теперь СВОЙСТВО ЦЕНТРОИДА
# (`_CENTROID_RADIUS_KM` ниже), а не одна константа на всю страну: у московских
# точек он 8 км, у свердловских остались прежние 25.
#
# Почему радиус пришлось сделать свойством точки. 25 км были рассчитаны на ОДИН
# центроид в центре города — круг вокруг Кремля. У плотной сетки круги
# СКЛАДЫВАЮТСЯ, и объединение 67 кругов по 25 км — это уже не «Москва с
# запасом», а пятно, накрывающее половину области. Наро-Фоминск, Кубинка и Чехов
# резолвились как «Москва» именно так: до ближайшей точки сетки им 9.83, 17.18
# и 22.25 км (расстояния посчитаны _haversine_km этого же модуля, сфера
# R=6371 — замер на эллипсоиде PostGIS даёт на 0.1-0.5 км больше), то есть
# внутрь общего круга они попадали, а собственной
# отрицательной точки у них нет. Прежнее утверждение в этом месте — будто
# радиус 25 км «больше не ограничение, запас четырёхкратный» — было ложным:
# ограничение не исчезло, оно поменяло знак. Раньше радиус резал покрытие
# изнутри (адрес дальше 25 км от Кремля терял город), теперь протекал наружу.
#
# Откуда 8 км. Свип по радиусу на итоговой сетке, тот же корпус (35 552
# активных вторичных объявления, region_code=77): R = 5, 6, 7 и 8 дают
# ОДИНАКОВЫЙ результат — 0.00% объявлений вне радиуса, 29/29 московских
# контрольных адресов резолвятся в «Москву», 32/32 областных дают «не
# определён». Верхний край плато жёсткий: при R=10 внутрь входит Наро-Фоминск
# (9.83 км — город БЕЗ отрицательной точки), при R=12 — ещё Голицыно и
# Лыткарино. Берём верхний край: 8 км — это 3.5 км запаса на адреса вне
# корпуса (худший московский контроль — Рублёво, 4.44 км до сетки) и всё ещё
# на 1.83 км ниже потолка 9.83. Брать меньше нечем оправдать: по корпусу
# выигрыша нет, а запас на дырки в сетке тает.
#
# Остаточная цена. 48 московских объявлений из 35 552 = 0.14% (на прежней
# конфигурации — 636 = 1.79%) всё ещё выигрываются подмосковным центром:
# Молжаниновский 15, Реутов 8, Подольск 7, Долгопрудный 7, Мытищи 7,
# Красногорск 3, Королёв 1. Крупнейший кусок снимается 13-й точкой
# «Молжаниновский» 55.9480/37.3478 (0.14% → 0.09%) — она в замере посчитана,
# но в список не внесена, чтобы конфигурация здесь совпадала с той, на которой
# прогнаны оба контрольных набора. Одно объявление не берётся ничем:
# 56.0087/37.7960, тот самый кривой геокод, — лечится перегеокодированием
# строки адреса, а не сеткой.
#
# Не перегенерируйте кластеризацию перед правкой: ST_ClusterKMeans без сида
# недетерминирован, разброс между прогонами ±0.2 п.п. Список ниже перепроверен
# отдельным прогоном именно в том виде, в каком он здесь записан.
_MOSCOW_GRID_DEG: dict[str, tuple[float, float]] = {
"Москва (Рогово, ТАО)": (55.2146, 37.0695),
"Москва (юг ТАО)": (55.2643, 37.1033),
"Москва (Вороновское, ТАО)": (55.3159, 37.1759),
"Москва (Кленовское, ТАО)": (55.3343, 37.3299),
"Москва (ТАО у Подольска)": (55.3569, 37.4010),
"Москва (Шишкин Лес, ТАО)": (55.4202, 37.1687),
"Москва (Щапово, ТАО)": (55.4212, 37.3979),
"Москва (Новофёдоровское, ТАО)": (55.4307, 36.8675),
"Москва (Троицк, юг)": (55.4568, 37.2867),
"Москва (Остафьево, НАО)": (55.4760, 37.5249),
"Москва (Киевский, ТАО)": (55.4884, 36.9199),
"Москва (Троицк)": (55.4953, 37.3150),
"Москва (Щербинка, НАО)": (55.5046, 37.5578),
"Москва (Ватутинки, НАО)": (55.5187, 37.3605),
"Москва (Первомайское, ТАО)": (55.5405, 37.1599),
"Москва (Коммунарка, НАО)": (55.5474, 37.4927),
"Москва (Филимонковское, НАО)": (55.5568, 37.3188),
"Москва (Северное Бутово)": (55.5615, 37.5697),
"Москва (Саларьево, НАО)": (55.5862, 37.4568),
"Москва (Марушкино, НАО)": (55.5933, 37.1658),
"Москва (Бирюлёво Западное)": (55.5945, 37.6447),
"Москва (Внуковское, НАО)": (55.6030, 37.3796),
"Москва (Ясенево)": (55.6138, 37.5535),
"Москва (Зябликово)": (55.6225, 37.7303),
"Москва (Ново-Переделкино)": (55.6317, 37.3215),
"Москва (Нагорный)": (55.6425, 37.6245),
"Москва (Солнцево)": (55.6455, 37.3966),
"Москва (Тропарёво-Никулино)": (55.6576, 37.4684),
"Москва (Люблино)": (55.6696, 37.7461),
"Москва (Обручевский)": (55.6711, 37.5432),
"Москва (Даниловский)": (55.6921, 37.6397),
"Москва (Некрасовка)": (55.7033, 37.9093),
"Москва (Раменки)": (55.7048, 37.4870),
"Москва (Кузьминки)": (55.7143, 37.7723),
"Москва (Кунцево)": (55.7281, 37.4239),
"Москва (Якиманка)": (55.7290, 37.6055),
"Москва (Таганский)": (55.7370, 37.6845),
"Москва (Пресненский)": (55.7486, 37.5282),
"Москва (Новогиреево)": (55.7545, 37.8107),
"Москва (Тверской)": (55.7655, 37.6038),
"Москва (Хорошёво-Мнёвники)": (55.7657, 37.4668),
"Москва (Преображенское)": (55.7886, 37.7045),
"Москва (Хорошёвский)": (55.7951, 37.5373),
"Москва (Строгино)": (55.7982, 37.3859),
"Москва (Северное Измайлово)": (55.8071, 37.7881),
"Москва (Останкинский)": (55.8086, 37.6174),
"Москва (Покровское-Стрешнево)": (55.8203, 37.4470),
"Москва (Митино)": (55.8467, 37.3877),
"Москва (Отрадное)": (55.8558, 37.5796),
"Москва (Ховрино)": (55.8581, 37.4924),
"Москва (Бабушкинский)": (55.8633, 37.6740),
"Москва (Лианозово)": (55.8944, 37.5622),
"Москва (Куркино — Молжаниново)": (55.9169, 37.3837),
"Москва (Зеленоград, юг)": (55.9793, 37.1620),
"Москва (Зеленоград, север)": (55.9958, 37.2147),
# Кластер «Москва (Восточный, эксклав)» (56.0087, 37.7960) ВЫБРОШЕН
# 11.09.2026 как артефакт геокода — см. «Выброшенный призрак» выше.
#
# Ниже — 12 точек, добавленных тем же замером вручную: жилые районы
# Москвы, которые целиком проигрывали конкурс ближайшего подмосковной
# точке. В комментарии — сколько объявлений возвращает точка и кому они
# уходили (прод-корпус, is_active AND region_code=77).
"Москва (Восточный)": (55.8194, 37.8740), # +9, уходили к Балашихе
"Москва (Левобережный)": (55.8713, 37.4592), # +91, к Химкам
"Москва (Орехово-Борисово Южное)": (55.5986, 37.7233), # +80, к Развилке
"Москва (Северный)": (55.9278, 37.5411), # +79, к Долгопрудному
"Москва (Митино-запад)": (55.8410, 37.3507), # +78, к Красногорску
"Москва (Ивановское)": (55.7717, 37.8326), # +65, к Реутову
"Москва (Жулебино)": (55.6885, 37.8521), # +62, к Люберцам
"Москва (Можайский-запад)": (55.7008, 37.3936), # +50, к Немчиновке
"Москва (Новокосино)": (55.7385, 37.8576), # +44, к Реутову
"Москва (Капотня)": (55.6341, 37.7981), # +29, к Дзержинскому
"Москва (Куркино)": (55.8895, 37.3980), # +20, к Химкам
"Москва (Некрасовка-восток)": (55.6838, 37.9216), # +13, к Люберцам
}
# Все ключи сетки — один display «Москва» и один порог. Кортеж выводится из
# самого словаря, чтобы список ключей физически не мог разойтись с сеткой.
COVERAGE_MOSCOW_CENTROID_KEYS = tuple(_MOSCOW_GRID_DEG)
def _fold_city(name: str) -> str:
"""ёЁ→еЕ + casefold — та же normalization-идиома, что для адресов (см. #1774)."""
return name.strip().translate(str.maketrans("ёЁ", "ее")).casefold()
# Ключ — folded ИМЯ ЦЕНТРОИДА, значение — (display-имя города, порог). Для
# свердловских городов ключ и display совпадают, для московских центроидов —
# нет (все 67 ключей сетки дают «Москва»).
_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
**{_fold_city(c): (c, COVERAGE_GREEN_MIN_N) for c in COVERAGE_GREEN_CITIES},
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES},
**{
_fold_city(k): (COVERAGE_MOSCOW_DISPLAY, COVERAGE_MOSCOW_MIN_N)
for k in COVERAGE_MOSCOW_CENTROID_KEYS
},
}
# Повторная проверка ручки #2894 (2026-08): город раньше резолвился модой
@ -3066,15 +2757,7 @@ _COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
# статичного справочника из 8 географических центров населённых пунктов —
# оверинжиниринг; координаты (WGS84, общедоступные центры НП) живут здесь же,
# рядом с порогами, которые они резолвят.
# Радиусы матчинга. Радиус — СВОЙСТВО ЦЕНТРОИДА (таблица `_CENTROID_RADIUS_KM`
# ниже, применяется в `_resolve_coverage_city`), потому что 25 км осмысленны
# ровно для конфигурации «один центроид на город», а у Москвы центроидов 67 и
# их круги складываются — разбор в комментарии над `_MOSCOW_GRID_DEG`.
# Свердловские точки радиуса не меняли: у каждой из них один центр на город,
# складываться нечему, а замер, которым выбраны эти 25 км, к сетке отношения
# не имеет.
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дефолт: один центроид на город, дальше — not_covered
COVERAGE_MOSCOW_MATCH_RADIUS_KM = 8.0 # сетка из 67 точек; верхний край плато 5..8, потолок 9.83
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дальше — город не определён (not_covered)
_CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Екатеринбург": (56.8389, 60.6057),
@ -3086,99 +2769,8 @@ _CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Первоуральск": (56.9083, 59.9483),
"Ревда": (56.7986, 59.9298),
"Серов": (59.6047, 60.5772),
# Москва — сетка из 67 центроидов, одно display-имя и один порог на все.
# Как сетка получена, почему точек именно столько, где проходит граница
# с областью и какова остаточная цена — см. большой комментарий над
# `_MOSCOW_GRID_DEG`. Здесь только подстановка: координаты и ключи живут
# в одном месте, чтобы их нельзя было рассинхронизировать.
**_MOSCOW_GRID_DEG,
}
# ОТРИЦАТЕЛЬНЫЕ центроиды: подмосковные города и посёлки, попадающие внутрь
# радиуса московских точек. Список 11.09.2026 перепроверен на итоговой
# конфигурации (67 точек сетки, радиус 8 км): все 32 точки живы — ни одну
# не перехватывает московский центроид, в самой точке выигрывает она сама,
# и ответ остаётся «город не определён». Внутрь 8 км от какой-нибудь точки
# сетки попадают 13 из них (Химки, Реутов, Люберцы, Котельники, Дзержинский,
# Красногорск, Долгопрудный, Мытищи, Видное, Одинцово, Подольск, Апрелевка,
# Селятино) — каждая выигрывает собственным центром. Ближайшая пара после
# добавления новых точек: «Митино-запад» в 1.6 км от Красногорска. Участвуют
# в конкурсе ближайшего центроида наравне с городами из
# `_CITY_CENTROIDS_DEG`, но НАМЕРЕННО отсутствуют в `_COVERAGE_CITY_THRESHOLDS`:
# центроид без порога = «город не определён», тот же ответ, что для точки
# в чистом поле. Обоснование и цена — в комментарии «ГРАНИЦА С ОБЛАСТЬЮ» выше.
#
# Координаты — WGS84, общедоступные центры НП; список закрытый и расширяется
# только по замеру (точка вне радиуса всех московских бесполезна, точка внутри
# Москвы сожгла бы ещё часть покрытия). Ключи здесь и в `_CITY_CENTROIDS_DEG`
# не должны пересекаться — это проверяется тестом.
#
# Города области, которые НЕ попадают ни в один московский радиус, здесь и не
# нужны: Наро-Фоминск (9.83 км до сетки), Кубинка (17.18), Чехов (22.25) отдают
# «город не определён» просто потому, что вне 8 км. При прежних 25 км все трое
# резолвились как «Москва» — это и был дефект, ради которого радиус переехал
# в свойство центроида.
_COVERAGE_NEGATIVE_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Андреевка": (55.9772, 37.1100),
"Менделеево": (56.0333, 37.2333),
"Сходня": (55.9500, 37.3000),
"Чёрная Грязь": (55.9667, 37.3167),
"Лунёво": (56.0000, 37.3500),
"Поварово": (56.0667, 37.0667),
"Дедовск": (55.8672, 37.1200),
"Нахабино": (55.8500, 37.1833),
"Реутов": (55.7614, 37.8564),
"Подольск": (55.4312, 37.5450),
"Апрелевка": (55.5500, 37.0700),
"Немчиновка": (55.7050, 37.3450),
"Химки": (55.8894, 37.4450),
"Одинцово": (55.6789, 37.2639),
"Лобня": (56.0100, 37.4750),
"Мытищи": (55.9116, 37.7308),
"Котельники": (55.6553, 37.8619),
"Красногорск": (55.8317, 37.3300),
"Люберцы": (55.6767, 37.8931),
"Дзержинский": (55.6294, 37.8500),
"Развилка": (55.5842, 37.7392),
"Климовск": (55.3667, 37.5333),
"Балашиха": (55.7969, 37.9386),
"Истра": (55.9167, 36.8667),
"Долгопрудный": (55.9386, 37.5100),
"Королёв": (55.9142, 37.8256),
"Видное": (55.5519, 37.7133),
"Селятино": (55.5081, 36.9825),
"Томилино": (55.6528, 37.9472),
"Некрасовский": (56.0500, 37.5500),
"Барвиха": (55.7333, 37.2333),
"Железнодорожный": (55.7444, 38.0128),
}
# Радиус матчинга по центроиду: ключ — folded имя центроида, значение — км.
# Чего здесь нет — берёт `COVERAGE_CITY_MATCH_RADIUS_KM` (25 км), то есть все
# свердловские точки. Таблица выводится из самих словарей координат, чтобы
# радиус физически не мог разойтись со списком точек.
#
# Почему у отрицательных точек радиус бесконечный. Отрицательный центроид
# ничего не «накрывает»: он влияет на ответ только там, где он ближе всех
# (в своей ячейке Вороного), а выигравший центроид без порога даёт «город не
# определён» на ЛЮБОМ расстоянии — обе ветки `_resolve_coverage_city` (вышли
# за радиус / нет порога) возвращают один и тот же ответ. Поэтому конечное
# число здесь было бы декорацией: поведение оно не меняет, но читалось бы как
# «дальше отрицательная точка перестаёт действовать», чего не происходит.
# math.inf говорит правду — границу отрицательной точки задаёт геометрия
# соседей, а не радиус, и попытка «подрезать» её числом ничего не вернёт
# Москве, зато спрячет этот факт от следующего читателя.
_CENTROID_RADIUS_KM: dict[str, float] = {
**{_fold_city(k): COVERAGE_MOSCOW_MATCH_RADIUS_KM for k in COVERAGE_MOSCOW_CENTROID_KEYS},
**{_fold_city(k): math.inf for k in _COVERAGE_NEGATIVE_CENTROIDS_DEG},
}
def _centroid_radius_km(centroid_key: str) -> float:
"""Радиус матчинга КОНКРЕТНОГО центроида в км (см. `_CENTROID_RADIUS_KM`)."""
return _CENTROID_RADIUS_KM.get(_fold_city(centroid_key), COVERAGE_CITY_MATCH_RADIUS_KM)
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Расстояние по большому кругу (км), радиус Земли 6371 км."""
@ -3194,49 +2786,23 @@ def _resolve_coverage_city(lat: float, lon: float) -> tuple[str, int, bool]:
"""Резолвит (display_city, threshold, is_supported) для пробы покрытия — ПО КООРДИНАТАМ.
Город = ближайший центроид из `_CITY_CENTROIDS_DEG`, если расстояние до него
в пределах СОБСТВЕННОГО радиуса этого центроида (`_centroid_radius_km`:
25 км у свердловских городов, 8 км у точек московской сетки почему так,
см. комментарий над `_MOSCOW_GRID_DEG`); иначе город не определён.
Детерминированно
< `COVERAGE_CITY_MATCH_RADIUS_KM`; иначе город не определён. Детерминированно
и без участия клиента см. комментарий над `_CITY_CENTROIDS_DEG` про то,
почему `listings.city` (мода когорты) и `city_hint` (клиентский вход) сюда
больше НЕ допускаются в качестве источника истины.
В конкурсе ближайшего участвуют И отрицательные центроиды
(`_COVERAGE_NEGATIVE_CENTROIDS_DEG`) подмосковные точки без порога.
Выигравший центроид без порога означает «город не определён»: так граница
Москвы и области проходит по конкурсу центров, а не по кругу радиуса
(см. «ГРАНИЦА С ОБЛАСТЬЮ» над `_CITY_CENTROIDS_DEG`).
Возвращается display-имя из `_COVERAGE_CITY_THRESHOLDS`, а НЕ ключ
центроида: у Москвы 67 центроидов на один город, и наружу все они обязаны
отдавать «Москва» с одним порогом (см. `COVERAGE_MOSCOW_CENTROID_KEYS`).
"""
nearest_city: str | None = None
nearest_km = math.inf
for city, (clat, clon) in (
*_CITY_CENTROIDS_DEG.items(),
*_COVERAGE_NEGATIVE_CENTROIDS_DEG.items(),
):
for city, (clat, clon) in _CITY_CENTROIDS_DEG.items():
distance_km = _haversine_km(lat, lon, clat, clon)
if distance_km < nearest_km:
nearest_km = distance_km
nearest_city = city
# Радиус берётся у ПОБЕДИТЕЛЯ конкурса, а не общий: круги плотной сетки
# складываются, и один глобальный радиус либо режет Свердловскую область,
# либо протекает из Москвы в область (см. `_CENTROID_RADIUS_KM`).
if nearest_city is None or nearest_km > _centroid_radius_km(nearest_city):
if nearest_city is None or nearest_km > COVERAGE_CITY_MATCH_RADIUS_KM:
return "", 0, False
# .get(), а не индексирование: ближайшим мог оказаться отрицательный
# центроид (или новый центроид, для которого забыли завести порог) — это
# «город не определён», а не KeyError и 500 на живом адресе.
entry = _COVERAGE_CITY_THRESHOLDS.get(_fold_city(nearest_city))
if entry is None:
return "", 0, False
display, threshold = entry
display, threshold = _COVERAGE_CITY_THRESHOLDS[_fold_city(nearest_city)]
return display, threshold, True

View file

@ -8,11 +8,6 @@ from repo root. We deliberately do NOT share code between repos via
When updating one copy, update the other.
РАСХОЖДЕНИЕ С ЗЕРКАЛОМ (#3316, намеренное — не «синхронизировать» обратно):
здесь `get_role` резолвит роль СНАЧАЛА из реестра людей (`tradein_users.role` /
`auth.users.role`), и только потом из YAML. У основного бэкенда реестра нет,
там копия остаётся YAML-only.
Caddy gates the whole site with basic_auth (см. `caddy/users.caddy.snippet`)
и пропускает в backend заголовок `X-Authenticated-User: <username>` через
`header_up X-Authenticated-User {http.auth.user.id}` в каждом reverse_proxy.
@ -30,15 +25,13 @@ import logging
import re
from functools import lru_cache
from pathlib import Path
from typing import Literal, TypedDict, cast
from typing import Literal, TypedDict
import yaml
logger = logging.getLogger(__name__)
# legacy roles.yaml-роли + роли реестра ('admin'|'manager'|'employee', CHECK
# tradein м.192 / auth м.004). Оба набора приходят из одного `get_role` (#3316).
Role = Literal["admin", "pilot", "analyst", "expired", "manager", "employee"]
Role = Literal["admin", "pilot", "analyst", "expired"]
class UserScope(TypedDict):
@ -162,74 +155,8 @@ def _load_roles_config() -> dict:
# ---------------------------------------------------------------------------
def yaml_role(username: str) -> Role | None:
"""Роль из roles.yaml (без похода в реестр) или None, если юзера там нет.
Нужна там, где спрашивают именно про legacy-файл, а не про эффективную роль:
`team.create_employee` (#3316) не даёт занять имя, за которым в YAML уже
числятся права.
"""
users: dict[str, Role] = _load_roles_config()["users"]
return users.get(username)
def _registry_role(username: str) -> str | None:
"""Роль из реестра людей (`tradein_users.role` / `auth.users.role`) или None.
None означает «реестр про этого юзера ничего не сказал»: строки нет, роль
пустая, либо реестр вообще недоступен. Во всех трёх случаях решение
остаётся за roles.yaml падение БД не имеет права выключить legacy-вход.
Осознанный компромисс (#3316 review): последняя ветка — недоступный
реестр на время сбоя ВОЗВРАЩАЕТ авторитетность roles.yaml, то есть ровно
то состояние, которое этот фикс и лечит. Сегодня это безопасно: коллизий
имён между реестром и YAML на проде нет, а новые закрыты 409-гвардом в
`team.create_employee`. Если коллизия всё же появится (ручной INSERT в
реестр, расширение roles.yaml) сбой БД станет окном эскалации, и тогда
эту ветку надо менять на fail-closed (отказ вместо YAML-роли), а не
дописывать проверки у вызывающих.
Имя таблицы берётся из фиксированного словаря `identity_schema()`, значение
едет bind-параметром: снаружи в SQL не попадает ничего.
"""
try:
from sqlalchemy import text
from app.services.identity_store import identity_schema, identity_session
schema = identity_schema()
with identity_session() as db:
row = db.execute(
text(f"SELECT role FROM {schema.users_table} WHERE username = :username"),
{"username": username},
).fetchone()
except Exception:
logger.exception(
"registry role lookup failed for %r — fallback to roles.yaml",
username,
)
return None
if row is None or not row.role:
return None
return str(row.role)
def get_role(username: str) -> Role:
"""Эффективная роль *username*: реестр (БД) первый, roles.yaml — fallback.
Raises KeyError, если юзера нет ни там, ни там.
#3316: раньше роль резолвилась ТОЛЬКО из roles.yaml, при том что люди
заводятся в БД (`tradein_users`) два дефекта разом. Вверх: сотрудник,
чьё имя совпало с YAML-админом, получал admin (IDOR по чужим оценкам +
безлимит квоты). Вниз: сотрудник, которого в YAML нет, получал KeyError
403 на СОБСТВЕННУЮ оценку. Единственный источник истины теперь один, и он
здесь вызывающие (rbac, trade_in, team, account_quota) не меняются.
"""
db_role = _registry_role(username)
if db_role is not None:
return cast(Role, db_role)
"""Return the role for *username* or raise KeyError if unknown."""
config = _load_roles_config()
users: dict[str, Role] = config["users"]
if username not in users:
@ -290,22 +217,13 @@ def get_user_scope(username: str) -> UserScope:
"""
config = _load_roles_config()
role = get_role(username)
role_def = config["roles"].get(role)
if role_def is None:
# Роль реестра (employee/manager) — её scope живёт в DB_ROLE_PATHS, а не
# в roles.yaml (#3316: get_role теперь может вернуть и такую роль).
from app.services.auth_session import get_db_role_scope
allowed_paths, deny_paths = get_db_role_scope(role)
else:
allowed_paths = list(role_def.get("paths", []) or [])
deny_paths = list(role_def.get("deny", []) or [])
role_def = config["roles"][role]
display_name, org, email = get_profile_for_user(username)
return UserScope(
username=username,
role=role,
allowed_paths=allowed_paths,
deny_paths=deny_paths,
allowed_paths=list(role_def.get("paths", []) or []),
deny_paths=list(role_def.get("deny", []) or []),
brand=get_brand_for_user(username),
display_name=display_name,
org=org,

View file

@ -47,7 +47,6 @@ from sqlalchemy.exc import ArgumentError
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings
from app.core.db import DB_CONNECT_ARGS
class AuthDatabaseNotConfiguredError(RuntimeError):
@ -91,30 +90,7 @@ def _build() -> tuple[Engine, sessionmaker[Session]]:
if not dsn:
raise AuthDatabaseNotConfiguredError(_NOT_CONFIGURED_MSG)
try:
engine = create_engine(
dsn,
pool_pre_ping=True,
future=True,
# #3194: SQLAlchemy печатает ВСЕ bind-параметры в тексте StatementError —
# через них в GlitchTip уезжали ключ шифрования кук и сами куки
# (pgp_sym_encrypt(:cookies_json, :key)). Флаг на УРОВНЕ ДВИЖКА кроет все
# сайты вызова разом, включая будущие.
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
# и сырые psycopg-подключения мимо движков — это отдельный класс.
hide_parameters=True,
# #3463. Те же потолки, что у продуктового движка, — ОДНОЙ константой на оба:
# потолок на одном движке и мина на втором это не починка, а половина.
# Этот движок живёт на ГОРЯЧЕМ пути: `core/rbac.py` резолвит session-cookie
# в middleware, синхронно на event loop'е, на КАЖДОМ запросе с cookie
# (на проде IDENTITY_STORE=auth во всех трёх сервисах образа — сверено 12.09,
# `printenv` в контейнерах). Без потолка `ACCESS EXCLUSIVE` на `auth.sessions`
# вешает не четыре слота `/estimate`, а весь uvicorn-воркер (он один, без
# --workers) — включая `/health`.
# Срабатывание потолка безопасно: вызов в rbac.py уже под `except Exception`
# с фолбэком на заголовочную аутентификацию, то есть отмена запроса даёт тот
# же путь, что и любой другой сбой реестра, а не 500.
connect_args=DB_CONNECT_ARGS,
)
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'`,

View file

@ -431,37 +431,10 @@ class Settings(BaseSettings):
# После фактора заново применяется le_asking-кламп (expected_sold ≤ asking).
# OFF ⇒ точно старое поведение expected_sold.
estimate_hedonic_correction_enabled: bool = True
# #3248 (перефит 2026-08-30, n=1269 из свежей прод-фикстуры ЕКБ 1600 сделок).
#
# Прежние значения (b0=0.6146, year=0.1220, larea=-0.1603, first=-0.1248) зафичены
# 2026-06-27 (#2002), когда asking→sold ratio ключевался ПО КОМНАТАМ. 2026-08-05
# (#2620) ratio переключили на area-бакеты — то есть под хедонику подставили ДРУГУЮ
# базу, а её саму не пересчитали. Хедоника по определению чинит ОСТАТОК
# log(actual_sold / expected_sold), поэтому её коэффициенты верны только для той
# базы, на которой фитились. Итог: площадь штрафовалась дважды, крупные лоты
# занижались на 21% (bias 4+ комнат = -21.4%).
#
# larea = 0.0 ВЫСТАВЛЕН НАМЕРЕННО, это не «не задан». После #2620 площадь несёт
# area-бакетный ratio, и его форма — перевёрнутая U (факт sold/ask по бакетам:
# 0.786 / 0.829 / 0.899 / 0.954 / 0.864), которую монотонный ln(area) выразить не
# может в принципе: он тянет крупное жильё вниз ровно там, где рынок его не
# дисконтирует. Член стал избыточным и вредным — обнуляем, оставляя код-путь.
#
# Замер вариантов на той же фикстуре (bias по комнатам, median):
# текущие MAPE 13.90 | студия +7.6 1к -0.5 2к -2.8 3к -5.3 4+ -21.4
# OLS все 4 члена MAPE 14.11 | студия -0.9 1к -1.6 2к -0.7 3к -4.0 4+ -14.1
# БЕЗ larea (тут) MAPE 14.28 | студия -3.1 1к -2.3 2к -0.4 3к -1.9 4+ -10.4
# хедоника OFF MAPE 15.88 | студия -1.5 1к +4.4 2к +4.2 3к -2.3 4+ -5.0
# Берём «без larea»: худший перекос вдвое меньше, все прочие классы в пределах
# ±3.1%, цена — +0.38 п.п. общего MAPE (хедоника сжимает разброс за счёт year).
#
# ОСТАТОК -10.4% по 4+ конфигом НЕ закрывается: ratio бакета 4 = 0.8211 при
# фактических sold/ask = 0.8640, т.е. занижен на ~5% ДО всякой хедоники. Это
# пересчёт самой таблицы (app/tasks/asking_to_sold_ratio.py), см. follow-up.
estimate_hedonic_b0: float = -0.0140 # #3248 перефит поверх area-бакетного ratio
estimate_hedonic_year_coef: float = 0.0769 # per (year-2000)/20
estimate_hedonic_larea_coef: float = 0.0 # НАМЕРЕННО 0 — площадь несёт ratio (#2620)
estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7%
estimate_hedonic_b0: float = 0.6146 # fit log(sold/es) ~ year + ln(area), n=2366 (#2002)
estimate_hedonic_year_coef: float = 0.1220 # per (year-2000)/20
estimate_hedonic_larea_coef: float = -0.1603 # per ln(area_m2)
estimate_hedonic_first_floor_coef: float = -0.1248 # floor==1 ground-floor ≈ -12%; #2002 n=2366
estimate_hedonic_factor_min: float = 0.75
estimate_hedonic_factor_max: float = 1.30
# ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ──────
@ -745,32 +718,6 @@ class Settings(BaseSettings):
estimate_quarter_from_coords_enabled: bool = False
# ── Сегментная поправка эстиматора по ценовому бэнду (#2255) ──────────────
#
# ⚠ НЕ ВКЛЮЧАТЬ БЕЗ ПЕРЕЗАМЕРА. Посылка, на которой откалиброваны множители
# ниже («движок занижает верхние сегменты»), 2026-08-29 признана АРТЕФАКТОМ
# измерения, а не свойством движка.
#
# Причина: `scripts/backtest_estimator.py::_segment_metrics` группирует ошибки
# ПО SOLD ₽/м² — по той самой величине, которую движок предсказывает. Группировка
# по исходу даёт регрессию к среднему: в дешёвых корзинах любой предиктор выглядит
# завышающим, в дорогих — занижающим, даже при нулевом перекосе.
#
# Контрольный опыт (13 074 сделки ЕКБ с 2025-06): предиктор БЕЗ сегментного
# перекоса по построению — медиана окружения 1 км ÷ общий уровень — даёт в тех же
# корзинах эконом +22.28%, бизнес 17.04%, элит 30.06%. Это БОЛЬШЕ наблюдавшегося
# перекоса движка (бизнес 9.74%, элит 18.45% на n=1191), т.е. движок «занижает»
# ровно настолько, насколько этого требует способ замера, и даже меньше.
#
# Перекрёстная проверка: те же сделки, но бэнд задан НЕЗАВИСИМЫМ прокси (медиана
# активных объявлений собственного дома, own_n>=3) — градиент исчезает, остаётся
# ровный уровень +20…+27% (это разрыв asking↔sold, его чинит asking_to_sold_ratio).
#
# Следствие: включение флага подняло бы бизнес/элит на 6-8% БЕЗ ОСНОВАНИЯ.
# Прежде чем трогать — перевести `_segment_metrics` на независимый бэнд и
# перемерить. Абзац «band-mismatch» ниже описывает симптом того же артефакта.
# Разбор: vault `segment-bias-is-regression-to-the-mean-artifact`.
#
# ИСТОРИЧЕСКАЯ ПОСЫЛКА (сохранена как есть, НЕ опирайся на неё):
# Эстиматор систематически занижает верхние сегменты (live-бэктест n=561,
# 2026-07-03): эконом +3.2%, комфорт 4.5%, бизнес 16.3% (n=76),
# элит 25.7% (n=16). Множитель применяется к median_price/median_ppm2 и
@ -793,9 +740,6 @@ class Settings(BaseSettings):
# бизнес bias 10.4→3.2, элит 30→24.6, эконом почти intact (MAPE 18.0).
# Дальнейшее сжатие bias без роста MAPE — только с confidence-гейтом
# (не применять множитель на low-confidence предсказаниях) → follow-up issue.
# NB (2026-08-29): «bias 10.4→3.2» выше меряет схлопывание АРТЕФАКТА, а не
# исправление ошибки — см. предупреждение в начале блока. Рост общего MAPE
# +1.87pp при этом настоящий: множитель двигает реальные цены.
# Пересчёт биасов — см. scripts/backtest_estimator.py --calibrate-segments.
estimate_segment_multipliers: dict[str, float] = {
"эконом": 1.00,
@ -942,17 +886,21 @@ class Settings(BaseSettings):
"""
return self.scraper_proxy_url_env
# ── Ban-recovery budget knobs (changeip снят #2616 шаг 2) ────────────────
# Живая ротация IP — ASOCKS_API_TOKEN / app.services.proxy_rotation (#2611);
# `_rotate_ip`/`_rotate_proxy_ip` остались no-op после удаления changeip-ссылки.
# Обе ручки читаются: `*_proxy_max_rotations` гейтит бюджет попыток в
# ban-rotation (scraper_kit.orchestration.pipeline._max_rotations),
# `avito_proxy_rotate_settle_s` — верхняя граница asyncio.wait_for в
# app.tasks.avito_detail_backfill.
# #3212: proxy_rotate_attempts / proxy_rotate_attempt_timeout_s удалены —
# ретраи changeip-GET, которых больше нет; ни одного читателя не осталось.
# ── Ban-recovery budget knobs (changeip-механизм снят #2616 шаг 2) ────────
# Раньше эти поля тюнили retry/settle для GET-changeip mobileproxy
# (AVITO_PROXY_ROTATE_URL и т.д., см. историю выше) — сама ссылка удалена
# (закрытый аккаунт), поэтому IP-ротация сейчас всегда no-op (_rotate_ip /
# _rotate_proxy_ip возвращают False без сетевого похода). Поля оставлены:
# `*_proxy_max_rotations` продолжают гейтить бюджет попыток в ban-rotation
# state machine (scraper_kit.orchestration.pipeline._try_rotate_within_budget)
# — те же 0 попыток "успеха", что и раньше при мёртвом changeip, просто без
# затрат на HTTP; `avito_proxy_rotate_settle_s` — верхняя граница
# asyncio.wait_for в app.tasks.avito_detail_backfill (страховка от зависания).
# Живая ротация IP — ASOCKS_API_TOKEN / app.services.proxy_rotation (#2611).
avito_proxy_max_rotations: int = 4
avito_proxy_rotate_settle_s: float = 9.0
proxy_rotate_attempt_timeout_s: float = 8.0
proxy_rotate_attempts: int = 3
# ── ASocks pool-proxy rotation (#2600) ───────────────────────────────────
# Bearer-токен веб-кабинета ASocks для POST .../unlimited-proxy/{portId}/refresh-ip
@ -1007,14 +955,6 @@ class Settings(BaseSettings):
# ENV: YANDEX_PROXY_MAX_ROTATIONS.
yandex_proxy_max_rotations: int = 4
# ── Порог полноты detail-страницы Яндекса (#3191) ────────────────────────
# Полная карточка оффера — 3-5 МБ; недорендеренная приходит с HTTP 200, валидным
# HTML и БЕЗ блока контактов (наблюдалось 1,8 МБ). Размер — второй признак к
# структурному (scraper_kit...yandex.detail.DETAIL_CONTACTS_MARKERS); любой из двух
# даёт отказ, объявление остаётся в очереди (detail_enriched_at не проставляется).
# ENV: YANDEX_DETAIL_MIN_HTML_BYTES.
yandex_detail_min_html_bytes: int = 1_000_000
@property
def yandex_proxy_url(self) -> str | None:
"""Прокси для Yandex-скраперов (#2616 шаг 2: = scraper_proxy_url)."""
@ -1174,38 +1114,6 @@ class Settings(BaseSettings):
default=90.0, validation_alias="AVITO_DETAIL_FETCH_TIMEOUT_S"
)
# Ротация exit-IP по счётчику попыток на арендованном прокси (задача поверх #3298
# app.services.proxy_rotation.rotate_proxy). Замер вживую: билайновский порт держит
# ~15 карточек до полного обвала (13/39, последняя четверть 0%), настоящий
# мобильный оператор — ~30 (27/39, без обвала). Порог зависит от оператора порта,
# поэтому настройка, а не константа — подстройка под конкретный IP без релиза.
# Считаются ПОПЫТКИ (успех ИЛИ отказ), не только успехи: неудачная карточка тратит
# бюджет IP так же, как удачная. ENV: AVITO_DETAIL_BACKFILL_ROTATE_AFTER_ATTEMPTS.
avito_detail_backfill_rotate_after_attempts: int = Field(
default=15, ge=1, validation_alias="AVITO_DETAIL_BACKFILL_ROTATE_AFTER_ATTEMPTS"
)
# #3283g: ротация exit-IP НА САМ БАН площадки, а не только по счётчику попыток.
# Бан привязан к IP (замерено вживую: rotate_proxy() лечит забаненный узел за
# секунды, clear_source_bans гасит бан в scrape_proxy_source_bans — строка живёт
# до purge, #3404), но
# #3251/#3212 запрещают сбрасывать browser-context на КАЖДЫЙ блок -- сброс без
# смены IP выбрасывает пройденный QRATOR-PoW и запускает самоподдерживающийся
# каскад блоков на том же адресе. rotate_on_ban МЕНЯЕТ IP вместе со сбросом,
# поэтому не подвержена этому каскаду и может срабатывать больше одного раза за
# прогон -- но всё равно ограничена бюджетом, чтобы не выжигать прокси-пул на
# длинной серии блоков. 0 = выключено, полный no-op (никаких доп. ротаций/логов).
# ENV: AVITO_DETAIL_BACKFILL_ROTATE_ON_BAN_MAX.
avito_detail_backfill_rotate_on_ban_max: int = Field(
default=2, ge=0, validation_alias="AVITO_DETAIL_BACKFILL_ROTATE_ON_BAN_MAX"
)
# Минимум попыток между ЛЮБЫМИ двумя ротациями (по счётчику ИЛИ по бану) --
# не даёт двум сбросам контекста идти подряд, даже если оба бюджета формально
# ещё не исчерпаны. ENV: AVITO_DETAIL_BACKFILL_ROTATE_ON_BAN_MIN_GAP.
avito_detail_backfill_rotate_on_ban_min_gap: int = Field(
default=10, ge=0, validation_alias="AVITO_DETAIL_BACKFILL_ROTATE_ON_BAN_MIN_GAP"
)
# #3184: доля блоков в скользящем окне последних N попыток -- критерий обрыва
# avito_detail_backfill (app.services.backfill_block_breaker.BlockRatioBreaker),
# взамен голого "N блоков подряд". ТОЛЬКО avito -- изначальный план распространить
@ -1287,69 +1195,6 @@ class Settings(BaseSettings):
telegram_alerts_chat_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_CHAT_ID")
telegram_alerts_topic_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_TOPIC_ID")
# Общий (не per-тему) лимит частоты отправки в ОДНУ группу — Telegram
# считает ~20 сообщений/минуту на группу суммарно по всем её темам (#3471:
# всплеск GlitchTip-алертов + поток поддержки в ту же группу давали 429 и
# потерю сообщений). `TELEGRAM_SUPPORT_CHAT_ID`/`TELEGRAM_ALERTS_CHAT_ID` на
# проде равны (одна группа, темы разные) — обе половины делят один
# площадочный бюджет.
#
# РАЗДЕЛЕНО ПО РОЛЯМ (review H2, #3471), а не одна общая константа: лимитер
# живёт in-memory В ЭКЗЕМПЛЯРЕ `TelegramClient`, а в эту группу пишут ДВА
# независимых процесса — API под uvicorn (`app/services/tgbot/shared.py`,
# интерактивные ручки + GlitchTip-вебхук) и контейнер бота (`app/tgbot_main.py`,
# long-polling воркер). У них НЕТ общего счётчика (это отдельная задача —
# Redis-based распределённый лимитер), поэтому если каждому дать по 18,
# сумма (2×18=36) УДВОИТ площадочный лимit и 429 вернётся ровно там же.
# Бюджет делится статически так, чтобы СУММА была заметно НИЖЕ ~20: у API
# больше — там же интерактивные ответы клиентам, у бота меньше — там же
# обычно только зеркалирование/уведомления, которые могут подождать дольше
# (см. `TelegramGroupRateLimiter.acquire` про `max_wait=None` для фона).
# 0/отрицательное значение выключает лимитер для соответствующего процесса.
#
# ЧЕСТНО ПРО ОГРАНИЧЕНИЕ ЭТОГО ДИЗАЙНА (review, #3471):
# `telegram_group_rate_limit_api_per_minute` — ОДИН общий бюджет на ВСЕ
# отправки процесса API в эту группу, а туда
# пишут И зеркала веб-чата поддержки (`app/api/v1/support.py`), И
# GlitchTip-алерты (`app/api/v1/glitchtip.py`) — обе ручки идут через один
# и тот же `get_telegram_client()` (см. `app/services/tgbot/shared.py`).
# Приоритета между ними НЕТ: кто первый встал в очередь `TelegramGroupRateLimiter`,
# тот и получил слот. Оба пути передают узкий `timeout` (5с у support, 8с у
# glitchtip) — он же становится потолком ожидания слота (см.
# `TelegramClient._request`, review H1). Значит при всплеске алертов (пачка
# ошибок прода бьёт в вебхук залпом) реально возможен сценарий: бюджет
# 12/мин исчерпан алертами → следующая отправка живого клиента в веб-чате
# ждёт до 5с и получает `TelegramRateLimitedError` → 502 клиенту поддержки.
# То есть при достаточно большом всплеске алертов веб-чат ДЕЙСТВИТЕЛЬНО
# может временно вставать. Разделить бюджет по ИСТОЧНИКУ (не по процессу) —
# отдельная задача: нужен свой `TelegramGroupRateLimiter` на алерты с явно
# малой квотой и/или приоритет для support-трафика; здесь НЕ сделано
# (вне бюджета этой правки).
telegram_group_rate_limit_api_per_minute: int = Field(
default=12, validation_alias="TELEGRAM_GROUP_RATE_LIMIT_API_PER_MINUTE"
)
telegram_group_rate_limit_bot_per_minute: int = Field(
default=6, validation_alias="TELEGRAM_GROUP_RATE_LIMIT_BOT_PER_MINUTE"
)
# ── Ретранслятор Bot API через Beget (#3471) ─────────────────────────────
# Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` с Selectel — 9
# успешных из 12 (три ConnectTimeout), TCP-443 до адреса Selectel — 5/6, TCP-443
# до адреса Beget — 8/8; за сутки 508 строк `network error` в логе бота, за 30
# дней 92 обрыва итерации poll loop. Путь до Telegram с Selectel лоссовый, с
# Beget чистый (Alertmanager там же шлёт без проблем) — поэтому продуктовый
# трафик Bot API идёт через маленький HTTP-ретранслятор на Beget
# (`ops/metrics/tg-relay`), а не напрямую.
#
# Пусто (дефолт) = прежнее поведение, прямой путь к api.telegram.org — это и
# есть механизм отката, если ретранслятор сам подведёт. При заданном адресе
# клиент (`app/services/tgbot/client.py`) всё равно делает одну попытку
# напрямую при транспортном отказе похода на ретранслятор — хуже прямого
# пути быть не должно ни при каких условиях.
# ENV: TELEGRAM_RELAY_BASE_URL, TELEGRAM_RELAY_SECRET.
telegram_relay_base_url: str = Field(default="", validation_alias="TELEGRAM_RELAY_BASE_URL")
telegram_relay_secret: str = Field(default="", validation_alias="TELEGRAM_RELAY_SECRET")
# ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ──────────
# См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит
# роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch.
@ -1381,13 +1226,5 @@ class Settings(BaseSettings):
# обязаны отказывать сразу, ничего не вызывая у T-Bank.
payments_enabled: bool = Field(default=False, validation_alias="PAYMENTS_ENABLED")
# Kill-switch анонимного расчёта на публичном домене (POST/GET
# /api/public/mera/estimate*). false — обе ручки отвечают 404, как будто их
# нет: включение публичного расчёта открывает запись адреса физлица в
# trade_in_estimates, и решение это продуктовое (нужна правка п.5.5 политики
# обработки ПДн), а не «смержили код». Тот же приём и та же причина, что у
# payments_enabled выше. ENV: PUBLIC_ESTIMATE_ENABLED.
public_estimate_enabled: bool = Field(default=False, validation_alias="PUBLIC_ESTIMATE_ENABLED")
settings = Settings()

View file

@ -1,111 +1,11 @@
import asyncio
import logging
from collections.abc import Callable, Generator
from typing import Any
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.core.config import settings
logger = logging.getLogger(__name__)
# #3463. Потолок ОДНОГО statement'а, секунды·1000. Ставится на КОННЕКТЕ (libpq
# `options`), а не в питоновской обёртке: обёртка (`run_db_thread` ниже) при
# отмене обязана ДОЖДАТЬСЯ потока, иначе поток остаётся сиротой в общей
# `Session` — ровно то, ради чего писался #3449. Значит верхняя граница ожидания
# = длительность самого запроса, и задать её может только сервер.
#
# 30 с выбраны так, чтобы потолок НИКОГДА не стал биндящим ограничением для
# честной работы, но остался конечным:
# * самый длинный ОБЪЯВЛЕННЫЙ бюджет на `/estimate` — 20 с (`estimate_avito_imv_timeout_s`,
# config.py:852); дальше 12 с геокод, 8 с Yandex/Cian/house_meta. 30 с = 1.5× от максимума;
# * ОСНОВНАЯ опора по планировщику — `scrape_runs` (длительности целых прогонов, они
# не вытесняются): самая долгая ЧИСТО-БД задача за 14 суток — listing_source_snapshot,
# 9.7 с ЦЕЛИКОМ (и у неё сверх того свой `SET LOCAL statement_timeout = 900000`,
# который перекрывает это значение — гейт tests/test_3463_db_timeouts.py);
# * самый длинный set-based statement ЧЕРЕЗ движок из замеренных — матч ГАР→houses
# (`services/gar_flats_loader._MATCH_SQL`): 2.07 с с городским фильтром и 6.46 с без
# него (`city_filter=None`, флаг CLI). Запас ~3×, и это СЧИТАЮЩИЙ запрос, а не ждущий.
#
# `pg_stat_statements` опорой по планировщику НЕ является: при `max = 5000` он вытесняет
# редкие записи (проверено 12.09 — `dealloc` вырос на единицу за десять минут, и из топа
# пропали ВСЕ записи с `calls = 1`, включая `REFRESH MATERIALIZED VIEW` 30.85 с и KNN
# `cadastral_geo_match` 2.45 с). Суточная задача до следующих суток там не доживает, так
# что «самый долгий запрос 4.27 с» верно только для ВЫСОКОЧАСТОТНЫХ запросов.
_STATEMENT_TIMEOUT_MS = 30_000
# Ожидание БЛОКИРОВКИ — заведомо меньше: ждать лок дольше секунд смысла нет, лучше
# деградировать. 5 с — та же величина, что у миграций проекта
# (`SET LOCAL lock_timeout = '5s'` в data/sql/250,251,260,272,277…), снизу ограничена
# deadlock_timeout (на проде 1 с — сверено 12.09). Именно этот потолок закрывает
# сценарий #3463: под `ACCESS EXCLUSIVE` на `geocode_cache` запрос ЖДЁТ лок, а не
# считает, — statement_timeout тут только страховка от «считает вечно».
_LOCK_TIMEOUT_MS = 5_000
# idle_in_transaction_session_timeout НАМЕРЕННО не трогаем: тем же движком живёт tgbot,
# и `services/tgbot/bridge.py` держит транзакцию открытой ПОВЕРХ long-poll Telegram
# (замер на проде 12.09, 3 пробы с шагом 7 с: одна и та же сессия, запрос
# `SELECT value FROM tg_support_state …`, возраст транзакции циклически растёт до ~29 с).
# Сессионный потолок на простой в транзакции ронял бы long-poll КАЖДЫЙ цикл —
# гарантированно, а не в редком случае.
DB_CONNECT_ARGS = {
"options": f"-c statement_timeout={_STATEMENT_TIMEOUT_MS} -c lock_timeout={_LOCK_TIMEOUT_MS}"
}
engine = create_engine(
settings.database_url,
pool_pre_ping=True,
future=True,
# #3463. Накрывает ВСЕ три сервиса образа (backend / scraper / tgbot — один и тот
# же `app.core.db`, см. docker-compose.prod.yml) и обе стороны: продуктовый путь
# `/estimate` и задачи планировщика. Миграции идут мимо (psql из
# .forgejo/workflows/deploy-tradein.yml, не этот движок) — их DDL под своим
# `SET LOCAL lock_timeout` и потолком не ограничен.
connect_args=DB_CONNECT_ARGS,
# #3194: SQLAlchemy печатает ВСЕ bind-параметры в тексте StatementError —
# через них в GlitchTip уезжали ключ шифрования кук и сами куки
# (pgp_sym_encrypt(:cookies_json, :key)). Флаг на УРОВНЕ ДВИЖКА кроет все
# сайты вызова разом, включая будущие.
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
# и сырые psycopg-подключения мимо движков — это отдельный класс.
hide_parameters=True,
# #3408 п.1. Потолок пула обязан быть НЕ МЕНЬШЕ суммы потолков одновременности,
# которые сам же процесс и объявляет: 4 оценки (`api/v1/trade_in`
# `_ESTIMATE_CONCURRENCY`) + 4 подсказки (`api/public/mera` `_SUGGEST_CONCURRENCY`)
# + 8 фоновых догрузок (`services/estimator` `_MAX_DEFERRED_REFRESH_TASKS`) = 16.
# Дефолт SQLAlchemy 5+10=15 меньше этой суммы, то есть исчерпать пул можно
# штатной работой, не абузом. Гейт — tests/test_3408_pool_ceiling.py.
#
# Инвариант «пул >= суммы объявленных потолков» — это ПОЛ, а не гарантия:
# сумма считает по одному коннекту на объявленную единицу работы, а коннект
# держит и всё остальное — любая ручка с `Depends(get_db)` тоже. Глобального
# обработчика `sqlalchemy.exc.TimeoutError` в app/main.py нет, поэтому на
# исчерпанном пуле соседние ручки отдают 500 (после ожидания `pool_timeout`),
# а не медленный 200.
#
# pool_size оставлен дефолтным (5): это ПОСТОЯННО открытые коннекты, а в покое
# прод держит 5-6 (замер 11.09). Растёт только overflow — коннекты пика,
# которые пул закрывает сам. Потолок процесса: 5 + 15 = 20; воркер один
# (docker-compose.prod.yml, uvicorn без --workers), Postgres max_connections=100.
max_overflow=15,
# #3408 п.2. Дефолтные 30 с ожидания коннекта длиннее ЛЮБОГО бюджета внешнего
# источника в эстиматоре: 8 с (Yandex / Cian / house_meta), 12 с (geocode),
# 20 с (Avito IMV — `estimate_avito_imv_timeout_s`, config.py:852). Сам чекаут
# прервать `asyncio.wait_for` не может: он занимает поток `asyncio.to_thread`
# целиком, а пул потоков конечен (min(32, cpu+4)) — исчерпанный пул коннектов
# так превращается в исчерпанный пул потоков. 5 с короче самого КОРОТКОГО
# бюджета: занятый пул деградирует ОДИН источник, а не весь запрос.
#
# Отдельный коммит в конце ветки намеренно (ревью PR #3444): это единственная
# правка, которая меняет режим отказа с «медленно» на «быстро с ошибкой», и
# едет она во ВСЕ сервисы образа — backend, scraper, tgbot
# (tradein-mvp/docker-compose.prod.yml). За 29 ч логов исчерпания пула не было
# ни разу, то есть новое значение на проде пока не на чем проверить.
# ТРИГГЕР ОТКАТА на 30 с: любое `QueuePool limit ... timed out` в логах
# бэкенда ЛИБО рост failed+zombie в `scrape_runs` после деплоя.
pool_timeout=5,
)
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
@ -119,39 +19,3 @@ def get_db() -> Generator[Session, None, None]:
yield db
finally:
db.close()
async def run_db_thread[T](fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
"""`asyncio.to_thread(fn, ...)`, который при отмене ДОЖИДАЕТСЯ своего потока (#3449).
Для синхронной работы по сессии, которая шагу НЕ принадлежит она общая со
всем остальным запросом (`Depends(get_db)`). Поток отменить нельзя: у
`asyncio.to_thread` отменяется только ожидание со стороны loop'а. Корутина
умирает по бюджету источника (`estimator._with_budget` = `asyncio.wait_for`,
геокодер 12 с), а поток продолжает работать с ТОЙ ЖЕ `Session`, пока
вызывающий уже идёт дальше по коду следующий источник,
`_fetch_anchor_comps`, `_persist_estimate_and_commit`. Два потока в одной
`Session` дают «another operation is in progress» / `InvalidRequestError` на
СЛЕДУЮЩЕМ шаге: у источников такую ошибку глушит `except` вокруг вызова, у
персиста оценки не глушит ничего 500 и потерянная оценка клиента.
Поэтому отмена пробрасывается ПОСЛЕ того, как поток отпустил сессию. Цена
бюджет источника переезжает на длину ОДНОГО шага БД (чекаут `pool_timeout`
плюс сам запрос), а не на длину фетча, ради которой бюджет и заведён.
Только защита от сироты: транзакцию шаг НЕ завершает (в середине геокодинга
commit зафиксировал бы частичное состояние оценки). Кому нужен ещё и возврат
коннекта в пул перед внешним HTTP `estimator._db_step`, он поверх этого.
Гейт tests/test_3449_geocoder_cancel_orphan.py.
"""
step = asyncio.ensure_future(asyncio.to_thread(fn, *args, **kwargs))
try:
return await asyncio.shield(step)
except asyncio.CancelledError:
await asyncio.wait([step])
if not step.cancelled() and step.exception() is not None:
# Результата уже никто не ждёт: без явного чтения asyncio напечатает
# «Task exception was never retrieved» вообще без контекста.
logger.warning("шаг БД упал уже после отмены: %s", step.exception())
raise

View file

@ -1,59 +0,0 @@
"""Ответ об ошибке валидации, который собирается при ЛЮБОМ входе.
ЧТО СЛОМАЛОСЬ
-------------
Аудит живого сайта 30.08.2026: публичная проба покрытия отвечала 500 на входе,
который обязан отсеиваться валидацией::
POST /trade-in/api/public/mera/coverage
{"lat":56.8,"lon":1e400,"rooms":2,"area_m2":50} 500
Разбор. `json.loads` принимает то, чего нет в стандарте JSON: литералы
`Infinity`, `-Infinity`, `NaN`, а `1e400` даёт `inf` переполнением. Pydantic
такое поле честно отбивает по границам (`lon: le=180`) и кладёт значение в
`input` ошибки. Дальше штатный обработчик FastAPI отдаёт перечень ошибок через
`JSONResponse`, а тот сериализует `json.dumps(..., allow_nan=False)` и падает
уже ПОСЛЕ входа в ответ. Наружу это 500, то есть отказ сервера там, где
корректный ответ 422.
ПОЧЕМУ ОДИН ОБРАБОТЧИК, А НЕ ВАЛИДАТОР НА ПОЛЕ
----------------------------------------------
Чинить по одному полю значит починить `lon` и оставить `lat`, `area_m2`,
`limit` и каждое число каждой будущей схемы. Ломается не поле: ломается
сборка ОТВЕТА об ошибке, одна на всё приложение. Здесь она и чинится.
Отдельным модулем (а не строкой в `app/main.py`) ровно затем, чтобы тест мог
поставить ТОТ ЖЕ обработчик на своё маленькое приложение, не втягивая весь
граф роутеров: иначе тестовое приложение отвечало бы иначе, чем прод, и
проверка «422, а не 500» была бы зелёной по построению.
"""
from __future__ import annotations
import math
from fastapi import FastAPI, Request
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
def _json_safe_float(value: float) -> float | str:
"""inf/nan → строка. JSON их не умеет, а на ВХОД они приходят законно."""
return value if math.isfinite(value) else str(value)
async def validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
"""Тот же стандартный `{"detail": [...]}`, но нефинитное число в `input`
едет строкой ("inf"/"nan") вместо того, чтобы ронять ответ."""
return JSONResponse(
status_code=422,
content=jsonable_encoder(
{"detail": exc.errors()},
custom_encoder={float: _json_safe_float},
),
)
def install_validation_error_handler(app: FastAPI) -> None:
app.add_exception_handler(RequestValidationError, validation_error_handler)

View file

@ -1,72 +0,0 @@
"""Секреты из query-строки не попадают в лог процесса (#3154).
Прод-факт: uvicorn пишет в access-log ПОЛНЫЙ путь вместе с query, а лог уезжает
в Loki (ретенция 30 суток, доступ по входу в Grafana):
INFO: 172.18.0.3:60322 - "POST /api/v1/trade-in/ops/glitchtip-webhook
?secret=<64 hex> HTTP/1.1" 200 OK
Скруббер в Alloy (#3115) это не ловит: там одно выражение под форму
``scheme://user:pass@host`` (DSN postgres_exporter, #3114). Чиним в СВОЁМ
процессе тогда секрета нет и в `docker logs`, до отправки куда-либо.
Фильтр вешается на логгер (`logging.Filter`), а не на форматтер: uvicorn.access
кладёт путь в ``record.args``, до форматирования он уже там. Поэтому берём
``record.getMessage()`` и, если что-то замаскировали, подменяем msg/args.
"""
from __future__ import annotations
import logging
import re
# Имена параметров, значение которых маскируем: имя ОКАНЧИВАЕТСЯ на чувствительное
# слово, поэтому перед альтернацией допускаем префикс (`client_secret`,
# `refresh_token`, `webhook_secret`). Значение — до следующего `&`, пробела или
# кавычки (access-строка uvicorn обрамляет запрос кавычками).
_SENSITIVE_QUERY = re.compile(
r"([?&][\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=)"
r"[^&\s\"'<>]+",
re.IGNORECASE,
)
def scrub_query_secrets(text: str) -> str:
"""Заменяет значения чувствительных query-параметров на ``***``.
Имя параметра и остальная строка сохраняются иначе access-лог перестал бы
годиться для диагностики.
"""
return _SENSITIVE_QUERY.sub(r"\1***", text)
class QuerySecretFilter(logging.Filter):
"""Маскирует секреты в query-строке ЛЮБОЙ записи логгера, к которому привязан."""
def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage()
scrubbed = scrub_query_secrets(message)
if scrubbed != message:
record.msg = scrubbed
record.args = ()
return True
def install_query_secret_filter(*logger_names: str) -> None:
"""Вешает фильтр на access-лог uvicorn И на обработчики корневого логгера.
Двумя местами, потому что uvicorn в своём log-config ставит `uvicorn.access`
собственный handler с ``propagate = False`` до корневого его записи не
доходят. А фильтр на handler'ах корня закрывает всё остальное приложение
(записи дочерних логгеров фильтры родителя не проходят, фильтры handler'а
проходят).
Идемпотентно: повторный вызов не наплодит дублей.
"""
targets: list[logging.Logger | logging.Handler] = [
logging.getLogger(name) for name in logger_names or ("uvicorn.access",)
]
targets.extend(logging.getLogger().handlers)
for target in targets:
if not any(isinstance(f, QuerySecretFilter) for f in target.filters):
target.addFilter(QuerySecretFilter())

View file

@ -161,18 +161,6 @@ class SlidingWindowLimiter:
del self._hits[k]
return len(bucket)
def reset(self, key: str) -> None:
"""Обнуляет окно под *key*.
Нужен вызывающим, которые считают не «сколько сделано», а «сколько ПОДРЯД»
например счётчик отказов отправки (`support.py:_send_failure_limiter`):
успешная отправка означает, что канал жив, и предыдущие отказы больше не
должны приближать cooldown. Для лимитеров-бюджетов (`_send_limiter`,
`_anon_ip_limiter`, `_notify_limiter`) не используется там обнуление
по запросу было бы дырой в самом лимите.
"""
self._hits.pop(key, None)
def check(self, key: str) -> float | None:
"""Комбинированная проверка+регистрация (peek+record за один вызов) —
для вызывающих, которым не нужно различать "попытка"/"успех" (см.

View file

@ -35,11 +35,10 @@ from typing import Any
from fastapi import Request
from fastapi.responses import JSONResponse, Response
from starlette.routing import Match
from app.core.auth import get_role, is_path_allowed
from app.core.config import settings
from app.services.auth_session import DB_ROLE_PATHS, get_db_role_scope, get_session_user
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__)
@ -115,42 +114,8 @@ _PUBLIC_PATHS = frozenset(
# держится на структуре пакета app/api/public/, а не на матчере.
"/api/public/mera/suggest",
"/api/public/mera/coverage",
# Витринные числа лэндинга (landing_stats, миграция 275): агрегаты по
# проду без единой персональной строки — их и показывают анонимному
# посетителю, ради чего метрики и считаются.
"/api/public/mera/stats",
# Витрина реальных ДКП-сделок против прогноза (миграция 276): читает
# СВОЮ таблицу-витрину, где по построению нет ни адреса, ни владельца —
# район + характеристики квартиры + пара «прогноз/факт».
"/api/public/mera/showcase",
# Анонимный расчёт и повторное чтение его бесплатной части. Оба
# POST с точным путём: у чтения токен едет ТЕЛОМ, а не в URI, иначе
# капабилити-ссылка легла бы в access-лог Caddy рядом с IP посетителя
# (тот же довод, что у /suggest — см. app/api/public/mera.py).
# Обе ручки дополнительно закрыты флагом settings.public_estimate_enabled.
"/api/public/mera/estimate",
"/api/public/mera/estimate/read",
# Вебхук Т-Банка (app/api/v1/payments.py::notify): сервер-к-серверу,
# X-Authenticated-User/сессии у банка нет и быть не может. Путь не
# секрет — аутентификацией здесь работает подпись `Token` тела,
# которую проверяет сам хендлер (services/payments/token.py), плюс
# отдельный узкий rate-limit (core/ratelimit.py). Всё остальное
# платёжное (checkout, статус заказа) остаётся ЗАКРЫТЫМ — открыт ровно
# тот путь, который иначе получил бы 401 у банка.
"/api/v1/trade-in/payments/notify",
}
)
# Capability-ссылка на оплаченный отчёт: /api/v1/trade-in/r/<token>. Точной
# строкой её в множество выше не положить — токен переменный, а множество
# проверяется как `path in`. Отдельный кортеж префиксов, а не превращение
# `_PUBLIC_PATHS` в префиксный матчер: тот механизм держит auth-гейт всего
# бэкенда, расширять его семантику ради одного роута нельзя.
#
# Открывается ИМЕННО подпуть /r/ и ничего выше: правом доступа служит сам
# непредсказуемый токен (32 байта энтропии), сверяемый по payment_entitlements
# в app/api/v1/payments.py. У покупателя-физлица на meraocenka.ru идентичности
# нет и не будет, поэтому иного способа отдать ему купленное не существует.
_PUBLIC_PATH_PREFIXES = ("/api/v1/trade-in/r/",)
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
# scope-проверки восстанавливаем внешний путь.
@ -182,90 +147,6 @@ def _db_role_path_allowed(role: str, path: str) -> bool:
return any(_db_glob_match(p, path) for p in paths)
def _path_is_routed(request: Request) -> bool:
"""Есть ли у пути хоть один маршрут в роутере приложения.
#3324: guard — HTTP-middleware, он отрабатывает ДО роутинга, поэтому раньше
отвечал 401 и на пути, которых в приложении нет вовсе. Анониму этого хватало,
чтобы бесплатно разведать периметр: мусор под «интересным» префиксом
(``/api/public/whatever``) давал 401 с rbac-текстом, а мусор под публичным
префиксом 404 роутера. Выключенная/закрытая ручка отличалась от
несуществующей. Несуществующий путь обязан отвечать одинаково независимо от
префикса, поэтому такие запросы пропускаются дальше 404 отдаёт роутер, тот
же самый, что и на любой другой мусор.
ЧТО ИМЕННО РАЗМЕНЯНО (это НЕ «ослабления нет»). Раньше аноним получал 401 на
ЛЮБОЙ non-public путь то есть оракул был ПРЕФИКСНЫЙ: он говорил «префикс
закрыт», но перечислить по нему таблицу маршрутов было нельзя. Теперь 401 =
«такой маршрут есть», 404 = «нет», и это уже оракул СУЩЕСТВОВАНИЯ маршрута:
перебором аноним восстанавливает список всех ручек приложения, включая имена
под ``/api/v1/admin/*``. Доступа это не даёт (закрытая ручка по-прежнему
отвечает 401/403), но карту периметра даёт.
Почему размен принят. Точечно: 404 на несуществующее норма HTTP, а
подобранное ИМЯ ручки без креденшелов бесполезно; исчезает же реальный
признак «этот префикс что-то охраняет». Это защита в глубину, и её глубина
здесь честно меньше, чем была.
Периметр admin-путей снаружи НЕ срезан проверено по конфигу, а не по
предположению: ``caddy/sites/apps.caddy`` блок ``handle /trade-in/api/*``
делает ``uri strip_prefix /trade-in`` + ``reverse_proxy tradein-backend:8000``
и стоит ЦЕЛИКОМ ВЫШЕ ``import caddy/users.caddy.snippet`` (basic_auth), т.е.
у trade-in своя авторизация и внешнего barrier'а нет. Значит внешний
``https://gendsgn.ru/trade-in/api/v1/admin/...`` доходит до этого guard'а
анонимно, и перебор имён admin-ручек выполним снаружи, не только изнутри
docker-сети. Хочется убрать резать надо в Caddy (отдельный issue), guard
этого не сделает: он про роли, а не про сетевой периметр.
``Match.NONE`` по ВСЕМ маршрутам значит, что выполнять нечего хендлера, до
которого можно было бы дотянуться, не существует. ``Match.PARTIAL`` (путь
есть, метод другой) считается маршрутом и идёт в guard как раньше там путь
реально существует, скрывать нечего.
Трейлинг-слэш был вторым каналом того же оракула в обход guard'а:
``/api/v1/me/`` не матчит ни один маршрут (``Match.NONE``) guard пропускает
Starlette-роутер отвечал 307 на ``/api/v1/me``, то есть «маршрут есть»
сообщал редирект, а не 401. Закрыто в ``app/main.py``:
``FastAPI(redirect_slashes=False)`` теперь такой путь даёт тот же 404, что
и любой другой мусор.
Неизвестное приложение (``scope["app"]`` не выставлен) ведём себя как
раньше, то есть отдаём запрос в guard.
"""
router = getattr(request.scope.get("app"), "router", None)
if router is None:
return True
return any(route.matches(request.scope)[0] != Match.NONE for route in router.routes)
def _unauthenticated(path: str, detail: str) -> JSONResponse:
"""Отказ анониму: 401 везде, но на admin-префиксе — 404 роутера.
#3360: периметр admin-путей снаружи НЕ срезан (``caddy/sites/apps.caddy``,
блок ``handle /trade-in/api/*`` стоит выше ``import
caddy/users.caddy.snippet``), и срезать его нельзя admin-UI кабинета зовёт
``/api/v1/admin/*`` ИЗ БРАУЗЕРА (tradein-mvp/frontend/src/app/scrapers/**,
components/scrapers/**, lib/admin-audit-api.ts). Значит внешний аноним
доходит сюда, а после #3324 (несуществующий путь → 404 роутера) 401 на
существующей ручке работал оракулом: перебором имён восстанавливался список
admin-API. Отвечаем ТЕМ ЖЕ, что роутер отдаёт на несуществующий путь,
существующая и несуществующая admin-ручки анониму неразличимы.
Скрываем ровно от НЕаутентифицированного. Аутентифицированный не-admin
по-прежнему получает 403 «admin only»: он уже прошёл идентификацию, прятать
от него наличие ручки незачем, а 404 вместо 403 маскировал бы отладку.
Тело литерал ``{"detail": "Not Found"}``: это ответ дефолтного
http_exception_handler FastAPI, тот же, что придёт из роутера. Тест
сравнивает два ЖИВЫХ ответа между собой, а не с этой константой, если
фреймворк сменит формулировку, покраснеет он, а не прод.
"""
if _ADMIN_API_RE.match(path):
logger.info("RBAC: anonymous probe of admin path %s — cloaked as 404", path)
return JSONResponse(status_code=404, content={"detail": "Not Found"})
return JSONResponse(status_code=401, content={"detail": detail})
def _propagate_authenticated_user(request: Request, username: str) -> None:
"""Инжектит ``X-Authenticated-User`` в ASGI scope — ПЕРЕЗАПИСЫВАЯ, а не
только добавляя при отсутствии, чтобы ``RateLimitMiddleware``/
@ -321,12 +202,7 @@ async def rbac_guard(
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
path = request.url.path
if path in _PUBLIC_PATHS or path.startswith(_PUBLIC_PATH_PREFIXES):
return await call_next(request)
# #3324: путь, которого нет в роутере, отвечает как любой несуществующий
# путь (404 роутера) — иначе 401 работает оракулом существования ручки.
if not _path_is_routed(request):
if path in _PUBLIC_PATHS:
return await call_next(request)
username: str | None = None
@ -368,12 +244,18 @@ async def rbac_guard(
# auth_mode == "db_only" — легаси trusted-header путь ПОЛНОСТЬЮ
# отключён, даже если валидный X-Authenticated-User присутствует.
if settings.auth_mode != "dual":
return _unauthenticated(path, "valid session required")
return JSONResponse(
status_code=401,
content={"detail": "valid session required"},
)
# ---- legacy trusted-header path — BIT-FOR-BIT как было до #2552 ----
username = request.headers.get("X-Authenticated-User")
if not username:
return _unauthenticated(path, "no authenticated user (valid session required)")
return JSONResponse(
status_code=401,
content={"detail": "no authenticated user (valid session required)"},
)
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
# ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env).
@ -389,7 +271,10 @@ async def rbac_guard(
username,
path,
)
return _unauthenticated(path, "invalid or missing internal auth secret")
return JSONResponse(
status_code=401,
content={"detail": "invalid or missing internal auth secret"},
)
try:
role = get_role(username)
@ -415,11 +300,7 @@ async def rbac_guard(
# scope-narrowed юзер не смог бы получить свою роль вовсе.
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
external_path = _EXTERNAL_PREFIX + path
# Матчер выбирается по РОДУ роли, а не только по источнику (#3316):
# с DB-first резолвом legacy trusted-header путь тоже может отдать роль
# реестра (employee/manager), а её паттернов в roles.yaml нет — сверка
# с `is_path_allowed` дала бы 403 на всё.
if from_session or role in DB_ROLE_PATHS:
if from_session:
allowed = _db_role_path_allowed(role, external_path)
else:
try:

View file

@ -19,7 +19,6 @@ from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration
from sentry_sdk.types import Event, Hint
from app.api.public import mera as public_mera
from app.api.v1 import (
@ -32,7 +31,6 @@ from app.api.v1 import (
glitchtip,
lead,
me,
payments,
privacy_admin,
search,
support,
@ -44,14 +42,11 @@ from app.core.auth_db import get_auth_engine
from app.core.config import settings
from app.core.db import SessionLocal
from app.core.fdw import ensure_fdw_user_mapping
from app.core.http_errors import install_validation_error_handler
from app.core.log_scrub import install_query_secret_filter
from app.core.ratelimit import RateLimitMiddleware
from app.core.rbac import rbac_guard
from app.core.request_audit import RequestAuditMiddleware
from app.observability import metrics as app_metrics
from app.observability.sentry_scrub import scrub_pii_event
from app.services.tgbot.shared import close_telegram_client, init_telegram_client
logger = logging.getLogger(__name__)
@ -67,11 +62,6 @@ logging.basicConfig(
# закрытие, что уже стоит в tgbot_main.py (см. его комментарий), нужно и здесь.
logging.getLogger("httpx").setLevel(logging.WARNING)
# #3154: uvicorn access-log печатает полный путь С QUERY, а лог уезжает в Loki —
# так секрет вебхука GlitchTip (`?secret=…`) оказался в хранилище открытым.
# Маскируем значения чувствительных query-параметров ДО записи строки.
install_query_secret_filter()
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
# DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP.
# Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs),
@ -80,43 +70,31 @@ install_query_secret_filter()
# frontend), отдельного broker нет → мониторить нечего.
if settings.glitchtip_dsn:
from app.observability.sentry_scrub import (
drop_payments_disabled_event,
redact_telegram_bot_token,
scrub_payment_request_body,
scrub_public_address,
stabilize_retry_error_fingerprint,
)
def _before_send(event: Event, hint: Hint) -> Event | None:
"""Композиция payments-disabled drop + платёжный body-wipe + PII-scrub +
Telegram bot-токен redaction + RetryError fingerprint-стабилизация
(#tgsupport-web, PR-D2, glitchtip-noise, #3471) — см.
app/tgbot_main.py._before_send (идентичная композиция без последнего
шага, тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже
держит TelegramClient в стек-фреймах при ошибке sendMessage, а
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
"""Композиция платёжный body-wipe + PII-scrub + Telegram bot-токен redaction +
RetryError fingerprint-стабилизация (#tgsupport-web, PR-D2, glitchtip-noise) —
см. app/tgbot_main.py._before_send (идентичная композиция без последнего шага,
тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже держит
TelegramClient в стек-фреймах при ошибке sendMessage, а
include_local_variables=False ниже первый рубеж защиты.
#3471: payments-disabled drop идёт ПЕРВЫМ шагом — это единственный
процесс из трёх entrypoint'ов, который реально держит ASGI-роут
`/api/v1/payments/*`, поэтому именно здесь события возникают; ранний
return None экономит остальную композицию на заведомо отбрасываемом
событии.
PR-D2: платёжный body-wipe идёт следующим шагом, а не заменяет
остальные режет `request.data` целиком только для `/payments/*`,
остальные пути (extra/contexts/traceback) по-прежнему проходят
ключ-based scrub и token-redaction. Тот же обработчик передан ОБОИМ
каналам ниже (before_send и before_send_transaction) вчерашний баг в
Птице закрыл только error-канал, transaction-канал остался вообще без
обработчика.
PR-D2: платёжный body-wipe идёт ПЕРВЫМ шагом, а не заменяет остальные
режет `request.data` целиком только для `/payments/*`, остальные пути
(extra/contexts/traceback) по-прежнему проходят ключ-based scrub и
token-redaction. Тот же обработчик передан ОБОИМ каналам ниже
(before_send и before_send_transaction) вчерашний баг в Птице закрыл
только error-канал, transaction-канал остался вообще без обработчика.
RetryError-стабилизация этот процесс обслуживает /api/v1/geocode/*
(suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см.
sentry_scrub.stabilize_retry_error_fingerprint."""
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type]
if dropped is None:
return None
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
# Публичный периметр МЕРЫ: тело запроса — это ровно введённый адрес, а
@ -230,19 +208,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# in the tradein-scraper container (`python -m app.scheduler_main`, kit scheduler).
# Prod backend has always run with SCHEDULER_ENABLE=false (see docker-compose.prod.yml);
# this API process never actually launched scheduler_loop() in production.
# Общий на приложение Telegram-клиент (#tg-connection-resilience): ручки
# support/glitchtip раньше создавали его на КАЖДЫЙ запрос, то есть каждое
# зеркалирование сообщения начиналось с полного TCP+TLS-хендшейка до
# api.telegram.org. Один пул keep-alive на процесс, закрываем на shutdown.
# Без токена не создаём: ручки в этом случае и так отвечают 503.
if settings.telegram_bot_token:
init_telegram_client()
try:
yield
finally:
await close_telegram_client()
yield
app = FastAPI(
@ -250,22 +216,8 @@ app = FastAPI(
description="Оценка вторичного жилья (выкупная стоимость) — копия trade-in feature из gendesign", # noqa: E501
version="0.1.0",
lifespan=lifespan,
# #3324: трейлинг-слэш обходил rbac_guard как канал разведки периметра.
# `/api/v1/me/` не матчит ни один маршрут → guard пропускает (см.
# rbac._path_is_routed) → роутер отвечал 307 на `/api/v1/me`, т.е. «маршрут
# существует» сообщал редирект вместо 401. Выключение проверено на предмет
# поломок: ни один route не объявлен с трейлинг-слэшем (нет `@router.get("/")`
# и пустых путей), ни один из 173 вызовов `api/v1` во фронте
# (tradein-mvp/frontend/src) не заканчивается слэшем, mount/StaticFiles нет.
# Deny-правила на слэш тоже не зависят от редиректа — они переведены на
# глоб-форму специально ради этого (см. auth_session.DB_ROLE_PATHS).
redirect_slashes=False,
)
# 422 вместо 500 на Infinity/NaN во входе — разбор в app/core/http_errors.py.
install_validation_error_handler(app)
# RBAC: defense-in-depth поверх Caddy basic_auth + X-Authenticated-User
# (см. app/core/auth.py + auth/roles.yaml). Правила:
# 1) Любой non-public path требует X-Authenticated-User — иначе 401.
@ -334,11 +286,6 @@ app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-ve
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
app.include_router(glitchtip.router, prefix="/api/v1/trade-in", tags=["trade-in-ops"])
# Платёжный контур — весь за settings.payments_enabled (дефолт False): роутер
# подключён всегда, но каждая его ручка отвечает 503, пока контур выключен.
# Подключать по флагу было бы хуже: путь /payments/notify обязан существовать
# и отвечать предсказуемо, а не менять форму ответа вместе с конфигом.
app.include_router(payments.router, prefix="/api/v1/trade-in", tags=["trade-in-payments"])
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
app.include_router(search.router, prefix="/api/v1", tags=["search"])
app.include_router(me.router, prefix="/api/v1", tags=["me"])

View file

@ -99,47 +99,6 @@ BUILD_INFO = Gauge(
# смысл метки в том, чтобы «что было задеплоено в 03:14» отвечалось однозначно.
BUILD_INFO.labels(app="mera", release=f"{APP_VERSION}+{BUILD_SHA}").set(1)
# ═══ ПРОДУКТОВЫЕ СЧЁТЧИКИ (#3471) ═══════════════════════════════════════════
#
# Источник списка — реальные `event_type` из `user_events` (миграция 184) плюс
# ручки, которые сами в этот аудит-лог не пишут (suggest, PDF-экспорт). Метки
# везде — фиксированный литерал из кода вызова (outcome/found/channel/result),
# НЕ значение из запроса: username, адрес, estimate_id в метку не идут —
# это ровно то, что взрывает кардинальность ряда у Prometheus.
ESTIMATES = Counter(
"mera_estimates_total",
"Запрошенных оценок trade-in, по исходу расчёта",
labelnames=("outcome",), # ok | insufficient_data
)
ADDRESS_SUGGESTIONS = Counter(
"mera_address_suggestions_total",
"Запросов автокомплита адреса (/geocode/suggest), нашёлся ли результат",
labelnames=("found",), # yes | no
)
REPORTS_EXPORTED = Counter(
"mera_reports_exported_total",
"Скачанных PDF-отчётов по оценке trade-in",
)
LEADS = Counter(
"mera_leads_total",
"Сохранённых контактных заявок (телефон + согласие) с результата оценки",
)
SUPPORT_MESSAGES = Counter(
"mera_support_messages_total",
"Сообщений в поддержку, дошедших до Telegram-топика, по каналу",
labelnames=("channel",), # web | anon
)
LOGINS = Counter(
"mera_logins_total",
"Попыток входа в личный кабинет, по исходу",
labelnames=("result",), # success | failed
)
def route_label(scope: Scope) -> str:
"""Шаблон маршрута из ASGI-scope, либо ``__unmatched__``.

View file

@ -25,7 +25,6 @@ import re
from typing import Any
from sentry_sdk.types import Event
from starlette.exceptions import HTTPException as _StarletteHTTPException
from tenacity import RetryError
_REDACTED = "[REDACTED]"
@ -132,20 +131,6 @@ _URL_SECRET_QUERY_REPLACEMENT = r"\g<1>" + _REDACTED
_HTTPX_ERROR_URL_QUERY_RE = re.compile(r"(for url '[^'?]*)\?[^']*(')")
_HTTPX_ERROR_URL_QUERY_REPLACEMENT = r"\g<1>?" + _REDACTED + r"\g<2>"
# Capability-токен оплаченного отчёта живёт В ПУТИ (`/api/v1/trade-in/r/<token>`,
# app/api/v1/payments.py), а не в query и не в теле — значит ни `_PII_KEYS`
# (ключ-based), ни `scrub_payment_request_body` (режет request.data по сегменту
# `/payments/`), ни sentry_sdk `sanitize_url` (режет только userinfo и query)
# его не касаются. А путь попадает в событие несколькими путями сразу:
# `event.request.url`, `transaction`, breadcrumb'ы, текст исключения. Токен —
# это ПРАВО ДОСТУПА целиком: утёкший в GlitchTip путь равен выданному отчёту.
# Поэтому — full-text regex по всему событию, как у TG-токена.
# `[\w-]` покрывает алфавит `secrets.token_urlsafe` (base64url), хвост
# `(?=[/?#]|$)` оставляет нетронутым остаток URL (query/фрагмент) — он полезен
# для диагностики и секретом не является.
_REPORT_LINK_TOKEN_RE = re.compile(r"(/api/v1/trade-in/r/)[\w-]+(?=[/?#]|$)")
_REPORT_LINK_TOKEN_REPLACEMENT = r"\g<1>" + _REDACTED
def _scrub(obj: Any) -> None:
"""Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place)."""
@ -220,7 +205,6 @@ def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
_scrub(event.get("contexts"))
_regex_redact_inplace(event, _URL_SECRET_QUERY_RE, _URL_SECRET_QUERY_REPLACEMENT)
_regex_redact_inplace(event, _HTTPX_ERROR_URL_QUERY_RE, _HTTPX_ERROR_URL_QUERY_REPLACEMENT)
_regex_redact_inplace(event, _REPORT_LINK_TOKEN_RE, _REPORT_LINK_TOKEN_REPLACEMENT)
return event
@ -257,41 +241,6 @@ def scrub_payment_request_body(event: Event, _hint: dict[str, Any]) -> Event | N
return event
_PAYMENTS_DISABLED_DETAIL = "payments are disabled"
def drop_payments_disabled_event(event: Event, hint: dict[str, Any]) -> Event | None:
"""before_send-хук: роняет 503 "payments are disabled" из
`payments.py._require_enabled` (issue #3471, GlitchTip-группа TRADE-IN-3GG).
Источник внутренний IP смоук-проверки: кнопки оплаты во фронте нет,
клиентского трафика на эти пути нет вообще, а выключенный платёжный контур
(`settings.payments_enabled=False`) штатно отвечает 503 на каждый такой
запрос 167 событий за 29.08-12.09 размывали ленту, на этом фоне терялась
настоящая ошибка. Само поведение ручки НЕ меняется (503 остаётся)
фильтруется только репортинг в трекер: sentry_sdk `StarletteIntegration`
репортит любой `HTTPException` с кодом из `failed_request_status_codes`
(по умолчанию весь диапазон 5xx) как error-событие, даже когда исключение
штатно обработано FastAPI и превращено в корректный HTTP-ответ.
Матчим `isinstance` реального объекта исключения из `hint["exc_info"]` (тот
же контракт, что `stabilize_retry_error_fingerprint` ниже) + точный текст
`detail` НЕ код 503 сам по себе, чтобы не проглотить другие 503 (напр.
будущий maintenance-режим другого роутера).
"""
if not isinstance(event, dict):
return event
exc_info = hint.get("exc_info") if isinstance(hint, dict) else None
exc_value = exc_info[1] if exc_info and len(exc_info) > 1 else None
if (
isinstance(exc_value, _StarletteHTTPException)
and exc_value.status_code == 503
and exc_value.detail == _PAYMENTS_DISABLED_DETAIL
):
return None
return event
_PUBLIC_API_URL_SEGMENT = "/api/public/"
#: Хосты геокодеров: их URL несёт введённый адрес прямо в query.

View file

@ -43,16 +43,14 @@ if settings.glitchtip_dsn:
from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.types import Event, Hint
from app.observability.sentry_scrub import (
drop_payments_disabled_event,
scrub_payment_request_body,
scrub_pii_event,
stabilize_retry_error_fingerprint,
)
def _before_send(event: Event, hint: Hint) -> Event | None:
def _before_send(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg]
"""PR-D2: этот процесс не держит ASGI-приложения (нет `request` в event
сегодня), но payments_confirm/payments_reconcile (PR-E, тот же
`tradein-scraper` контейнер) будут звать Т-Банк API отсюда belt-and-
@ -60,13 +58,6 @@ if settings.glitchtip_dsn:
`request`/`extra`. Тот же обработчик на оба канала ниже см.
app/main.py._before_send (идентичный мотив, не дублировать без причины).
#3471: payments-disabled drop — тот же belt-and-suspenders мотив, что и
payment body-wipe выше по докстрингу: этот процесс сегодня не отвечает
503 из `_require_enabled` (нет ASGI/роутов), реальный источник шума
`app/main.py`, но фильтр держим одинаковым во всех трёх entrypoint'ах,
чтобы поведение не разошлось, если payments-код когда-нибудь переедет
сюда же.
PII-scrub + RetryError fingerprint-стабилизация (glitchtip-noise) идут
следом за платёжным body-wipe: этот процесс гоняет
`geocode_missing_listings` (ночной batch, сотни адресов за прогон)
@ -75,16 +66,13 @@ if settings.glitchtip_dsn:
на КАЖДЫЙ адрес (RetryError.__str__() тащит нестабильный repr() Future).
См. sentry_scrub docstring.
"""
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type]
if dropped is None:
return None
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
scrubbed = scrub_pii_event(scrubbed, hint)
if scrubbed is None:
return None
return stabilize_retry_error_fingerprint(scrubbed, hint) # type: ignore[arg-type,return-value]
return stabilize_retry_error_fingerprint(scrubbed, hint)
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
@ -92,11 +80,6 @@ if settings.glitchtip_dsn:
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
traces_sample_rate=0.0,
send_default_pii=False,
# #3194: процесс скрейпера — в локальных переменных кадров лежат
# прокси-креды вида user:pass, default sentry_sdk (True) приложил бы их
# к traceback открытым текстом. Так же выставлено у соседей (main.py,
# tgbot). НЕ закрывает значения в самом тексте исключения и в логах.
include_local_variables=False,
before_send=_before_send,
before_send_transaction=_before_send,
integrations=[
@ -113,23 +96,15 @@ def _should_run() -> bool:
return settings.scheduler_enable
async def _await_scheduler(task: asyncio.Task[None]) -> bool:
async def _await_scheduler(task: asyncio.Task[None]) -> None:
"""Дождаться завершения scheduler-задачи с кооперативным SIGTERM-drain'ом.
Возвращает True, если задачу пришлось хард-кансельнуть (grace истёк), False если
она вышла сама. Вызывающий обязан различать эти исходы в логах (#3391): строка
«drained cleanly» после hard-cancel'а — ложь, а именно она печаталась на проде
07.09 сразу за WARNING'ом о превышении grace.
- Нет shutdown: scheduler_loop бесконечен задача никогда не завершается, ждём её
как есть (процесс просто работает).
- shutdown запрошен, задача ещё бежит: bounded `wait_for(_DRAIN_TIMEOUT_S)`
кооперативная задача докоммитит текущий unit на ближайшем checkpoint'е и выйдет
сама. Если превысила grace hard-cancel + suppress CancelledError, чтобы
некооперативная задача не подвесила процесс за пределами docker stop_grace_period.
Пометку `interrupted` своим in-flight прогонам kit-scheduler успевает поставить в
обработчике CancelledError (SchedulerContext.mark_inflight_interrupted): между
hard-cancel'ом и SIGKILL'ом остаётся 20 с docker-grace (120s 100s).
"""
# Гонка «задача завершилась сама» против «пришёл SIGTERM»: отсчёт safety-net'а
# должен стартовать от МОМЕНТА запроса drain'а, а не от старта процесса.
@ -147,7 +122,7 @@ async def _await_scheduler(task: asyncio.Task[None]) -> bool:
# упал (сохраняем прежнюю loud-crash семантику `await task`), а не глушит его.
task.result()
logger.info("scheduler_main: scheduler task exited cleanly")
return False
return
logger.info(
"scheduler_main: SIGTERM-drain — waiting up to %.0fs for in-flight unit to commit",
@ -156,7 +131,6 @@ async def _await_scheduler(task: asyncio.Task[None]) -> bool:
try:
await asyncio.wait_for(task, timeout=_DRAIN_TIMEOUT_S)
logger.info("scheduler_main: scheduler drained and exited cleanly")
return False
except TimeoutError:
logger.warning(
"scheduler_main: drain exceeded %.0fs grace — hard-cancelling scheduler task",
@ -165,7 +139,6 @@ async def _await_scheduler(task: asyncio.Task[None]) -> bool:
task.cancel()
with suppress(asyncio.CancelledError):
await task
return True
async def _run_kit_scheduler() -> None:
@ -246,12 +219,8 @@ async def _run() -> None:
# Windows dev: signal handlers через loop не поддерживаются
logger.warning("scheduler_main: loop.add_signal_handler not supported (Windows dev)")
hard_cancelled = await _await_scheduler(task)
await _await_scheduler(task)
if hard_cancelled:
# Дрейн НЕ был чистым: задача не вышла сама, её сняли. WARNING об этом уже
# напечатан в _await_scheduler — второй строкой её не «переобъявляем».
return
if shutdown_requested():
logger.info("scheduler_main: scheduler drained cleanly (SIGTERM)")
else:

View file

@ -9,7 +9,7 @@ from datetime import date, datetime
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, Field, computed_field, model_validator
from pydantic import BaseModel, Field, computed_field
class TradeInEstimateInput(BaseModel):
@ -57,21 +57,6 @@ class TradeInEstimateInput(BaseModel):
# закрыто договором, а не UI-чекбоксом, и их фронт НЕ шлёт это поле вовсе.
consent: bool | None = None
@model_validator(mode="after")
def _check_floor_within_total_floors(self) -> TradeInEstimateInput:
"""#3257: этаж не может быть выше этажности дома.
Частичный ввод легален (пользователь ещё печатает форму) гейт срабатывает
только когда ОБА поля заданы одновременно.
"""
if self.floor is not None and self.total_floors is not None:
if self.floor > self.total_floors:
raise ValueError(
f"Этаж {self.floor} больше этажности дома {self.total_floors}"
"проверьте данные"
)
return self
class AnalogLot(BaseModel):
address: str
@ -158,14 +143,10 @@ class DkpCorridor(BaseModel):
"""Коридор реальных ДКП-сделок Росреестра для target (#652).
Источник: `deals` (source='rosreestr', ДКП-only), агрегированные по улице +
rooms + площади ±15% за период. Показывается как тонкая референсная линия
«коридор реальных сделок: XY млн»; если итоговая медиана /м² выходит за
[low,high]×slack добавляется текстовая пометка.
rooms + площади ±15% за период. ADVISORY: показывается как тонкая референсная
линия «коридор реальных сделок: XY млн»; если итоговая медиана /м² выходит
за [low,high]×slack добавляется текстовая пометка. НЕ хард-клампит оценку.
None / count=0 если по улице нет сопоставимых сделок.
#3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
порога не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
"""
count: int # число ДКП-сделок в выборке
@ -187,30 +168,6 @@ class DkpCorridor(BaseModel):
# None = сделки без даты (в проде не встречается) — потребитель молчит.
latest_deal_date: date | None = None
@computed_field # type: ignore[prop-decorator]
@property
def advisory_only(self) -> bool:
"""#3452: True = сделок меньше порога, ценовые страховки коридора выключены.
Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ
путей коридора: soft-кламп headline сверху и radius-floor снизу
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
цену зона n=3..9 на экране была неотличима от работающей.
ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C
(#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и
deals-headline-fallback берёт медиану коридора начиная с трёх сделок.
Потребителю (витрине) поэтому корректно говорить про РАЗМЕР ВЫБОРКИ, а
не про то, что цену коридор не трогал.
Производное от count, поэтому верно во ВСЕХ конструкторах DkpCorridor
автоматически (POST /estimate и GET-rehydrate) и не дублирует порог
вторым числом.
"""
from app.core.config import settings # локально: schemas остаётся import-light
return self.count < settings.estimate_corridor_clamp_min_n
class PriceTrendPoint(BaseModel):
"""Одна точка месячного ₽/м² тренда для целевого дома / района (web TREND chart).

View file

@ -46,7 +46,7 @@ from fastapi import HTTPException
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.auth import get_role, yaml_role
from app.core.auth import get_role
from app.core.config import settings
logger = logging.getLogger(__name__)
@ -61,7 +61,8 @@ def limit_exhausted_message(limit: int) -> str:
отличаться от глобального MONTHLY_LIMIT для персонального override ИЛИ
anon default_limit, см. #b2c-antiabuse-2)."""
return (
f"Лимит из {limit} оценок в этом месяце исчерпан. За полной версией обращайтесь к Копылову."
f"Лимит из {limit} оценок в этом месяце исчерпан. "
"За полной версией обращайтесь к Копылову."
)
@ -96,14 +97,6 @@ def is_unlimited(db: Session, username: str) -> bool:
return False
if role == "admin":
return True
# #3316: get_role резолвит роль из реестра (БД) первой, поэтому сотрудник
# team-API больше не даёт KeyError. Право на ПЕРСОНАЛЬНЫЙ безлимит при этом
# осталось там же, где было — за roles.yaml: фикс убирает эскалацию, а не
# раздаёт новую. Иначе руками проставленный `unlimited` начал бы работать
# для аккаунтов, которым он раньше молча игнорировался (и разъехался бы с
# `_batch_quota_status` в списке «Команды»).
if yaml_role(username) is None:
return False
row = db.execute(
text(
"""

View file

@ -56,8 +56,6 @@ from __future__ import annotations
from collections import Counter, deque
from dataclasses import dataclass, field
from scraper_kit.orchestration.runs import BAN_KIND_PLATFORM
@dataclass
class BlockRatioBreaker:
@ -90,26 +88,7 @@ class BlockRatioBreaker:
"""Знаменатель ratio-критерия: сколько попыток уже влезло в окно."""
return len(self._window)
def record_block(self, kind: str = BAN_KIND_PLATFORM) -> None:
"""Блок в числитель ratio-критерия — ТОЛЬКО отказ площадки (#3288).
`kind` диагноз того же исключения, что уходит в counters.ban_kinds
(ban_kind_of_exception). Всё, что не 'platform' (в первую очередь
BAN_KIND_INFRA: отказ нашего сайдкара, пустой пул), отказ НАШЕЙ стороны:
площадка его не показывала, и рвать по нему прогон значит объявить бан там,
где площадка молчала. Прогон 5425 оборвался по доле на 41 infra из 48
«блоков» при 41 успешно обогащённой карточке.
Такой отказ идёт ровно туда же, куда record_failure(): в ЗНАМЕНАТЕЛЬ окна
(доля платформенных блоков от него честно падает), мимо серии и safety-net.
Отдельно важно, что серию он не двигает: иначе safety-net на снапшоте
короче окна рвал бы прогон по тем же infra-отказам, только другим
критерием. Дефолт 'platform' сохраняет поведение вызывающих, которые вид
не считают.
"""
if kind != BAN_KIND_PLATFORM:
self.record_failure()
return
def record_block(self) -> None:
self._consecutive_blocks += 1
self._window.append(True)

View file

@ -1,464 +0,0 @@
"""Макро-ряды ЦБ РФ: ипотека по субъектам (XLSX) + ключевая ставка (SOAP).
CONTEXT: продукту нужен региональный макро-контекст ипотечного рынка (динамика ставок,
объёмов выдач и задолженности по субъектам РФ) и дневная ключевая ставка. Этот модуль
только собирает данные в cbr_mortgage_series / cbr_key_rate (292_cbr_macro_series.sql)
подключение к estimator вынесено в отдельное продуктовое решение (out of scope).
ИСТОЧНИК (ипотека): три XLSX-дашборда cbr.ru, wide-формат (территория × месяц),
строка 1 пустая, строка 2 заголовок-описание, строка 3 шапка периодов
(«Январь 2019», «Февраль 2019», ), строки 4+ территории в колонке A
(РФ целиком федеральные округа субъекты). Unpivot в длинный ряд
(region, period_month, series, value).
ИСТОЧНИК (ключевая ставка): POST https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx,
SOAP 1.1 (Content-Type: text/xml; charset=utf-8, SOAPAction: http://web.cbr.ru/KeyRate),
метод KeyRate(fromDate, ToDate). Ответ DataSet-XML со строками
<KR><DT>дата</DT><Rate>значение</Rate></KR> (namespace-агностичный парсинг: берём по
локальному имени тега, т.к. .NET DataSet оборачивает их в diffgram/reset-default-ns).
TLS: cbr.ru отдаёт RU-сертификат verify=False (open data, без auth/PII).
psycopg v3: CAST(:x AS type), НИКОГДА :x::type.
Дизайн-инвариант (как domrf_kapremont_loader): сервис-функции НЕ коммитят коммитит
caller (app/tasks/cbr_macro_pull.py). Изоляция сбоев try/except на серию,
db.rollback() в except (иначе аборченная транзакция каскадит на следующие серии).
"""
from __future__ import annotations
import io
import logging
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from datetime import date, datetime
import httpx
import openpyxl
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
DOWNLOAD_TIMEOUT_SEC = 60.0
# ---------------------------------------------------------------------------
# Ипотека по субъектам — конфиг серий
# ---------------------------------------------------------------------------
_MORTGAGE_BASE_URL = "https://www.cbr.ru/vfs/statistics/BankSector/Mortgage"
NEW_LOANS_URL = f"{_MORTGAGE_BASE_URL}/02_11_New_loans_mortgage.xlsx"
RATES_URL = f"{_MORTGAGE_BASE_URL}/02_13_Rates_mortgage.xlsx"
DEBT_URL = f"{_MORTGAGE_BASE_URL}/02_14_Debt_mortgage.xlsx"
@dataclass(frozen=True)
class CbrMortgageSeries:
slug: str
url: str
sheet: str
# По одной серии на файл (лист «в рублях» — основной ряд каждого дашборда).
CBR_MORTGAGE_SERIES: list[CbrMortgageSeries] = [
CbrMortgageSeries("new_loans_rub", NEW_LOANS_URL, "в рублях"),
CbrMortgageSeries("rate_rub", RATES_URL, "ставка в рублях"),
CbrMortgageSeries("debt_rub", DEBT_URL, "в рублях"),
]
@dataclass(slots=True, frozen=True)
class CbrMortgageRow:
region: str
period_month: date
series: str
value: float
# ---------------------------------------------------------------------------
# Период: русские имена месяцев → date(y, m, 1)
# ---------------------------------------------------------------------------
RU_MONTHS: dict[str, int] = {
"Январь": 1,
"Февраль": 2,
"Март": 3,
"Апрель": 4,
"Май": 5,
"Июнь": 6,
"Июль": 7,
"Август": 8,
"Сентябрь": 9,
"Октябрь": 10,
"Ноябрь": 11,
"Декабрь": 12,
}
def parse_ru_period(period_str: str) -> date:
"""Разобрать шапку периода «Январь 2019» → date(2019, 1, 1)."""
parts = str(period_str).strip().split()
if len(parts) != 2:
raise ValueError(f"cbr_macro: не удалось разобрать период {period_str!r}")
month_name, year_str = parts
month = RU_MONTHS.get(month_name)
if month is None:
raise ValueError(f"cbr_macro: неизвестное имя месяца {month_name!r} в {period_str!r}")
try:
year = int(year_str)
except ValueError as exc:
raise ValueError(f"cbr_macro: неверный год в периоде {period_str!r}") from exc
return date(year, month, 1)
def parse_period_cell(cell: object) -> date:
"""Разобрать ячейку шапки периода в первое число месяца.
ЦБ отдаёт шапку в двух формах, зависящих от файла:
* текст «Январь 2019» 02_11_New_loans_mortgage, 02_13_Rates_mortgage;
* настоящий datetime/date 02_14_Debt_mortgage.
Обе приводятся к date(y, m, 1).
"""
if isinstance(cell, datetime):
return date(cell.year, cell.month, 1)
if isinstance(cell, date):
return date(cell.year, cell.month, 1)
return parse_ru_period(str(cell))
# ---------------------------------------------------------------------------
# Unpivot листа (чистая функция — тестируется без сети/файла)
# ---------------------------------------------------------------------------
HEADER_SCAN_ROWS = 6
"""Сколько первых строк просматривать в поисках шапки периодов."""
MIN_HEADER_PERIODS = 6
"""Минимум распознанных периодов, чтобы считать строку шапкой (а не данными)."""
def _locate_header_row(rows: list[tuple[object, ...]]) -> tuple[int, dict[int, date]]:
"""Найти строку шапки периодов и колонки-периоды в ней.
Раскладка у файлов ЦБ РАЗНАЯ и жёстко фиксировать индекс строки нельзя:
* 02_11 / 02_13 строка 1 пустая, строка 2 описание, шапка в строке 3 (idx 2);
* 02_14 (задолженность) строка 1 описание, шапка уже в строке 2 (idx 1),
и периоды там datetime, а не «Январь 2019».
Ранее индекс был захардкожен на idx 2, из-за чего серия задолженности молча
давала ноль строк. Поэтому шапку ищем: берём строку с наибольшим числом
распознанных периодов среди первых HEADER_SCAN_ROWS.
"""
best_idx = -1
best_periods: dict[int, date] = {}
for row_idx, row in enumerate(rows[:HEADER_SCAN_ROWS]):
periods: dict[int, date] = {}
for col_idx, cell in enumerate(row):
if col_idx == 0 or cell is None or str(cell).strip() == "":
continue
try:
periods[col_idx] = parse_period_cell(cell)
except ValueError:
continue
if len(periods) > len(best_periods):
best_idx, best_periods = row_idx, periods
if len(best_periods) < MIN_HEADER_PERIODS:
return -1, {}
return best_idx, best_periods
def parse_mortgage_sheet(
rows: list[tuple[object, ...]], *, series_slug: str
) -> list[CbrMortgageRow]:
"""Unpivot wide-листа ЦБ (территория × месяц) в длинный ряд.
rows результат ws.iter_rows(values_only=True). Строка шапки периодов ищется
динамически (`_locate_header_row`), территории идут сразу после неё.
"""
if len(rows) < 3:
return []
header_idx, periods = _locate_header_row(rows)
if header_idx < 0:
logger.warning(
"cbr_macro: шапка периодов не найдена для series=%s — лист пропущен", series_slug
)
return []
out: list[CbrMortgageRow] = []
for data_row in rows[header_idx + 1 :]:
if not data_row or data_row[0] is None:
continue
region = str(data_row[0]).strip()
if not region:
continue
for col_idx, period_month in periods.items():
if col_idx >= len(data_row):
continue
raw_value = data_row[col_idx]
if raw_value is None:
continue
try:
value = float(raw_value)
except (TypeError, ValueError):
logger.warning(
"cbr_macro: пропуск ячейки region=%r period=%s series=%s — не число: %r",
region,
period_month,
series_slug,
raw_value,
)
continue
out.append(
CbrMortgageRow(
region=region, period_month=period_month, series=series_slug, value=value
)
)
return out
# ---------------------------------------------------------------------------
# Загрузка XLSX + upsert
# ---------------------------------------------------------------------------
_UPSERT_MORTGAGE_SQL = text("""
INSERT INTO cbr_mortgage_series (region, period_month, series, value, source, fetched_at)
VALUES (
CAST(:region AS text),
CAST(:period_month AS date),
CAST(:series AS text),
CAST(:value AS double precision),
'cbr',
now()
)
ON CONFLICT (region, period_month, series)
DO UPDATE SET
value = EXCLUDED.value
-- fetched_at НЕ трогаем (#2846 у sber_price_index): означает
-- «когда мы ВПЕРВЫЕ увидели этот период», а не время последней загрузки.
""")
def _upsert_mortgage_rows(db: Session, rows: list[CbrMortgageRow]) -> int:
for row in rows:
db.execute(
_UPSERT_MORTGAGE_SQL,
{
"region": row.region,
"period_month": row.period_month.isoformat(),
"series": row.series,
"value": row.value,
},
)
return len(rows)
def _download(url: str, *, client: httpx.Client) -> bytes:
resp = client.get(url, timeout=DOWNLOAD_TIMEOUT_SEC)
resp.raise_for_status()
return resp.content
def load_cbr_mortgage_series(
db: Session,
series: CbrMortgageSeries,
*,
client: httpx.Client | None = None,
dry_run: bool = False,
) -> dict[str, int]:
"""Скачать один XLSX-дашборд, разобрать один лист, upsert-нуть ряд.
Не коммитит коммитит caller.
"""
own_client = client is None
if own_client:
# cbr.ru отдаёт RU-сертификат НУЦ Минцифры → verify=False. Открытые данные,
# без auth/PII (прецедент: sber_index.py, domrf_kapremont_loader.py).
client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
try:
data = _download(series.url, client=client)
finally:
if own_client:
client.close()
wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
try:
if series.sheet not in wb.sheetnames:
raise ValueError(
f"cbr_macro: лист {series.sheet!r} отсутствует в {series.url} "
f"(есть: {wb.sheetnames!r})"
)
ws = wb[series.sheet]
rows_raw = list(ws.iter_rows(values_only=True))
finally:
wb.close()
parsed = parse_mortgage_sheet(rows_raw, series_slug=series.slug)
upserted = 0 if dry_run else _upsert_mortgage_rows(db, parsed)
return {"rows": len(parsed), "upserted": upserted}
def pull_cbr_mortgage(
db: Session,
*,
series_list: list[CbrMortgageSeries] | None = None,
client: httpx.Client | None = None,
dry_run: bool = False,
) -> dict[str, int]:
"""Забрать все (или выбранные) серии ипотечной статистики.
Per-series try/except: одна сбойнувшая серия логируется и не роняет остальные
(урок #1345 у sber_index: без rollback аборченная транзакция каскадит дальше).
"""
if series_list is None:
series_list = CBR_MORTGAGE_SERIES
counters = {"upserted": 0, "skipped": 0, "errors": 0}
own_client = client is None
if own_client:
client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
try:
for series in series_list:
try:
result = load_cbr_mortgage_series(db, series, client=client, dry_run=dry_run)
if result["rows"] == 0:
logger.info("cbr_macro: пустой результат для series=%s", series.slug)
counters["skipped"] += 1
else:
counters["upserted"] += result["upserted"]
logger.info(
"cbr_macro: upserted %d rows for series=%s", result["upserted"], series.slug
)
except Exception:
db.rollback()
logger.exception("cbr_macro: series=%s сбойнула — пропуск", series.slug)
counters["errors"] += 1
finally:
if own_client:
client.close()
return counters
# ---------------------------------------------------------------------------
# Ключевая ставка — SOAP
# ---------------------------------------------------------------------------
CBR_SOAP_URL = "https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx"
KEY_RATE_START = date(2013, 9, 13) # инструмент введён Советом директоров ЦБ РФ
_KEY_RATE_ENVELOPE = """<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" \
xmlns:xsd="http://www.w3.org/2001/XMLSchema" \
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<KeyRate xmlns="http://web.cbr.ru/">
<fromDate>{from_date}</fromDate>
<ToDate>{to_date}</ToDate>
</KeyRate>
</soap:Body>
</soap:Envelope>"""
def build_key_rate_envelope(from_date: date, to_date: date) -> str:
return _KEY_RATE_ENVELOPE.format(from_date=from_date.isoformat(), to_date=to_date.isoformat())
def _local_tag(tag: str) -> str:
"""Локальное имя тега без namespace-префикса ('{ns}KR''KR')."""
return tag.rsplit("}", 1)[-1]
def parse_key_rate_response(xml_text: str) -> list[tuple[date, float]]:
"""Разобрать SOAP-ответ KeyRate: строки <KR><DT>…</DT><Rate>…</Rate></KR>.
Namespace-агностично (ищем по локальному имени тега) .NET DataSet
оборачивает строки в diffgram/reset-default-ns, точный путь не гарантирован.
"""
root = ET.fromstring(xml_text)
out: list[tuple[date, float]] = []
for el in root.iter():
if _local_tag(el.tag) != "KR":
continue
dt_text: str | None = None
rate_text: str | None = None
for child in el:
name = _local_tag(child.tag)
if name == "DT":
dt_text = child.text
elif name == "Rate":
rate_text = child.text
if not dt_text or rate_text is None:
continue
try:
rate_date = datetime.fromisoformat(dt_text).date()
rate = float(rate_text)
except (ValueError, TypeError):
logger.warning(
"cbr_macro: пропуск строки KeyRate — не распознана: DT=%r Rate=%r",
dt_text,
rate_text,
)
continue
out.append((rate_date, rate))
return out
def fetch_key_rate(
client: httpx.Client, *, from_date: date, to_date: date
) -> list[tuple[date, float]]:
body = build_key_rate_envelope(from_date, to_date)
headers = {
"Content-Type": "text/xml; charset=utf-8",
"SOAPAction": "http://web.cbr.ru/KeyRate",
}
resp = client.post(CBR_SOAP_URL, content=body.encode("utf-8"), headers=headers)
resp.raise_for_status()
return parse_key_rate_response(resp.text)
_UPSERT_KEY_RATE_SQL = text("""
INSERT INTO cbr_key_rate (rate_date, rate, fetched_at)
VALUES (CAST(:rate_date AS date), CAST(:rate AS double precision), now())
ON CONFLICT (rate_date)
DO UPDATE SET
rate = EXCLUDED.rate
-- fetched_at НЕ трогаем та же логика, что и у cbr_mortgage_series.
""")
def _upsert_key_rate_rows(db: Session, rows: list[tuple[date, float]]) -> int:
for rate_date, rate in rows:
db.execute(_UPSERT_KEY_RATE_SQL, {"rate_date": rate_date.isoformat(), "rate": rate})
return len(rows)
def load_key_rate(
db: Session,
*,
from_date: date | None = None,
to_date: date | None = None,
client: httpx.Client | None = None,
dry_run: bool = False,
) -> dict[str, int]:
"""Скачать ключевую ставку за диапазон дат, upsert-нуть в cbr_key_rate.
Не коммитит коммитит caller. from_date/to_date по умолчанию вся история
инструмента (KEY_RATE_START) до сегодня.
"""
if from_date is None:
from_date = KEY_RATE_START
if to_date is None:
to_date = date.today()
own_client = client is None
if own_client:
client = httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False)
try:
rows = fetch_key_rate(client, from_date=from_date, to_date=to_date)
finally:
if own_client:
client.close()
upserted = 0 if dry_run else _upsert_key_rate_rows(db, rows)
result = {"rows": len(rows), "upserted": upserted}
logger.info("cbr_macro: key_rate load DONE (dry_run=%s): %s", dry_run, result)
return result

View file

@ -34,6 +34,25 @@ from app.services.scraper_settings import get_scraper_delay
logger = logging.getLogger(__name__)
class _PoolCurlConfig(RealScraperConfig):
"""RealScraperConfig с принудительно включённым pool-режимом curl (#2830).
`USE_PROXY_POOL_CURL` задан только контейнеру `scraper` (docker-compose.prod.yml
services.scraper.environment), а этот бэкфилл запускается ручкой
`POST /admin/scrape/cian-price-history` в контейнере `backend`, где переменной нет
`settings.use_proxy_pool_curl` = False. С ней `providers/_proxy.py::curl_proxy_url`
ИГНОРИРУЕТ переданный `proxy_provider` и уходит на статичный `SCRAPER_PROXY_URL`:
один `proxy_provider=` был бы правкой без эффекта (зелёный тест, нулевой прод).
Флаг рубильник раскатки pool-режима для планировщика, а не решение «этому пути
пул не нужен»: инцидент 2026-08-10 (#2830) — ровно про то, что нужен именно ему.
"""
@property
def use_proxy_pool_curl(self) -> bool:
return True
@dataclass
class CianPriceHistoryResult:
checked: int = 0
@ -65,13 +84,7 @@ async def backfill_cian_price_history(
# Egress через пул с учётом `scrape_proxy_source_bans` (#2830): узел выбирает
# `curl_proxy_url` внутри `fetch_detail`, он же на выходе возвращает вердикт
# (mark_banned на CianBlockedError / mark_health / release).
#
# Флаг читается из окружения как у всех (#3386 хвост): до #3387 у контейнера
# `backend` не было `USE_PROXY_POOL_CURL`, и здесь стоял подкласс с зашитым
# `use_proxy_pool_curl = True` — иначе `curl_proxy_url` игнорировал бы
# `proxy_provider`. Теперь переменная задана и сервису `backend` (compose), а
# зашитая константа делала рубильник неотключаемым ровно на этом пути.
scraper_config = RealScraperConfig()
scraper_config = _PoolCurlConfig()
proxy_provider = RealProxyProvider()
if listing_id is not None:
@ -131,7 +144,7 @@ async def backfill_cian_price_history(
# Fail-closed (#2616): пул пуст/недоступен в проде. Остальные листинги
# упрутся в то же самое — рвём батч сразу, а не 50 раз по 5 секунд с
# логом, который читается как «Циан нас блокирует».
logger.warning(
logger.error(
"cian_price_history: нет доступного прокси в пуле (%s) — батч прерван "
"на listing_id=%s (обработано %d из %d)",
exc,

View file

@ -201,7 +201,7 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
# ИМЕННО для cian/нездоровы — НЕ уходим на settings.cian_proxy_url (тот самый
# статичный узел мог быть источником бана, см. proxy_egress module docstring).
# Явный отказ вместо слепого прохода через заведомо подозрительный egress.
logger.warning(
logger.error(
"Cian cookies verify: пул прокси исчерпан для cian (%s) — verify пропущен, "
"cookies НЕ помечены протухшими, retry на следующем такте",
exc,

View file

@ -349,7 +349,6 @@ async def suggest_addresses(
limit: int = 8,
city: str | None = "Екатеринбург",
region: str | None = None,
regions: list[str] | None = None,
) -> list[DadataSuggestion]:
"""Автокомплит адресов через DaData /suggest/address.
@ -368,13 +367,6 @@ async def suggest_addresses(
БЕЗ типа («Свердловская», а тип отдельно в `region_type`="обл").
Передашь «Свердловская область» совпадений не будет, и запрос
вернёт ПУСТО без всякой ошибки (hard-filter, не boost).
regions: НЕСКОЛЬКО регионов разом `locations` у DaData это список, и
элементы в нём складываются по ИЛИ. Нужно там, где один регион даёт
заведомо неверный ответ: адрес Авито по Москве и области («Юбилейная
ул.,20Б») лежит либо в 77, либо в 50, и констрейнт из одного региона
молча притягивает подмосковный дом к московской улице. Имеет
приоритет над `region`/`city`. Имена так же БЕЗ типа («Москва»,
«Московская»).
Returns:
list[DadataSuggestion] пустой список если:
@ -402,11 +394,7 @@ async def suggest_addresses(
"query": query.strip(),
"count": max(1, min(int(limit), 20)),
}
if regions:
# Список → ИЛИ по регионам (см. docstring). Пустые имена выбрасываем:
# `{"region": ""}` — не «любой регион», а гарантированный ноль хитов.
body["locations"] = [{"region": r} for r in regions if r and r.strip()]
elif region:
if region:
# `locations` с полем region — уже жёсткий фильтр сам по себе (DaData
# ограничивает выдачу этим регионом). `restrict_value` — ТОП-LEVEL параметр
# body (не ключ внутри объекта locations) — здесь он был бы silent no-op,

View file

@ -1,157 +0,0 @@
"""Ключ города ДКП-сделки для ценовых полос (#3051 «Москва», округа).
ПРОБЛЕМА. deal_city_price_bands ключуется (region_code, city), а deals.city у
ВСЕХ 212 937 московских сделок буквально 'Москва' на весь город получалась
ОДНА полоса 34221..718870 /м². Москва неоднородна на порядок (Хамовники против
Некрасовки), поэтому единая полоса одновременно и не режет опечатки в дорогом
центре, и режет легитимный рынок на окраинах.
РЕШЕНИЕ. Ключом города становится
COALESCE(NULLIF(raw_payload->>'src_city', ''), city)
у московских сделок Росреестра raw_payload.src_city несёт муниципальный округ
('муниципальный округ Хамовники' и т.п.): заполнен у 198 600 из 212 937 сделок
(93.27%), 197 различных значений. Оставшиеся 6.73% (14 376 сделок) отдают
'Москва' и образуют СВОЮ строку-фолбэк (n=14376 tier 'full', полоса
22475..772165), а не проваливаются в глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2,
откалиброванные под Екатеринбург. Ключи дизъюнктны: либо 'муниципальный округ X',
либо ровно 'Москва' b.city = ключ_сделки всегда матчит одну строку.
ИНВАРИАНТ РЕГИОНА 66. У сделок Свердловской области src_city пуст у ВСЕХ
108 623 строк, поэтому COALESCE отдаёт city и ключ не меняется. Проверено на
проде 2026-09-10: сделок региона 66, где ключ отличается от city, 0 штук;
пересчёт по новому выражению даёт те же 383 строки полос, все совпадают с
текущими по (ppm2_min, ppm2_max, n_deals, tier).
NULL-семантика. Если raw_payload отсутствует целиком (NULL), то
NULL->>'src_city' = NULL NULLIF(NULL,'') = NULL COALESCE отдаёт city.
Если src_city есть, но пустая строка NULLIF гасит её в NULL, тот же исход.
Питоновский helper ниже повторяет эту семантику один-в-один (пустая строка
считается отсутствующей, пробелы НЕ подрезаются SQL их тоже не подрезает).
Модуль намеренно крошечный и без зависимостей: выражение обязано быть ОДНИМ на
derivation (app/tasks/deal_city_price_bands_refresh.py) и на все читающие места
(app/services/estimator.py). Три копии выражения = три места, где полосы
разъезжаются молча.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
# Имя вычисляемой колонки-ключа в SELECT'ах, которые читают сделки для питонового
# пути фильтрации (_fetch_deals → _is_plausible_deal).
DEAL_CITY_KEY_COLUMN = "city_key"
def deal_city_key_sql(alias: str = "d") -> str:
"""SQL-выражение ключа города сделки.
alias префикс таблицы deals в запросе ('d' для `FROM deals d`, '' для
`FROM deals` без алиаса, как в derivation задачи полос).
"""
prefix = f"{alias}." if alias else ""
return f"COALESCE(NULLIF({prefix}raw_payload->>'src_city', ''), {prefix}city)"
def deal_city_key(row: Mapping[str, Any]) -> str | None:
"""Питоновский эквивалент deal_city_key_sql для уже прочитанной строки сделки.
Порядок: готовая колонка DEAL_CITY_KEY_COLUMN (её считает SQL) src_city из
raw_payload, если строку прочитали вместе с payload city. Пустая строка
трактуется как отсутствие значения как NULLIF(x, '') в SQL.
"""
key = row.get(DEAL_CITY_KEY_COLUMN)
if key:
return str(key)
raw = row.get("raw_payload")
if isinstance(raw, Mapping):
src = raw.get("src_city")
if src:
return str(src)
city = row.get("city")
return str(city) if city else None
# ── Двухступенчатый поиск полосы (#3051, разрыв на деплое) ───────────────────
#
# Таблицу deal_city_price_bands наполняет НОЧНАЯ задача, а читающая сторона
# уезжает на ключ-округ сразу с деплоем. В окне между деплоем и первым рефрешем
# по региону 77 в таблице лежит ровно ОДНА строка city='Москва': 93.27%
# московских сделок искали бы ключ 'муниципальный округ X', не находили и падали
# на глобальные DEAL_MIN_PPM2=50000 / DEAL_MAX_PPM2=800000 (калибровка ЕКБ) —
# нижняя граница прыгала бы с 34221 до 50000 и молча выбрасывала легитимно
# дешёвые сделки. Это ХУЖЕ, чем было до правки.
#
# Поэтому поиск полосы ДВУХСТУПЕНЧАТЫЙ и одинаковый во всех трёх читающих местах
# (два SQL-джойна ДКП-коридора + питоновский путь _fetch_deals):
# ступень 1 — строка по ключу-округу (deal_city_key / deal_city_key_sql);
# ступень 2 — строка по deals.city;
# ступень 3 — глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2.
# Порядок деплоя перестаёт иметь значение, а округ, для которого строки ещё нет
# (свежий округ, n<10, мусорное значение src_city), деградирует в ГОРОДСКУЮ
# полосу, а не в екатеринбургскую калибровку.
#
# Ступень не расщепляется по границам: ppm2_min и ppm2_max в таблице NOT NULL
# (data/sql/178_deal_city_price_bands.sql), значит найденная строка отдаёт ОБЕ
# границы — COALESCE не может взять min из округа, а max из города.
#
# ИНВАРИАНТ РЕГИОНА 66. src_city пуст у всех его 108 623 сделок → ключ ступени 1
# равен ключу ступени 2, обе ступени находят одну и ту же строку, результат
# байт-в-байт прежний. Вторая ступень для него — тавтология, не изменение.
DEAL_CITY_BAND_ALIAS = "b" # ступень 1: строка по ключу-округу
DEAL_CITY_BAND_FALLBACK_ALIAS = "bc" # ступень 2: строка по deals.city
def deal_city_band_join_sql(alias: str = "d", indent: str = "") -> str:
"""Два LEFT JOIN'а к deal_city_price_bands: ступень 1 (округ) + ступень 2 (город).
alias префикс таблицы deals, indent отступ строк со 2-й (косметика SQL).
"""
prefix = f"{alias}." if alias else ""
b, bc = DEAL_CITY_BAND_ALIAS, DEAL_CITY_BAND_FALLBACK_ALIAS
lines = [
f"LEFT JOIN deal_city_price_bands {b}",
f" ON {b}.region_code = {prefix}region_code",
f" AND {b}.city = {deal_city_key_sql(alias)}",
f"LEFT JOIN deal_city_price_bands {bc}",
f" ON {bc}.region_code = {prefix}region_code",
f" AND {bc}.city = {prefix}city",
]
return ("\n" + indent).join(lines)
def deal_city_band_bounds_sql(
min_param: str = ":ppm_min", max_param: str = ":ppm_max", indent: str = ""
) -> str:
"""Границы полосы одним выражением: округ → город → глобальные константы."""
b, bc = DEAL_CITY_BAND_ALIAS, DEAL_CITY_BAND_FALLBACK_ALIAS
lines = [
f"BETWEEN COALESCE({b}.ppm2_min, {bc}.ppm2_min, CAST({min_param} AS int))",
f" AND COALESCE({b}.ppm2_max, {bc}.ppm2_max, CAST({max_param} AS int))",
]
return ("\n" + indent).join(lines)
def resolve_city_band(
bands: Mapping[tuple[int, str], tuple[int, int]] | None,
region_code: int,
city_key: str | None,
city: str | None,
default: tuple[int, int],
) -> tuple[int, int]:
"""Питоновский эквивалент двух LEFT JOIN'ов выше: округ → город → default.
Ступени и их порядок обязаны совпадать с SQL-версией: разъехавшийся порядок
означал бы, что питоновский фильтр сделок судит по другой полосе, чем
ДКП-коридор на тех же данных.
"""
table = bands or {}
for key in (city_key, city):
if key is None:
continue
band = table.get((region_code, key))
if band is not None:
return band
return default

View file

@ -1,157 +0,0 @@
"""Радиусные агрегаты ДТП вокруг точки — `dtp_incidents` (#3410).
Отдельный модуль, не расширение `location_index.py`: та модель считает ЦЕНОВОЙ индекс
(медиана /м² локально vs по городу) с фиксированным bbox продукт-ядра города другая
предметная область и другой контракт результата (status/coverage у location_index
завязан на `regions_mod`/`bbox_product_core`, ДТП покрывает всю область без city-bbox
ограничения источника). Общее только сам паттерн запроса (bbox-префильтр +
`ST_DWithin(geom::geography, )`) и dtp_incidents/osm_poi_ekb_local roднит один и тот же
"пустая таблица -> unavailable" graceful fallback (см. `_fetch_nearby_poi` в
location_index.py) переиспользован здесь буквально.
Bbox-префильтр здесь, в отличие от location_index (там фиксированный bbox city-ядра),
строится ВОКРУГ ТОЧКИ по радиусу (`_bbox_from_point`) dtp_incidents не ограничена
одним городом, фиксированного bbox покрытия нет. Дешёвый lat/lon BETWEEN перед
ST_DWithin тот же приём, что в шаблоне recon_geo.md п.3.
psycopg v3: `CAST(:x AS type)`, никогда `:x::type`.
"""
from __future__ import annotations
import logging
import math
from typing import Any
from pydantic import BaseModel
from sqlalchemy import text
logger = logging.getLogger(__name__)
# Радиус по умолчанию для "фактора безопасности" района — сопоставим по порядку
# величины с DEFAULT_POI_RADIUS_M=1200 в location_index.py (тот же масштаб "пешком от
# дома"), но не импортируется оттуда: разные предметные области, совпадение числа
# не должно создавать ложную связь между модулями.
DEFAULT_DTP_RADIUS_M = 1000
# Сколько лет ДТП назад учитывать по умолчанию — источник копит данные с 2015 (см.
# докстринг dtp_stat_loader.py), но "фактор безопасности" района должен отражать
# ТЕКУЩУЮ ситуацию, а не десятилетнюю историю.
DEFAULT_YEARS_LOOKBACK = 5
_METERS_PER_DEGREE_LAT = 111_320.0
def _bbox_from_point(lat: float, lon: float, radius_m: int) -> tuple[float, float, float, float]:
"""(south, north, west, east) — bbox вокруг точки радиусом radius_m (приближённо)."""
lat_delta = radius_m / _METERS_PER_DEGREE_LAT
lon_scale = math.cos(math.radians(lat))
# Защита от вырождения делителя у полюсов — для Свердловской обл. (~56-61° с.ш.)
# cos никогда не приближается к нулю, но защита дешёвая и делает функцию safe везде.
lon_delta = radius_m / (_METERS_PER_DEGREE_LAT * max(lon_scale, 0.01))
return (lat - lat_delta, lat + lat_delta, lon - lon_delta, lon + lon_delta)
class DtpAreaStats(BaseModel):
"""Результат compute_dtp_stats."""
status: str # "ok" | "unavailable" (dtp_incidents пуста — рефреш ещё не запускался)
radius_m: int
years: int
incidents_count: int
severe_count: int
dead: int
injured: int
_DTP_STATS_SQL = text(
"""
SELECT
count(*) AS incidents_count,
count(*) FILTER (WHERE severity = 'Тяжёлый') AS severe_count,
COALESCE(sum(dead), 0) AS dead_total,
COALESCE(sum(injured), 0) AS injured_total
FROM dtp_incidents
WHERE dtp_at >= NOW() - make_interval(years => CAST(:years AS integer))
AND lat BETWEEN CAST(:bbox_south AS double precision)
AND CAST(:bbox_north AS double precision)
AND lon BETWEEN CAST(:bbox_west AS double precision)
AND CAST(:bbox_east AS double precision)
AND ST_DWithin(
geom::geography,
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
CAST(:radius_m AS double precision)
)
"""
)
def compute_dtp_stats(
db: Any,
lat: float,
lon: float,
*,
radius_m: int = DEFAULT_DTP_RADIUS_M,
years: int = DEFAULT_YEARS_LOOKBACK,
) -> DtpAreaStats:
"""Посчитать агрегаты ДТП в радиусе `radius_m` вокруг точки за последние `years` лет.
Graceful fallback: `dtp_incidents` пуста (рефреш ещё не запускался на этом
окружении, source dormant по умолчанию миграция 295) -> status="unavailable",
нули НЕ фабрикуются. Ноль инцидентов при непустой таблице легитимный "ok"
результат (в радиусе правда ничего не было), отличается статусом от "нет данных".
"""
total = db.execute(text("SELECT count(*) FROM dtp_incidents")).scalar() or 0
if total == 0:
logger.warning(
"dtp_index: dtp_incidents пуста (рефреш ещё не запускался на этом "
"окружении) — unavailable, без сфабрикованных нулей"
)
return DtpAreaStats(
status="unavailable",
radius_m=radius_m,
years=years,
incidents_count=0,
severe_count=0,
dead=0,
injured=0,
)
bbox_south, bbox_north, bbox_west, bbox_east = _bbox_from_point(lat, lon, radius_m)
row = (
db.execute(
_DTP_STATS_SQL,
{
"lat": lat,
"lon": lon,
"radius_m": radius_m,
"years": years,
"bbox_south": bbox_south,
"bbox_north": bbox_north,
"bbox_west": bbox_west,
"bbox_east": bbox_east,
},
)
.mappings()
.first()
)
if row is None:
return DtpAreaStats(
status="ok",
radius_m=radius_m,
years=years,
incidents_count=0,
severe_count=0,
dead=0,
injured=0,
)
return DtpAreaStats(
status="ok",
radius_m=radius_m,
years=years,
incidents_count=int(row["incidents_count"] or 0),
severe_count=int(row["severe_count"] or 0),
dead=int(row["dead_total"] or 0),
injured=int(row["injured_total"] or 0),
)

View file

@ -1,281 +0,0 @@
"""dtp-stat.ru loader: слой ДТП по Свердловской обл. в `dtp_incidents` (#3410).
CONTEXT: карточка района МЕРЫ хочет "фактор безопасности" сколько ДТП/тяжёлых/
погибших/раненых было рядом за последние годы. dtp-stat.ru агрегирует открытые данные
ГИБДД в GeoJSON по регионам, без auth.
ИСТОЧНИК: https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip ZIP
(~5 МБ) с одним файлом `sverdlovskaia-oblast.geojson` (~71 МБ распакованного,
FeatureCollection, ~31k Point-фич). Проверено живьём 08.09.2026: HTTP 200,
5 047 685 байт. Домен обычный (не RU-gov минцифровский сертификат) verify=False
НЕ ставим, лишний.
МОНИТОРИНГ ПРОТУХАНИЯ (не реализован в этом PR зафиксировано, чтобы следующий не
считал пустую дельту багом): на момент проверки 08.09.2026 дамп источника заморожен,
Last-Modified 26.02.2026. TRUNCATE+INSERT переливает те же ~31k строк каждый рефреш
это ожидаемо, а не признак сломанного парсинга. Если нужен freshness-монитор см.
паттерн `recon_macro.md` (СберИндекс), здесь не сделан осознанно (вне скоупа #3410).
СТРИМИНГ (обязательно, файл большой): `json.load()` целиком держал бы ~71 МБ + объектный
граф в памяти разом. Используем `ijson.items(stream, "features.item")` поверх
файлового объекта из `zipfile.ZipFile.open(name)` постоянная память вне зависимости
от размера файла, каждая feature обрабатывается и отбрасывается по одной.
ПДн НЕ СОХРАНЯЕМ (осознанное решение, зафиксировано и в 294_dtp_incidents.sql):
`properties.vehicles[].participants[]` источника несёт пол/роль/нарушения физлиц
это персональные данные конкретных людей, продукту не нужны и не разрешены. Парсер
(`_parse_feature`) НИКОГДА не читает ключи `vehicles`/`participants` ни в raw-jsonb,
ни отдельной колонкой. Из `properties` берём только неперсональные агрегаты и атрибуты
происшествия (see DtpIncidentRow).
Идемпотентность: TRUNCATE + bulk INSERT в одной транзакции (тот же паттерн, что
`app/tasks/osm_poi_ekb_refresh.py` полная замена, не upsert; повторный прогон с тем
же дампом даёт тот же результат).
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
Массивы (`weather`, `nearby`, тип `text[]`) psycopg v3 адаптирует Python `list[str]`
в `text[]` напрямую через `CAST(:x AS text[])` (тот же идиом, что
`app/tasks/deactivate_stale_avito.py`).
"""
from __future__ import annotations
import io
import logging
import zipfile
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import IO, Any
import httpx
import ijson
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
SRC_URL = "https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip"
DOWNLOAD_TIMEOUT_SEC = 180
INSERT_CHUNK_SIZE = 2000
# properties.datetime — "2015-01-01 03:00:00", без явной таймзоны в источнике.
# Храним как naive → колонка timestamptz получит его в timezone сессии БД (обычно UTC).
# Источник не публикует TZ, поэтому точная привязка к МСК не гарантирована — для
# радиусных агрегатов по годам (dtp_index.py) это несущественно.
_DT_FMT = "%Y-%m-%d %H:%M:%S"
@dataclass(slots=True)
class DtpIncidentRow:
"""Одна строка ДТП — ТОЛЬКО неперсональные атрибуты (см. докстринг модуля)."""
source_id: str
dtp_at: datetime | None
category: str | None
severity: str | None
dead: int | None
injured: int | None
address: str | None
light: str | None
weather: list[str] | None
nearby: list[str] | None
lat: float
lon: float
def _parse_datetime(raw: Any) -> datetime | None:
if not isinstance(raw, str) or not raw.strip():
return None
try:
return datetime.strptime(raw.strip(), _DT_FMT)
except ValueError:
logger.warning("dtp_stat_loader: не распарсен datetime %r", raw)
return None
def _parse_str_list(raw: Any) -> list[str] | None:
if not isinstance(raw, list) or not raw:
return None
return [str(item) for item in raw if item is not None]
def _parse_int(raw: Any) -> int | None:
if isinstance(raw, bool):
return None
if isinstance(raw, int):
return raw
return None
# Санити-рамка координат. Выгрузка dtp-stat по Свердловской области содержит битые
# точки: на реальном дампе 2026-02-26 из 31 253 записей 74 лежат вне широт региона,
# в том числе 10 ровно в (0.0, 0.0) и такие значения, как (1.0, 1.0) и (41.0, 47.0).
# Пускать их в geo-таблицу нельзя: слой кормит радиусные агрегаты локации.
# Рамка намеренно шире официальных границ области (~56.0-62.3 N, ~57.2-66.2 E),
# чтобы не срезать легитимные приграничные точки.
REGION_LAT_RANGE = (55.0, 63.5)
REGION_LON_RANGE = (56.0, 67.5)
def _coords_plausible(lat: float, lon: float) -> bool:
"""Точка похожа на реальное ДТП в регионе выгрузки, а не на мусор источника."""
if lat == 0.0 and lon == 0.0:
return False
if not (REGION_LAT_RANGE[0] <= lat <= REGION_LAT_RANGE[1]):
return False
return REGION_LON_RANGE[0] <= lon <= REGION_LON_RANGE[1]
def _parse_feature(feature: dict[str, Any]) -> DtpIncidentRow | None:
"""Чистая функция: одна GeoJSON Feature -> DtpIncidentRow, либо None (пропуск).
НЕ читает `properties.vehicles` / `properties.participants` намеренно (ПДн,
см. докстринг модуля). Пропускает фичи без валидной Point-геометрии/id, а также
с координатами вне санити-рамки региона (см. `_coords_plausible`).
"""
geometry = feature.get("geometry") or {}
if geometry.get("type") != "Point":
return None
coords = geometry.get("coordinates")
if not isinstance(coords, list) or len(coords) < 2:
return None
try:
lon, lat = float(coords[0]), float(coords[1])
except (TypeError, ValueError):
return None
if not _coords_plausible(lat, lon):
return None
props = feature.get("properties") or {}
source_id = props.get("id")
if source_id is None:
return None
return DtpIncidentRow(
source_id=str(source_id),
dtp_at=_parse_datetime(props.get("datetime")),
category=props.get("category") if isinstance(props.get("category"), str) else None,
severity=props.get("severity") if isinstance(props.get("severity"), str) else None,
dead=_parse_int(props.get("dead_count")),
injured=_parse_int(props.get("injured_count")),
address=props.get("address") if isinstance(props.get("address"), str) else None,
light=props.get("light") if isinstance(props.get("light"), str) else None,
weather=_parse_str_list(props.get("weather")),
nearby=_parse_str_list(props.get("nearby")),
lat=lat,
lon=lon,
)
def iter_incidents(stream: IO[bytes]) -> Iterator[DtpIncidentRow]:
"""Потоковый парс GeoJSON FeatureCollection -> DtpIncidentRow, по одной фиче за раз.
`stream` бинарный файловый объект (из `zipfile.ZipFile.open()` или обычного
`open(path, "rb")`). Использует `ijson.items(..., "features.item")` постоянная
память, НЕ читает файл целиком.
"""
for feature in ijson.items(stream, "features.item"):
row = _parse_feature(feature)
if row is not None:
yield row
def _open_geojson_in_zip(zf: zipfile.ZipFile) -> IO[bytes]:
names = [n for n in zf.namelist() if n.lower().endswith(".geojson")]
if not names:
raise ValueError(f"zip не содержит .geojson записей: {zf.namelist()!r}")
return zf.open(names[0])
def download_dtp_zip(*, client: httpx.Client) -> bytes:
"""Скачать ZIP dtp-stat.ru. Открытые данные, без auth/PII — стандартный verify."""
resp = client.get(SRC_URL, timeout=DOWNLOAD_TIMEOUT_SEC)
resp.raise_for_status()
return resp.content
_TRUNCATE_SQL = text("TRUNCATE dtp_incidents")
_INSERT_SQL = text(
"""
INSERT INTO dtp_incidents
(source_id, dtp_at, category, severity, dead, injured, address, light,
weather, nearby, lat, lon, geom)
VALUES
(CAST(:source_id AS text), CAST(:dtp_at AS timestamptz),
CAST(:category AS text), CAST(:severity AS text),
CAST(:dead AS integer), CAST(:injured AS integer),
CAST(:address AS text), CAST(:light AS text),
CAST(:weather AS text[]), CAST(:nearby AS text[]),
CAST(:lat AS double precision), CAST(:lon AS double precision),
ST_SetSRID(ST_MakePoint(CAST(:lon AS double precision), CAST(:lat AS double precision)),
4326))
ON CONFLICT (source_id) DO NOTHING
"""
)
def _row_params(row: DtpIncidentRow) -> dict[str, Any]:
return {
"source_id": row.source_id,
"dtp_at": row.dtp_at,
"category": row.category,
"severity": row.severity,
"dead": row.dead,
"injured": row.injured,
"address": row.address,
"light": row.light,
"weather": row.weather,
"nearby": row.nearby,
"lat": row.lat,
"lon": row.lon,
}
def load_dtp_incidents(
db: Session,
*,
src_path: str | Path | None = None,
client: httpx.Client | None = None,
chunk_size: int = INSERT_CHUNK_SIZE,
dry_run: bool = False,
) -> dict[str, int]:
"""TRUNCATE dtp_incidents; потоковый парс + bulk INSERT из ZIP dtp-stat.ru.
`src_path` локальный ZIP (тесты/ручной прогон); если None качаем `SRC_URL`
через `client` (обязателен параметром, чтобы был чем подменить в тестах;
создаётся временный `httpx.Client()` если не передан).
`dry_run=True` парсит и считает строки, НЕ трогает БД (ни TRUNCATE, ни INSERT).
Не коммитит коммитит caller (app/tasks/dtp_stat_refresh.py).
"""
owns_client = client is None
client = client or httpx.Client()
try:
if src_path is not None:
with zipfile.ZipFile(src_path) as zf, _open_geojson_in_zip(zf) as stream:
rows = list(iter_incidents(stream))
else:
data = download_dtp_zip(client=client)
with zipfile.ZipFile(io.BytesIO(data)) as zf, _open_geojson_in_zip(zf) as stream:
rows = list(iter_incidents(stream))
finally:
if owns_client:
client.close()
parsed = len(rows)
inserted = 0
if not dry_run:
db.execute(_TRUNCATE_SQL)
for i in range(0, len(rows), chunk_size):
chunk = rows[i : i + chunk_size]
result = db.execute(_INSERT_SQL, [_row_params(r) for r in chunk])
inserted += result.rowcount or 0
result_counts = {"parsed": parsed, "inserted": inserted}
logger.info("dtp_stat_loader load DONE (dry_run=%s): %s", dry_run, result_counts)
return result_counts

File diff suppressed because it is too large Load diff

View file

@ -46,6 +46,7 @@ import matplotlib
matplotlib.use("Agg") # headless-safe backend — ОБЯЗАТЕЛЬНО до любого рендера: в Docker-образе
# нет GUI toolkit (Tkinter/Qt/GTK), импорт matplotlib без явного backend может тянуть их/падать.
import matplotlib.font_manager as _font_manager
import segno
from matplotlib.figure import Figure # object API, НЕ pyplot — см. _price_range_chart_svg
from matplotlib.patches import Rectangle
@ -290,9 +291,8 @@ matplotlib.rcParams["font.family"] = "Manrope"
# ── Source pseudo-logos (текстовые pill-badges с брендовыми цветами источников) ─
# Неизвестный/снятый source (напр. историческое inactive 'n1', #2204) рендерится
# через безопасный fallback в _source_pill: серый фон + «Другой источник» (#3341 —
# сырой id тоже мог бы утечь именем площадки, source.title() больше не используется)
# — код на исторических строках НЕ падает. Цвета источников — реальные бренд-цвета
# через безопасный fallback в _source_pill: серый фон + source.title() — код на
# исторических строках НЕ падает. Цвета источников — реальные бренд-цвета
# сторонних площадок (Avito/Циан/...), НЕ часть внутренней design-системы —
# намеренно не конвертируются в наши OKLCH-токены.
_SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
@ -304,59 +304,15 @@ _SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
"etazhi": ("#e30613", "#fff"), # Этажи красный
}
# Публичные лейблы — канон `frontend/src/lib/source-registry.ts::SOURCES` (#3341,
# класс #3264): клиентский PDF не должен называть площадки-источники так же, как
# веб-отчёт и лендинг перестали делать это с 31.08. Ключи — ТОЛЬКО базовые id
# (алиасы валюации/написания резолвятся через _SOURCE_CANONICAL/_canonical_source
# ДО обращения сюда — иначе два источника правды для одной площадки).
_SOURCE_DISPLAY_NAMES: dict[str, str] = {
"avito": "Источник 1",
"cian": "Источник 2",
"yandex": "Источник 3",
"domklik": "Источник 4",
"etazhi": "Источник 5",
"avito": "Avito",
"cian": "Циан",
"domklik": "Домклик · Сбер",
"yandex": "Я.Недвижимость",
"rosreestr": "Росреестр",
"etazhi": "Этажи",
}
# Алиас → базовый id. Зеркалит группировку publicLabel/dot в
# frontend/src/lib/source-registry.ts (одна площадка = один номер/цвет там же);
# правишь один реестр — проверь другой (#3341 review: без канонизации
# estimate.sources_used = [avito, avito_imv, cian, cian_valuation, ...]
# (estimator.py `_canonical_sources`, listing+valuation union) рендерил
# ДВЕ одинаковые пилюли «Источник 1, Источник 1» и серую точку у алиаса —
# ни avito_imv/cian_valuation/yandex_valuation/domclick/etagi не было ключом
# ни в _SOURCE_LOGO_COLORS, ни (после дедупа лейблов) в _SOURCE_DISPLAY_NAMES).
_SOURCE_CANONICAL: dict[str, str] = {
"avito_imv": "avito",
"cian_valuation": "cian",
"yandex_valuation": "yandex",
"domclick": "domklik",
"etagi": "etazhi",
}
def _canonical_source(source: str) -> str:
"""Алиас (valuation-вариант / альтернативное написание) → базовый id площадки."""
return _SOURCE_CANONICAL.get(source, source)
def _public_sources(ids: list[str]) -> list[str]:
"""Канонизирует id и дедуплицирует с сохранением порядка первого появления.
`estimate.sources_used` отсортированное объединение listing-id и их
valuation-алиасов (estimator.py `_canonical_sources`), напр.
`[avito, avito_imv, cian, cian_valuation, domklik, yandex, yandex_valuation]`.
Без канонизации+дедупа ДО среза `[:5]` рендерились дубли лейблов и «Источник 3»
(yandex) мог быть вытеснен алиасом другой площадки (#3341 review)."""
seen: set[str] = set()
result: list[str] = []
for raw in ids:
canon = _canonical_source(raw)
if canon not in seen:
seen.add(canon)
result.append(canon)
return result
def _source_logo_pill(source: str) -> str:
"""Source pill — мягкий HUD-чип (.source-pill, ObjectSummary.tsx dot-идиома):
@ -364,12 +320,10 @@ def _source_logo_pill(source: str) -> str:
непрозрачный цветной чип; теперь единая с остальным документом мягкая палитра
(Mera v2), бренд-цвет остаётся только акцентной точкой. Название из
_SOURCE_DISPLAY_NAMES (короткий bounded набор, max ~18 символов)
overflow-wrap на всякий случай, если source незнаком (см. fallback ниже).
Канонизирует алиасы (avito_imv avito и т.п.) ДО lookup, чтобы цвет/лейбл
совпадали с базовой площадкой (#3341 review)."""
canon = _canonical_source(source)
dot = _SOURCE_LOGO_COLORS.get(canon, (_MUTED, "#fff"))[0]
name = _SOURCE_DISPLAY_NAMES.get(canon, "Другой источник")
overflow-wrap на всякий случай, если source незнаком и попадёт .title()
произвольной длины (см. fallback ниже)."""
dot = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))[0]
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
return (
"<span class='source-pill' style='display:inline-flex;align-items:center;gap:4pt;"
f"margin:0 4pt 4pt 0;background:{_CARD_BG};color:{_BODY};vertical-align:middle;"
@ -380,13 +334,11 @@ def _source_logo_pill(source: str) -> str:
def _source_badge_inline(source: str | None) -> str:
"""Маленький source badge для table cells (без фона). Канонизирует алиасы
ДО lookup см. _source_logo_pill."""
"""Маленький source badge для table cells (без фона)."""
if not source:
return f"<span style='color:{_MUTED};'>—</span>"
canon = _canonical_source(source)
bg, fg = _SOURCE_LOGO_COLORS.get(canon, (_MUTED, "#fff"))
name = _SOURCE_DISPLAY_NAMES.get(canon, "Другой источник")
bg, fg = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
return (
f"<span style='display:inline-block;padding:1pt 4pt;background:{bg};color:{fg};"
f"font-size:{_FS_XS};font-weight:700;border-radius:2pt;'>{_html.escape(name)}</span>"
@ -489,6 +441,14 @@ def _expires_date(estimate: AggregatedEstimate) -> dt.date:
return dt.date.today() + dt.timedelta(days=30)
def _qr_code_data_url(text: str, size: int = 4) -> str:
"""QR-код как SVG data URL."""
qr = segno.make(text, error="m")
buf = io.BytesIO()
qr.save(buf, kind="svg", scale=size, dark="#1a1d23", light="#ffffff")
return f"data:image/svg+xml;base64,{base64.b64encode(buf.getvalue()).decode('ascii')}"
def _conf_label(confidence: str) -> str:
return {"high": "Высокая", "medium": "Средняя", "low": "Низкая"}.get(confidence, confidence)
@ -1216,12 +1176,12 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
advice_discount_text = (
f"Фактические сделки проходят ниже цен в объявлениях — по этому объекту "
f"на {discount_pct}% (см. «Ожидаемая цена продажи»); подтверждают Росреестр, "
f"сделки площадок и продажи агентств недвижимости"
f"ДомКлик и продажи агентств недвижимости"
)
else:
advice_discount_text = (
"Фактические сделки проходят ниже цен в объявлениях, что подтверждают "
"Росреестр, сделки площадок и продажи агентств недвижимости"
"Росреестр, ДомКлик и продажи агентств недвижимости"
)
disclaimer_html = ""
@ -1406,11 +1366,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
# count «с учётом ремонта» не существует, второе число было идентично n_total.
# Source logos (pseudo) — берём из estimate.sources_used (не захардкоженный список).
# #3341 review: sources_used — union listing+valuation алиасов (estimator.py
# `_canonical_sources`), напр. [avito, avito_imv, cian, cian_valuation, ...] —
# _public_sources канонизирует+дедуплицирует ДО среза [:5], иначе дубли пилюль
# и «Источник 3» мог быть вытеснен алиасом другой площадки.
sources_to_show = _public_sources(estimate.sources_used or [])
sources_to_show = estimate.sources_used or []
sources_html = "".join(_source_logo_pill(s) for s in sources_to_show[:5])
# Params правой колонки — параметры поиска (НЕ конкретной квартиры)
@ -1651,13 +1607,10 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
# Источники для сделок — берём из estimate.sources_used (не захардкоженный список).
# Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
# #3341 review: канонизация+дедуп (_public_sources) ДО фильтра/среза [:5] — те же
# причины, что и на странице листингов (см. коммент там).
_deal_source_keys = {"etazhi", "domklik", "rosreestr"}
canonical_sources = _public_sources(estimate.sources_used or [])
deal_sources = [s for s in canonical_sources if s in _deal_source_keys]
deal_sources = [s for s in (estimate.sources_used or []) if s in _deal_source_keys]
if not deal_sources:
deal_sources = canonical_sources
deal_sources = [s for s in (estimate.sources_used or [])]
sources_html = "".join(_source_logo_pill(s) for s in deal_sources[:5])
area = float(input_snapshot.get("area_m2", 0) or 0)
@ -1903,7 +1856,7 @@ def _build_offer_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
<td style="padding:6pt 4pt;">
<div class="bold">Расходы на рекламу</div>
<div style="font-size:{_FS_XS};color:{_MUTED};">Ежемесячное базовое продвижение объекта
на основных площадках объявлений</div>
на Циан, Авито, Я.Недвижимости</div>
</td>
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
бесплатно</td>

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