feat(tradein): persistent infra для backfill scripts (PR F) #592

Merged
Light1YT merged 1 commit from feat/tradein-scripts-persistent-infra into main 2026-05-27 06:59:55 +00:00
9 changed files with 105 additions and 14 deletions

View file

@ -6,6 +6,12 @@ DATABASE_URL=postgresql+psycopg://tradein:tradein@postgres:5432/tradein
CORS_ORIGINS=["http://localhost:8080","http://localhost:3000"] CORS_ORIGINS=["http://localhost:8080","http://localhost:3000"]
ENVIRONMENT=dev ENVIRONMENT=dev
# Yandex Geocoder API key (25k req/day free tier).
# Required for backfill scripts (scripts/backfill_house_coords.py + audit_address_mismatch.py).
# Empty = Nominatim fallback для backend геокодинга; backfill scripts требуют этот ключ
# и упадут с SystemExit без него.
YANDEX_GEOCODER_API_KEY=
# === Frontend (build-time) === # === Frontend (build-time) ===
# Backend URL для Next.js rewrites (browser обращается к /api/* — Next.js проксирует на BACKEND_URL/api/*). # Backend URL для Next.js rewrites (browser обращается к /api/* — Next.js проксирует на BACKEND_URL/api/*).
# В docker compose это уже выставлено в http://backend:8000 — менять не нужно. # В docker compose это уже выставлено в http://backend:8000 — менять не нужно.

View file

@ -50,12 +50,23 @@ import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment
## Что нужно положить в `.env.runtime` (на сервере, НЕ в git) ## Что нужно положить в `.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 ```bash
# /opt/gendesign/tradein-mvp/.env.runtime # /opt/gendesign/tradein-mvp/.env.runtime
TRADEIN_POSTGRES_USER=tradein TRADEIN_POSTGRES_USER=tradein
TRADEIN_POSTGRES_PASSWORD=<сгенерировать openssl rand -hex 32> TRADEIN_POSTGRES_PASSWORD=<сгенерировать openssl rand -hex 32>
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
YANDEX_GEOCODER_KEY= # пусто пока, Nominatim fallback работает YANDEX_GEOCODER_API_KEY= # пусто пока, Nominatim fallback работает
# Encryption key for Cian session cookies (pgp_sym_encrypt / Stage 9 Calculator). # Encryption key for Cian session cookies (pgp_sym_encrypt / Stage 9 Calculator).
# Empty = Valuation Calculator scraper disabled + /api/v1/cookies/upload returns 503. # Empty = Valuation Calculator scraper disabled + /api/v1/cookies/upload returns 503.
@ -64,7 +75,29 @@ YANDEX_GEOCODER_KEY= # пусто пока, Nominatim fallback работает
COOKIE_ENCRYPTION_KEY=<64-char hex> 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=<key или пусто>
ADMIN_TOKEN=<token или пусто>
COOKIE_ENCRYPTION_KEY=<64-char hex>
GENDESIGN_FDW_PASSWORD=<password или пусто>
GLITCHTIP_DSN=<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 ### Обновление `COOKIE_ENCRYPTION_KEY` на существующем VPS
@ -120,15 +153,20 @@ nano Caddyfile
# вставить ПЕРЕД последним handle { ... }: # вставить ПЕРЕД последним handle { ... }:
# import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment # 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 <<EOF cat > tradein-mvp/.env.runtime <<EOF
TRADEIN_POSTGRES_USER=tradein TRADEIN_POSTGRES_USER=tradein
TRADEIN_POSTGRES_PASSWORD=$(openssl rand -hex 32) TRADEIN_POSTGRES_PASSWORD=$(openssl rand -hex 32)
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
YANDEX_GEOCODER_KEY= YANDEX_GEOCODER_API_KEY=
EOF EOF
chmod 600 tradein-mvp/.env.runtime chmod 600 tradein-mvp/.env.runtime
# 2. tradein-mvp/backend/.env.runtime — для env_file внутри container'а.
# Симлинк работает если переменные одни и те же; иначе создать отдельный файл.
ln -sf ../.env.runtime tradein-mvp/backend/.env.runtime
# Запустить stack первый раз вручную (после этого GitHub Actions сам) # Запустить stack первый раз вручную (после этого GitHub Actions сам)
docker network inspect gendesign_shared >/dev/null 2>&1 || docker network create gendesign_shared docker network inspect gendesign_shared >/dev/null 2>&1 || docker network create gendesign_shared
cd tradein-mvp cd tradein-mvp

View file

@ -24,6 +24,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --no-dev uv sync --no-dev
COPY app ./app COPY app ./app
COPY scripts ./scripts
# ---- runner ---- # ---- runner ----
@ -48,6 +49,7 @@ WORKDIR /app
COPY --from=builder --chown=app:app /app/.venv /app/.venv 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/app /app/app
COPY --from=builder --chown=app:app /app/scripts /app/scripts
USER app USER app

View file

@ -12,8 +12,10 @@ class Settings(BaseSettings):
cors_origins: list[str] = ["http://localhost", "http://localhost:3000", "http://localhost:8080"] cors_origins: list[str] = ["http://localhost", "http://localhost:3000", "http://localhost:8080"]
environment: str = "dev" environment: str = "dev"
# Geocoder # Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/
yandex_geocoder_key: str | None = None # 25K req/day free после регистрации # 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) yandex_suggest_key: str | None = None # для frontend autocomplete (proxy через backend)
# для User-Agent в Nominatim (Nominatim Usage Policy) # для User-Agent в Nominatim (Nominatim Usage Policy)
contact_email: str = "erginrajpopxbe@outlook.com" contact_email: str = "erginrajpopxbe@outlook.com"

View file

@ -545,9 +545,9 @@ async def suggest(
if cad_results: if cad_results:
return cad_results return cad_results
if settings.yandex_geocoder_key: if settings.yandex_geocoder_api_key:
try: try:
results = await _yandex_suggest(query, settings.yandex_geocoder_key, limit) results = await _yandex_suggest(query, settings.yandex_geocoder_api_key, limit)
if results: if results:
return results return results
except Exception: except Exception:
@ -600,9 +600,9 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
return result return result
# 3. Yandex (если есть key) с typo-fallback # 3. Yandex (если есть key) с typo-fallback
if settings.yandex_geocoder_key: if settings.yandex_geocoder_api_key:
try: try:
result = await _yandex_lookup(address, settings.yandex_geocoder_key) result = await _yandex_lookup(address, settings.yandex_geocoder_api_key)
# Если результат вне ЕКБ — пробуем typo-варианты # Если результат вне ЕКБ — пробуем typo-варианты
in_ekb = ( in_ekb = (
result is not None result is not None
@ -616,7 +616,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
# Tier 2: typo-variants # Tier 2: typo-variants
for variant in _typo_variants(address, limit=4): for variant in _typo_variants(address, limit=4):
try: try:
result = await _yandex_lookup(variant, settings.yandex_geocoder_key) result = await _yandex_lookup(variant, settings.yandex_geocoder_api_key)
except Exception: except Exception:
continue continue
if result is None: if result is None:

View file

@ -5,7 +5,7 @@
- Future: cron job (Celery beat если bootstrap'нут / OS cron) - Future: cron job (Celery beat если bootstrap'нут / OS cron)
Pattern: dedup по address (1 unique address 1 geocode call UPDATE all listings). 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): Отличие от /admin/geocode-missing (per-ID):
- Этот модуль группирует по address меньше API calls (dedup). - Этот модуль группирует по address меньше API calls (dedup).

View file

@ -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) ## Address audit + backfill (issue #582)
End-to-end address quality pipeline. Three scripts, two helpers, two SQL files. 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) ### `audit_address_mismatch.py` — Phase 1 baseline (PR #583)
Stratified-sample audit (200 EKB houses) comparing `houses.address` vs Stratified-sample audit (200 EKB houses) comparing `houses.address` vs

View file

@ -319,7 +319,7 @@ async def test_suggest_falls_back_to_yandex_when_cadastral_empty() -> None:
return_value=[yandex_result], return_value=[yandex_result],
) as mock_yandex, ) 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) results = await suggest("Малышева", db=db, limit=8)
assert len(results) == 1 assert len(results) == 1

View file

@ -36,13 +36,22 @@ services:
backend: backend:
image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest} image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest}
container_name: tradein-backend 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: environment:
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein" DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein"
PUBLIC_URL: "https://gendsgn.ru/trade-in" PUBLIC_URL: "https://gendsgn.ru/trade-in"
CORS_ORIGINS: '["https://gendsgn.ru"]' CORS_ORIGINS: '["https://gendsgn.ru"]'
ENVIRONMENT: "production" ENVIRONMENT: "production"
CONTACT_EMAIL: "${TRADEIN_CONTACT_EMAIL:-tradein@gendsgn.ru}" 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). # Защита /api/v1/admin/* — задаётся в .env.runtime. Пусто = открыто (dev).
ADMIN_TOKEN: "${ADMIN_TOKEN:-}" ADMIN_TOKEN: "${ADMIN_TOKEN:-}"
# GlitchTip DSN — мониторинг ошибок (#396). Пусто = выключено. # GlitchTip DSN — мониторинг ошибок (#396). Пусто = выключено.