From b8f9213cd9f74a97b0ae1a814dd546ea3614b7aa Mon Sep 17 00:00:00 2001 From: Light1YT Date: Wed, 27 May 2026 11:59:14 +0500 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20persistent=20infra=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20backfill=20scripts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 проблемы фиксятся: 1. tradein-mvp/backend/Dockerfile копировал только COPY app — scripts/ слетал при каждом deploy, требовал manual docker cp. 2. docker-compose.prod.yml без env_file — .env.runtime не читался контейнером, переменные приходили только через host env. 3. Env-var name mismatch: compose YANDEX_GEOCODER_KEY vs script YANDEX_GEOCODER_API_KEY — script всегда видел None. Изменения: - backend/Dockerfile: COPY scripts ./scripts в builder + runner stage - docker-compose.prod.yml: * env_file: ./backend/.env.runtime (required: false) — pattern из main backend (PR #585) * убран дубль `YANDEX_GEOCODER_API_KEY: ${...}` из environment: block — он бы overrideal env_file пустым значением. Reviewer nit applied inline. - pydantic Settings (app/core/config.py): rename yandex_geocoder_key → yandex_geocoder_api_key. Case-insensitive env binding автоматически подхватывает `YANDEX_GEOCODER_API_KEY` (no Field alias нужен). - All 5 call sites в app/services/geocoder.py обновлены. - app/tasks/geocode_missing.py docstring + test mock обновлены. - backend/scripts/README.md: canonical `docker exec` commands без manual `docker cp` workflow. - DEPLOY.md + .env.example обновлены с новым именем переменной. Verified: - pytest tests/test_backfill_house_coords.py + test_audit_address_mismatch.py + test_cadastral_reverse.py → 56 pass - docker compose -f docker-compose.prod.yml config → valid syntax - pydantic binding: YANDEX_GEOCODER_API_KEY=foo → settings.yandex_geocoder_api_key=='foo' - code-reviewer LGTM (nit applied inline) После merge — на VPS уже создан /opt/gendesign/tradein-mvp/backend/.env.runtime с YANDEX_GEOCODER_API_KEY (см. PR #591 deploy). deploy.yml triggers force-recreate из-за нового image hash → env_file pick up автоматически. --- tradein-mvp/.env.example | 6 +++ tradein-mvp/DEPLOY.md | 46 +++++++++++++++++-- tradein-mvp/backend/Dockerfile | 2 + tradein-mvp/backend/app/core/config.py | 6 ++- tradein-mvp/backend/app/services/geocoder.py | 10 ++-- .../backend/app/tasks/geocode_missing.py | 2 +- tradein-mvp/backend/scripts/README.md | 34 ++++++++++++++ .../tests/services/test_cadastral_reverse.py | 2 +- tradein-mvp/docker-compose.prod.yml | 11 ++++- 9 files changed, 105 insertions(+), 14 deletions(-) diff --git a/tradein-mvp/.env.example b/tradein-mvp/.env.example index 1fd4cdf3..2c8c0c83 100644 --- a/tradein-mvp/.env.example +++ b/tradein-mvp/.env.example @@ -6,6 +6,12 @@ DATABASE_URL=postgresql+psycopg://tradein:tradein@postgres:5432/tradein CORS_ORIGINS=["http://localhost:8080","http://localhost:3000"] ENVIRONMENT=dev +# Yandex Geocoder API key (25k req/day free tier). +# Required for backfill scripts (scripts/backfill_house_coords.py + audit_address_mismatch.py). +# Empty = Nominatim fallback для backend геокодинга; backfill scripts требуют этот ключ +# и упадут с SystemExit без него. +YANDEX_GEOCODER_API_KEY= + # === Frontend (build-time) === # Backend URL для Next.js rewrites (browser обращается к /api/* — Next.js проксирует на BACKEND_URL/api/*). # В docker compose это уже выставлено в http://backend:8000 — менять не нужно. diff --git a/tradein-mvp/DEPLOY.md b/tradein-mvp/DEPLOY.md index 60ee2e2b..4ab4bb1b 100644 --- a/tradein-mvp/DEPLOY.md +++ b/tradein-mvp/DEPLOY.md @@ -50,12 +50,23 @@ import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment ## Что нужно положить в `.env.runtime` (на сервере, НЕ в git) +Два файла: + +1. `/opt/gendesign/tradein-mvp/.env.runtime` — переменные для docker compose + substitution (`${TRADEIN_POSTGRES_PASSWORD}` и т.п. в compose-файле). Читается + shell-скриптом deploy через `source .env.runtime` перед `compose up`. +2. `/opt/gendesign/tradein-mvp/backend/.env.runtime` — переменные внутри + контейнера `tradein-backend` (читаются через `env_file:` в compose). Сюда + попадают `YANDEX_GEOCODER_API_KEY`, `ADMIN_TOKEN`, `COOKIE_ENCRYPTION_KEY` — + всё, что нужно scripts/backfill_house_coords.py и application code внутри + контейнера. + ```bash # /opt/gendesign/tradein-mvp/.env.runtime TRADEIN_POSTGRES_USER=tradein TRADEIN_POSTGRES_PASSWORD=<сгенерировать openssl rand -hex 32> TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru -YANDEX_GEOCODER_KEY= # пусто пока, Nominatim fallback работает +YANDEX_GEOCODER_API_KEY= # пусто пока, Nominatim fallback работает # Encryption key for Cian session cookies (pgp_sym_encrypt / Stage 9 Calculator). # Empty = Valuation Calculator scraper disabled + /api/v1/cookies/upload returns 503. @@ -64,7 +75,29 @@ YANDEX_GEOCODER_KEY= # пусто пока, Nominatim fallback работает COOKIE_ENCRYPTION_KEY=<64-char hex> ``` -Файл создаётся вручную при первом деплое. +```bash +# /opt/gendesign/tradein-mvp/backend/.env.runtime — те же ключи которые +# читаются ВНУТРИ container'а (scripts/backfill_house_coords.py, app/*). +# Может быть симлинком на ../.env.runtime если переменные совпадают: +# ln -s ../.env.runtime /opt/gendesign/tradein-mvp/backend/.env.runtime +YANDEX_GEOCODER_API_KEY= +ADMIN_TOKEN= +COOKIE_ENCRYPTION_KEY=<64-char hex> +GENDESIGN_FDW_PASSWORD= +GLITCHTIP_DSN= +``` + +Оба файла создаются вручную при первом деплое. + +### После изменения `backend/.env.runtime` + +```bash +# .env.runtime читается на старте container — `compose restart` НЕ перечитывает. +docker compose -p gendesign-tradein -f docker-compose.prod.yml \ + up -d --force-recreate --no-deps backend +``` + +См. `.claude/rules/deploy.md` — main backend pattern идентичный. ### Обновление `COOKIE_ENCRYPTION_KEY` на существующем VPS @@ -120,15 +153,20 @@ nano Caddyfile # вставить ПЕРЕД последним handle { ... }: # import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment -# Создать .env.runtime +# Создать .env.runtime — два файла: +# 1. tradein-mvp/.env.runtime — для compose substitution на host'е cat > tradein-mvp/.env.runtime </dev/null 2>&1 || docker network create gendesign_shared cd tradein-mvp diff --git a/tradein-mvp/backend/Dockerfile b/tradein-mvp/backend/Dockerfile index bf781a85..bd277646 100644 --- a/tradein-mvp/backend/Dockerfile +++ b/tradein-mvp/backend/Dockerfile @@ -24,6 +24,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --no-dev COPY app ./app +COPY scripts ./scripts # ---- runner ---- @@ -48,6 +49,7 @@ WORKDIR /app COPY --from=builder --chown=app:app /app/.venv /app/.venv COPY --from=builder --chown=app:app /app/app /app/app +COPY --from=builder --chown=app:app /app/scripts /app/scripts USER app diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 6d6b422d..85341d6b 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -12,8 +12,10 @@ class Settings(BaseSettings): cors_origins: list[str] = ["http://localhost", "http://localhost:3000", "http://localhost:8080"] environment: str = "dev" - # Geocoder - yandex_geocoder_key: str | None = None # 25K req/day free после регистрации + # Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/ + # backfill_house_coords.py + audit_address_mismatch.py + main backend + # OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F). + yandex_geocoder_api_key: str | None = None # 25K req/day free после регистрации yandex_suggest_key: str | None = None # для frontend autocomplete (proxy через backend) # для User-Agent в Nominatim (Nominatim Usage Policy) contact_email: str = "erginrajpopxbe@outlook.com" diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 1c4c657d..aba0aaba 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -545,9 +545,9 @@ async def suggest( if cad_results: return cad_results - if settings.yandex_geocoder_key: + if settings.yandex_geocoder_api_key: try: - results = await _yandex_suggest(query, settings.yandex_geocoder_key, limit) + results = await _yandex_suggest(query, settings.yandex_geocoder_api_key, limit) if results: return results except Exception: @@ -600,9 +600,9 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None: return result # 3. Yandex (если есть key) с typo-fallback - if settings.yandex_geocoder_key: + if settings.yandex_geocoder_api_key: try: - result = await _yandex_lookup(address, settings.yandex_geocoder_key) + result = await _yandex_lookup(address, settings.yandex_geocoder_api_key) # Если результат вне ЕКБ — пробуем typo-варианты in_ekb = ( result is not None @@ -616,7 +616,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None: # Tier 2: typo-variants for variant in _typo_variants(address, limit=4): try: - result = await _yandex_lookup(variant, settings.yandex_geocoder_key) + result = await _yandex_lookup(variant, settings.yandex_geocoder_api_key) except Exception: continue if result is None: diff --git a/tradein-mvp/backend/app/tasks/geocode_missing.py b/tradein-mvp/backend/app/tasks/geocode_missing.py index 5a57a060..05845c83 100644 --- a/tradein-mvp/backend/app/tasks/geocode_missing.py +++ b/tradein-mvp/backend/app/tasks/geocode_missing.py @@ -5,7 +5,7 @@ - Future: cron job (Celery beat если bootstrap'нут / OS cron) Pattern: dedup по address (1 unique address → 1 geocode call → UPDATE all listings). -Rate limit: Nominatim 1 req/sec. Yandex 25K/day если YANDEX_GEOCODER_KEY set. +Rate limit: Nominatim 1 req/sec. Yandex 25K/day если YANDEX_GEOCODER_API_KEY set. Отличие от /admin/geocode-missing (per-ID): - Этот модуль группирует по address → меньше API calls (dedup). diff --git a/tradein-mvp/backend/scripts/README.md b/tradein-mvp/backend/scripts/README.md index 4a713ff2..6eb3407e 100644 --- a/tradein-mvp/backend/scripts/README.md +++ b/tradein-mvp/backend/scripts/README.md @@ -10,10 +10,44 @@ each row is wrapped in a SAVEPOINT (`db.begin_nested()`) per `.claude/rules/back --- +## Production usage (canonical) + +Scripts ship inside the `tradein-backend` image (PR F — `COPY scripts ./scripts` +в `backend/Dockerfile`). На VPS они уже в `/app/scripts/` — никаких manual +`docker cp` не нужно. + +`YANDEX_GEOCODER_API_KEY` подтягивается из `/opt/gendesign/tradein-mvp/backend/ +.env.runtime` через `env_file:` в `docker-compose.prod.yml` — никакого `-e` в +`docker exec` не нужно. + +```bash +# Backfill (forward geocode 4170 houses без coords) +ssh gendesign 'docker exec tradein-backend python -m scripts.backfill_house_coords --batch 2026-05-27_backfill' + +# Audit-only (reverse geocode проверка для уже geocoded houses) +ssh gendesign 'docker exec tradein-backend python -m scripts.backfill_house_coords --audit-only --batch 2026-05-27_audit' + +# Canary first +ssh gendesign 'docker exec tradein-backend python -m scripts.backfill_house_coords --limit 100 --batch canary_$(date +%F)' +``` + +После изменения `backend/.env.runtime` нужен `--force-recreate` контейнера +(см. `.claude/rules/deploy.md`): + +```bash +ssh gendesign 'cd /opt/gendesign/tradein-mvp && docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --force-recreate --no-deps backend' +``` + +--- + ## Address audit + backfill (issue #582) End-to-end address quality pipeline. Three scripts, two helpers, two SQL files. +> Локальные примеры ниже — для dev-машины с `uv run` и переменными в shell. +> На prod используй canonical `docker exec` команды из секции выше — там +> `YANDEX_GEOCODER_API_KEY` уже подгружен из `backend/.env.runtime`. + ### `audit_address_mismatch.py` — Phase 1 baseline (PR #583) Stratified-sample audit (200 EKB houses) comparing `houses.address` vs diff --git a/tradein-mvp/backend/tests/services/test_cadastral_reverse.py b/tradein-mvp/backend/tests/services/test_cadastral_reverse.py index a5454b71..f238f93d 100644 --- a/tradein-mvp/backend/tests/services/test_cadastral_reverse.py +++ b/tradein-mvp/backend/tests/services/test_cadastral_reverse.py @@ -319,7 +319,7 @@ async def test_suggest_falls_back_to_yandex_when_cadastral_empty() -> None: return_value=[yandex_result], ) as mock_yandex, ): - mock_settings.yandex_geocoder_key = "fake-key" + mock_settings.yandex_geocoder_api_key = "fake-key" results = await suggest("Малышева", db=db, limit=8) assert len(results) == 1 diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index 50c7af94..f019e401 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -36,13 +36,22 @@ services: backend: image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest} container_name: tradein-backend + # .env.runtime пишется вручную на VPS (см. DEPLOY.md / scripts/README.md). + # Контейнер читает YANDEX_GEOCODER_API_KEY, COOKIE_ENCRYPTION_KEY и пр. из + # этого файла — без него backfill scripts падают с "key required". + # required: false — compose не падает если файла нет (первый деплой). + env_file: + - path: ./backend/.env.runtime + required: false environment: DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein" PUBLIC_URL: "https://gendsgn.ru/trade-in" CORS_ORIGINS: '["https://gendsgn.ru"]' ENVIRONMENT: "production" CONTACT_EMAIL: "${TRADEIN_CONTACT_EMAIL:-tradein@gendsgn.ru}" - YANDEX_GEOCODER_KEY: "${YANDEX_GEOCODER_KEY:-}" + # Yandex Geocoder API key — читается из backend/.env.runtime (env_file). + # Раньше дублировалось здесь как ${YANDEX_GEOCODER_API_KEY:-} — убрано + # т.к. environment: overrides env_file и при пустой host-env стирает значение. # Защита /api/v1/admin/* — задаётся в .env.runtime. Пусто = открыто (dev). ADMIN_TOKEN: "${ADMIN_TOKEN:-}" # GlitchTip DSN — мониторинг ошибок (#396). Пусто = выключено. -- 2.45.3