Техническая справка
Этот справочник охватывает внутреннюю архитектуру, 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)
Поток данных для запроса примерки¶
- Сайт клиента загружает
tryon-embed.jsчерез тег<script>. Виджет рендерит плавающую кнопку действия. - Пользователь нажимает виджет, выбирает одежду. Виджет кодирует оба изображения в base64 и отправляет POST на
/api/v1/tryonс заголовкомX-API-Key. - Admin API проверяет API-ключ, квоту тарифа клиента, обрабатывает изображения (ресайз, нормализация формата, удаление EXIF), сохраняет их и ставит задачу в очередь Redis.
- ML Worker получает задачу через
BRPOP, вызывает fal.ai и опрашивает статус до завершения (до 600 секунд). - После завершения воркер записывает URL результата в базу данных и вызывает зарегистрированные вебхуки.
- Виджет опрашивает
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 JWT —
scope="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:
- Валидация магических байтов (только JPEG/PNG)
- Конвертация HEIC → WebP
- Сжатие альфа-канала (белый фон)
- Нормализация ориентации EXIF
- Удаление метаданных EXIF
- Конвертация Palette → RGB/RGBA
- Ресайз до макс. 1500×1500 пкс
- Перекодирование в WebP (адаптивный рамп качества 85→55)
- Проверка минимального размера 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.
Всегда проверяйте 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 |