Перейти к содержанию

Техническая справка

Этот справочник охватывает внутреннюю архитектуру, API, модели данных, дизайн безопасности и рабочий процесс разработки платформы TryOn SaaS. Аудитория: инженеры, знакомящиеся с кодовой базой.

Источник истины

CLAUDE.md в корне репозитория — авторитетный справочник, автоматически поддерживаемый вместе с изменениями кода.


Обзор

TryOn SaaS — мультитенантная B2B-платформа виртуальной примерки одежды. Клиентские бизнесы встраивают JavaScript-виджет на страницы товаров; виджет отправляет изображения одежды и модели в Admin API, которое ставит задачу AI-вывода в очередь. ML Worker забирает задачи из Redis и вызывает fal.ai FASHN v1.5 для генерации изображений. Результаты хранятся в базе данных и возвращаются через поллинг или вебхуки.

Платформа мультитенантна: у каждого клиента есть аккаунт, тариф с лимитами генерации, API-ключи и опционально зарегистрированные вебхуки. Интерфейс администратора управляет клиентами и следит за использованием. Клиентский портал позволяет клиентам отслеживать свои задачи и управлять интеграцией.


Архитектура

Сайт клиента → Embed-скрипт (tryon-embed.js) → Admin API (:8000) → Redis Queue → ML Worker → fal.ai FASHN v1.5
                      ↓                                ↓
                 PostgreSQL 15                    Redis 7 (AOF)
          Prometheus (:9090) → Grafana (:3000)
          ML Worker → Pushgateway (:9091) → Prometheus
          Nginx (:80/:443) → Frontend (:5173) + Embed Static (/embed/)
          Telegram Bot ← AlertManager (health + worker alerts)

Поток данных для запроса примерки

  1. Сайт клиента загружает tryon-embed.js через тег <script>. Виджет рендерит плавающую кнопку действия.
  2. Пользователь нажимает виджет, выбирает одежду. Виджет кодирует оба изображения в base64 и отправляет POST на /api/v1/tryon с заголовком X-API-Key.
  3. Admin API проверяет API-ключ, квоту тарифа клиента, обрабатывает изображения (ресайз, нормализация формата, удаление EXIF), сохраняет их и ставит задачу в очередь Redis.
  4. ML Worker получает задачу через BRPOP, вызывает fal.ai и опрашивает статус до завершения (до 600 секунд).
  5. После завершения воркер записывает URL результата в базу данных и вызывает зарегистрированные вебхуки.
  6. Виджет опрашивает GET /api/v1/tryon/status/{id} до получения URL результата, затем отображает изображение примерки.

Инфраструктура

Nginx проксирует весь публичный трафик. Admin API и фронтенд — внутренние Docker-сервисы. Redis и PostgreSQL — внутренние. Prometheus собирает метрики с admin-api:8000/metrics каждые 15 секунд; ML Worker отправляет метрики в Pushgateway. Grafana читает из Prometheus и предоставляет три автоматически подготовленных дашборда.


Сервисы

Сервис Порт Технология Назначение
admin-api 8000 (внутренний) FastAPI + SQLAlchemy 2.0 (async) + Pydantic v2 Основной API: аутентификация, клиенты, задачи, биллинг, вебхуки
ml-worker внутренний Python asyncio + BRPOP loop + fal-client Обработчик AI-задач
postgres внутренний PostgreSQL 15 Основная база данных
redis внутренний Redis 7 (AOF) Очередь задач + rate limiting + дедупликация токенов
frontend внутренний React 18 + Vite + TypeScript + shadcn/ui Интерфейс администратора + клиентский портал
nginx 80/443 Nginx Обратный прокси, TLS, rate limiting, заголовки безопасности
prometheus 9090 (внутренний) Prometheus Сбор и хранение метрик
grafana 3000 (внутренний) Grafana Дашборды (авто-подготовка)
pushgateway 9091 (внутренний) Prometheus Pushgateway Получает метрики от ML Worker

Note

Все сервисы имеют restart: unless-stopped, logging: json-file (максимум 50 МБ / 5 файлов) и ограничения ресурсов.


Справочник API

Модель аутентификации

  • Токены доступа — срок жизни 15 минут, HS256, подписаны JWT_SECRET_KEY
  • Токены обновления — срок жизни 30 дней, ротируются при каждом /auth/refresh. Хранятся как bcrypt(sha256(token)). token_prefix = первые 16 символов для O(1)-поиска.
  • Защита от брутфорса — 5 неудачных попыток → IP + email заблокированы на 15 мин
  • Защита от тайминг-атакdummy_password_check() выравнивает время ответа для несуществующих пользователей
  • API-ключи — префикс tryon_ + 32 случайных символа. key_prefix = первые 20 символов.
  • Portal JWTscope="portal", отдельный от admin JWT

PyJWT, не python-jose

Проект использует PyJWT>=2.8.0. Импортировать как import jwt, ловить jwt.PyJWTError. НЕ добавлять python-jose.

Эндпоинты администратора (требуют admin JWT)

Группа Метод + путь Описание
Аутентификация POST /api/v1/auth/login Токены доступа + обновления
Аутентификация POST /api/v1/auth/refresh Ротация токена обновления
Аутентификация POST /api/v1/auth/logout Инвалидация токена обновления
Аутентификация GET /api/v1/auth/me Информация о текущем пользователе
Аутентификация POST /api/v1/auth/change-password Смена пароля
Клиенты GET/POST /api/v1/clients Список / создание клиентов
Клиенты GET/PATCH/DELETE /api/v1/clients/{id} Детали / обновление / удаление
Клиенты POST /api/v1/clients/{id}/suspend Приостановить клиента
Клиенты POST /api/v1/clients/{id}/activate Восстановить клиента
Клиенты POST /api/v1/clients/{id}/reset-usage Сбросить месячное использование
API-ключи GET /api/v1/clients/{id}/keys Список ключей (постраничный конверт)
API-ключи POST /api/v1/clients/{id}/keys Создать ключ (исходное значение показывается один раз)
API-ключи DELETE /api/v1/clients/{id}/keys/{key_id} Отозвать ключ
Тарифы GET/POST /api/v1/plans Список / создание тарифов
Тарифы GET/PATCH/DELETE /api/v1/plans/{id} CRUD тарифов
Задачи GET /api/v1/jobs Все задачи (фильтры date_from, date_to)
Задачи GET /api/v1/jobs/{id} Детали задачи
Статистика GET /api/v1/stats/overview Общая статистика платформы
Статистика GET /api/v1/stats/jobs Статистика задач
Статистика GET /api/v1/stats/clients/{id} Статистика по клиенту
Статистика GET /api/v1/stats/realtime Живая статистика из Redis

Форма ответа при получении API-ключей

GET /api/v1/clients/{id}/keys возвращает постраничный конверт: {"items": [...], "total": N, "limit": N, "offset": N}. Итерировать по .items, не по корневому объекту.

Для создания тарифа нужен slug

PlanCreate требует slug (URL-идентификатор) и generations_limit (множественное число). Фронтенд должен автоматически генерировать slug из имени тарифа. Неверные названия полей приводят к тихой ошибке HTTP 422.

Эндпоинты портала (API-ключ клиента → portal JWT)

Метод + путь Описание
POST /api/v1/portal/auth/login Вход с API-ключом → portal JWT
GET /api/v1/portal/me Информация о клиенте
GET /api/v1/portal/jobs Задачи клиента (страницы 1–∞, размер 1–100)
GET /api/v1/portal/jobs/{id} Детали задачи (ограничено клиентом)
GET /api/v1/portal/usage Использование vs квота тарифа
GET/POST /api/v1/portal/webhooks Список / создание вебхуков
DELETE /api/v1/portal/webhooks/{id} Удалить вебхук
GET /api/v1/portal/api-keys Список API-ключей

Названия событий вебхуков портала

События должны быть job.completed или job.failed — только эти значения включены в _VALID_EVENTS. Использование tryon.completed и подобных приводит к тихой ошибке HTTP 422.

Публичные эндпоинты (заголовок X-API-Key)

Метод + путь Описание
POST /api/v1/tryon Отправить запрос примерки (base64-изображения, макс. 10 МБ каждое)
GET /api/v1/tryon/status/{id} Опросить статус задачи (ограничено клиентом)
GET /api/v1/tryon/domain-check Предварительная проверка embed-скрипта

Тело запроса примерки:

{
  "model_image": "<base64 jpeg/png>",
  "garment_image": "<base64 jpeg/png>",
  "category": "tops | bottoms | dresses | outerwear",
  "mode": "balanced | quality",
  "webhook_url": "<необязательный HTTPS URL>"
}

Маппинг категорий на fal.ai

Внутренние категории переводятся перед отправкой в fal.ai: dresses → full-body, outerwear → auto, tops/bottoms передаются без изменений.

Системные эндпоинты

Путь Аутентификация Описание
GET /health Нет Детальная проверка живости (статус fal.ai ключа, storage backend, media_base_url_public)
GET /readiness Нет Строгая проверка готовности K8s — 503 если БД или Redis недоступны
GET /metrics Заблокировано nginx (403) Метрики Prometheus

Используйте /readiness для проб балансировщика нагрузки

/health — для мониторинговых дашбордов. /readiness — строгая проверка доступности; используйте её для балансировщиков нагрузки.


Справочник конфигурации

Переменная Обязательна Описание
POSTGRES_PASSWORD Да Пароль PostgreSQL
JWT_SECRET_KEY Да ≥32 символов — openssl rand -hex 32
FAL_API_KEY Да Ключ fal.ai. Формат: {uuid}:{32-char-hex}
FIRST_ADMIN_EMAIL Да Email первого администратора
FIRST_ADMIN_PASSWORD Да Пароль первого администратора
GRAFANA_ADMIN_PASSWORD Да Пароль администратора Grafana
DOMAIN Только prod Используется для MEDIA_BASE_URL в production compose
MEDIA_BASE_URL Prod Публичный URL-префикс для загрузок — должен быть доступен fal.ai
STORAGE_BACKEND Нет local (по умолчанию) или s3
S3_BUCKET Если S3 Имя бакета S3/R2/MinIO
S3_REGION Если S3 По умолчанию auto (для R2)
S3_ENDPOINT_URL Если S3 Пусто для AWS; задать для R2/MinIO
S3_ACCESS_KEY Если S3 Ключ доступа S3
S3_SECRET_KEY Если S3 Секретный ключ S3
S3_PUBLIC_URL Если S3 Префикс CDN без завершающего слеша
ENABLE_DOCS Нет false отключает /docs в production
UPLOAD_RETENTION_HOURS Нет По умолчанию 48ч для очистки медиафайлов
CORS_ORIGINS Нет JSON-массив ["https://a.com"] — НЕ через запятую

CORS_ORIGINS должен быть JSON-массивом

pydantic-settings не может разобрать строку через запятую для List[str]. Всегда используйте формат JSON-массива в .env:
CORS_ORIGINS=["https://a.com","https://b.com"]

MEDIA_BASE_URL должен быть публично доступен

Если MEDIA_BASE_URL содержит localhost или 127.0.0.1, fal.ai не сможет получить загруженные изображения. Лог запуска выдаст media_base_url_localhost_warning. В production задайте публичный домен.


Модели данных

Таблица Ключевые столбцы Примечания
users id, email, password_hash, is_admin Только пользователи-администраторы
clients id, name, plan_id, status, allowed_domains B2B-клиенты
plans id, slug, name, generations_limit, price_monthly slug обязателен при создании
api_keys id, client_id, key_prefix, key_hash, is_active key_prefix = первые 20 символов
jobs id, client_id, status, model_image_url, garment_url, result_url Статус: pending→processing→completed/failed/interrupted
refresh_tokens id, user_id, token_prefix, token_hash, expires_at token_prefix = первые 16 символов
usage_logs client_id, month, generation_count, avg_latency_ms Ежемесячно по клиенту
webhook_endpoints id, client_id, url, secret, events, is_active События: job.completed, job.failed

Дизайн безопасности

Угроза Защита
Брутфорс Блокировка после 5 попыток (Redis), окно 15 мин по IP + email
Тайминг-атаки dummy_password_check() выравнивает время ответа для несуществующих пользователей
SSRF через URL вебхуков Блокирует RFC-1918, loopback, link-local, CGNAT; DNS-проверка при регистрации
Кросс-тенантное чтение задач Поиск статуса фильтруется по client_id, соответствующему API-ключу
Злоупотребление rate limit Почасовой лимит по ключу + 30/мин по IP при отправке tryon
Секреты в логах structlog редактирует чувствительные поля
Большие изображения nginx 12m + Pydantic max_length=14_000_000
Подмена MIME Валидация магических байтов
Decompression bombs MAX_IMAGE_PIXELS = 4096×4096
Cache poisoning Cache-Control: no-store, private на всех /api/ ответах
Утечка метрик /metrics: deny all; return 403 на уровне nginx
Открытые внутренние порты Production compose закрывает порты 8000/9090/3000

Заголовки безопасности применяются с флагом always (также для ответов с ошибками): X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy, полный CSP.

Белый список доменов работает только в браузерах

validate_api_key() проверяет заголовки Origin/Referer. Server-to-server вызовы (curl, SDK) не отправляют эти заголовки — проверка домена пропускается. Если API-ключ утечёт, злоумышленник может использовать его из любой среды. Клиенты должны хранить API-ключи в секрете.


Пайплайн обработки изображений

Изображения проходят 9-шаговый пайплайн в admin/app/services/image_processor.py:

  1. Валидация магических байтов (только JPEG/PNG)
  2. Конвертация HEIC → WebP
  3. Сжатие альфа-канала (белый фон)
  4. Нормализация ориентации EXIF
  5. Удаление метаданных EXIF
  6. Конвертация Palette → RGB/RGBA
  7. Ресайз до макс. 1500×1500 пкс
  8. Перекодирование в WebP (адаптивный рамп качества 85→55)
  9. Проверка минимального размера 256×256 пкс

Защита от decompression bombs

PILImage.MAX_IMAGE_PIXELS задан равным 4096 * 4096 на уровне модуля. Изображения, превышающие 16.7 млн пикселей, отклоняются с HTTP 422 до любого декодирования.


Мониторинг

Метрики Prometheus

Метрика Метки Описание
tryon_submitted_total client_id Отправленные задачи
tryon_completed_total client_id, status Завершённые задачи
tryon_rate_limited_total reason (per_ip, limit_exceeded) Срабатывания rate limit
cleanup_files_deleted_total Файлы, удалённые сервисом очистки

Дашборды Grafana

Три дашборда автоматически подготавливаются при запуске: - Platform Overview — частота отправок, завершений, ошибок - ML Worker — задержка обработки задач, длительность вызовов fal.ai, статус heartbeat - Infrastructure — CPU, память, диск, количество подключений Redis и Postgres

Алертинг

Telegram-алерты срабатывают при: - Heartbeat воркера устарел более чем на 5 минут - Частота ошибок задач > 5% за 10 минут - БД или Redis недоступны


Тестирование

507+ тестов, покрытие ≥97%. Тесты используют in-memory БД и fakeredis.

cd admin
pytest --cov=app --cov-report=term-missing -q

Всегда проверяйте term-missing перед написанием тестов

Сначала запустите pytest --cov=app --cov-report=term-missing -q 2>&1 | grep -E "^\s+app/" | sort -k4 -t% -n | head -20. Никогда не угадывайте, что не покрыто — читайте точные непокрытые строки.

Ключевые паттерны тестов:

  • validate_api_key вызывает redis.pipeline() — тесты должны предоставить рабочий pipeline или переопределить зависимость
  • SSRF-валидатор вызывает socket.getaddrinfo() — мокировать в юнит-тестах
  • Фикстуры URL вебхуков используют https://example.com/... (резолвится в CI; new.example.com — нет)
  • FastAPI возвращает 422 (не 401) когда обязательный заголовок отсутствует целиком; 401 только когда токен есть, но невалиден

Рабочий процесс разработки

# Клонирование и запуск
git clone <repo> && cd tryon-saas
cp .env.example .env   # заполнить обязательные значения
docker-compose up -d

# Просмотр логов
docker-compose logs -f admin-api

# Запуск тестов
cd admin && pytest -x --tb=short

# Применение миграций БД вручную (авто-миграция НЕ включена)
docker-compose exec admin-api alembic upgrade head

# Линтер
cd admin && ruff check app/   # запускать из директории admin/, НЕ из корня репозитория

# Пересборка после изменений фронтенда
docker-compose build frontend && docker-compose up -d frontend

Миграции не запускаются автоматически

main.py вызывает Base.metadata.create_all (создаёт таблицы из ORM-моделей для чистых установок), но НЕ запускает alembic upgrade head. Новые столбцы из миграций требуют ручного выполнения alembic upgrade head.

Фронтенд встроен в Docker-образ

После изменения исходного кода фронтенда необходимо запустить docker-compose build frontend перед up -d. Устаревшие образы вызывают ошибки времени выполнения, когда форма ответа API изменилась, но контейнер всё ещё работает со старым фронтендом.


Известные ограничения

Ограничение Подробности
CORS_ORIGINS Должен быть JSON-массивом, не через запятую
Деплой фронтенда Пересобирать Docker-образ после каждого изменения исходного кода
Список API-ключей Постраничный конверт {items, total, limit, offset} — итерировать по .items
bcrypt 5.x несовместим с passlib — использовать bcrypt напрямую
JWT-библиотека Только PyJWT, не python-jose
Rate limiting смены пароля Не реализован (известный пробел)
ACL для S3 По умолчанию не задан — настроить политику публичного бакета отдельно
События вебхуков портала Должны быть job.completed или job.failed
avg_latency_ms Не пересчитывается при CONFLICT — показывает значение первой задачи
Таймаут поллинга fal.ai Потолок 600 сек; воркер, остановленный в середине поллинга, будет убит Docker после 35 сек grace period