Compare commits
No commits in common. "main" and "feat/b2c-report-redesign" have entirely different histories.
main
...
feat/b2c-r
494 changed files with 16541 additions and 72326 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
#
|
||||
# ЗАЧЕМ. Публичный лендинг лежал 30–90 с на КАЖДОМ деплое МЕРЫ —
|
||||
# не потому, что подмена контейнера медленная (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
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────
|
||||
# Публичный лендинг лежал 30–90 с на КАЖДОМ деплое. Причина не в
|
||||
# том, что подмена контейнера медленная — она занимает полсекунды.
|
||||
# Причина в том, что `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) падал с
|
||||
|
|
|
|||
|
|
@ -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) ──────────────────
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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
7
.gitignore
vendored
|
|
@ -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-*/
|
||||
|
|
|
|||
31
Caddyfile
31
Caddyfile
|
|
@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ./
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 доходит до
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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__``.
|
||||
|
|
|
|||
|
|
@ -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-планировок в радиусе вокруг участка."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
"""КРТ-реквизиты для участка: застройщик / цена / градпотенциал.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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", "-"})
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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), (
|
||||
"маска поиска не видит дословно ту строку, ради которой заведён гейт"
|
||||
)
|
||||
|
|
@ -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)."
|
||||
)
|
||||
|
|
@ -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 не изменился, шаг обязан падать, а не считаться успешным"
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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 (кВА-санитайз) ───────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
257
backend/uv.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# caddy/deploy-window.caddy.snippet — ответ на окно деплоя (#3274)
|
||||
#
|
||||
# Импортируется ВНУТРЬ `handle_errors 502 503 504 { ... }` (см. apps.caddy):
|
||||
# сам по себе снипет ничего не перехватывает, он только решает, ЧТО отдать,
|
||||
# когда апстрим не отвечает.
|
||||
#
|
||||
# ЧТО ЭТО ЛЕЧИТ, А ЧТО НЕТ. Каждый деплой tradein-frontend/tradein-backend
|
||||
# оставляет окно 30–90 с, в котором контейнера просто нет: 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
|
||||
}
|
||||
|
|
@ -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` отвергнут
|
||||
# для окна 30–90 с — держать посетителя минуту в ожидании хуже честной
|
||||
# ошибки, инструмент рассчитан на разрыв в сотни миллисекунд. Правка в
|
||||
# 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
|
||||
# оставляет 30–90 с, в которые контейнера нет и посетитель видит голый 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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 нет вообще.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 пустышкой.
|
||||
|
|
|
|||
|
|
@ -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
16147
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "✓ конфиг прокси перезагружен без пересоздания контейнера — окна недоступности нет."
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"}]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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" } } ] },
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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`, а не просто добавить файл в эту папку.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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())
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Гейт: подмена tradein-frontend не возвращает окно недоступности (#3274).
|
||||
|
||||
ПОЧЕМУ. Публичный лендинг meraocenka.ru лежал 30–90 с на КАЖДОМ деплое МЕРЫ.
|
||||
Причина — НЕ медленная подмена контейнера (она стоит полсекунды), а то, что
|
||||
`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` со списком сервисов, а это и есть окно 30–90 с (#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())
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
#!/bin/sh
|
||||
# Проба окна недоступности при деплое (#3274).
|
||||
#
|
||||
# ЗАЧЕМ. Статус джобы деплоя зелёный независимо от того, видел ли посетитель
|
||||
# ошибку: подмена контейнера происходит ВНУТРИ успешного прогона. Единственный
|
||||
# честный замер — непрерывный опрос публичного адреса во время деплоя и подсчёт
|
||||
# САМОЙ ДЛИННОЙ СЕРИИ подряд идущих не-200. Приёмка #3274 сформулирована именно
|
||||
# так (комментарий от 05.09): «серия не-2xx/000 на / в окне мержа < 2 с».
|
||||
#
|
||||
# ГДЕ ЗАПУСКАТЬ — НА САМОМ ХОСТЕ (ssh poincare), не с ноутбука. Боевой периметр
|
||||
# отбивает частые серии с одного внешнего адреса: замер 11.09 дал 30–50 %
|
||||
# `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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
9
tradein-mvp/.gitignore
vendored
9
tradein-mvp/.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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®ion_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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'`,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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())
|
||||
|
|
@ -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 за один вызов) —
|
||||
для вызывающих, которым не нужно различать "попытка"/"успех" (см.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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__``.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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% за период. Показывается как тонкая референсная линия
|
||||
«коридор реальных сделок: X–Y млн»; если итоговая медиана ₽/м² выходит за
|
||||
[low,high]×slack — добавляется текстовая пометка.
|
||||
rooms + площади ±15% за период. ADVISORY: показывается как тонкая референсная
|
||||
линия «коридор реальных сделок: X–Y млн»; если итоговая медиана ₽/м² выходит
|
||||
за [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).
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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
|
|
@ -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
Loading…
Add table
Reference in a new issue