← К блогу
02.05.2026 · 26 мин чтения

Архитектура Telegram Mini App с ИИ-аватарами: разбор стека на 250+ пользователей

Глубокий технический разбор production-архитектуры Telegram Mini App: authentication через WebApp.initData, интеграция T-Bank API для платежей, работа с лимитами WebView, docker-compose деплой с zero-downtime и обработка 250+ активных пользователей на FastAPI + React стеке.

Архитектура Telegram Mini App с ИИ-аватарами: разбор стека на 250+ пользователей

Введение: почему Mini App, а не классический бот

Когда перед нами встала задача создать сервис генерации ИИ-аватаров с возможностью оплаты и управления заказами, первый вопрос звучал так: делать классического Telegram-бота с inline-кнопками или сразу идти в Mini App? Классический бот ограничен UI: кнопки, текст, максимум — встроенный веб-вью через InlineKeyboardButton с url. Для сложных форм, галерей изображений, drag-and-drop загрузки фото это неудобно.

Telegram Mini Apps (ранее Web Apps) решают эту проблему: полноценное SPA-приложение внутри мессенджера, доступ к Telegram SDK для получения user ID, theme, viewport. При этом сохраняется нативный UX: пользователь не покидает Telegram, не переходит в браузер. По статистике конверсия в оплату в Mini App на 18-22% выше, чем при редиректе на внешний сайт — пользователь остаётся в привычной среде.

Наш сервис обрабатывает загрузку 10-15 фотографий пользователя, отправку их в ML-пайплайн (Stable Diffusion fine-tuning + inference), генерацию 50-100 аватаров и доставку через Telegram. За три месяца работы мы обслужили 250+ уникальных пользователей, обработали 3800+ изображений, провели 180+ успешных транзакций. Средний RPS в пиковые часы — 12-15 запросов в секунду, p95 latency API — 340 мс.

Выбор технологического стека и обоснование

Стек выбирался под критерии: быстрая разработка, простота деплоя, возможность горизонтального масштабирования, нативная поддержка async для работы с Telegram Bot API и внешними ML-сервисами.

КомпонентТехнологияОбоснование
Backend APIFastAPI 0.104Async из коробки, автогенерация OpenAPI, Pydantic для валидации, скорость разработки
ORMSQLAlchemy 2.0 (async)Зрелая экосистема, поддержка async с asyncpg драйвером
БДPostgreSQL 15JSONB для хранения метаданных изображений, полнотекстовый поиск, надёжность
Кэш/очередиRedis 7.2Сессии пользователей, rate limiting, Celery broker для фоновых задач
Task queueCelery 5.3Обработка ML-задач (fine-tuning занимает 8-12 минут), retry логика
FrontendReact 18 + ViteБыстрый dev-сервер, HMR, лёгкая интеграция с Telegram WebApp SDK
Telegram SDK@twa-dev/sdkТипизированная обёртка над window.Telegram.WebApp
ДеплойDocker ComposeПростота для MVP, volume mounts для zero-downtime, готовность к миграции на K8s

Альтернативы рассматривались: Django + DRF (отказались из-за overhead и меньшей async-зрелости на момент старта), Node.js + Express (команда сильнее в Python, ML-пайплайн на PyTorch), Go (время разработки выше, меньше готовых библиотек для ML).

Authentication flow через WebApp.initData

Ключевая особенность Mini App — аутентификация без паролей. Telegram передаёт в приложение строку initData, содержащую подписанные данные пользователя. Схема:

  1. Пользователь открывает Mini App через бота (команда /start с параметром или inline-кнопка с web_app).
  2. Telegram WebView инициализируется, в window.Telegram.WebApp.initData лежит строка вида query_id=AAHdF...&user=%7B%22id%22%3A12345...&auth_date=1703001234&hash=abc123...
  3. Frontend отправляет эту строку на backend endpoint POST /api/auth/telegram.
  4. Backend валидирует подпись через HMAC-SHA256 с секретом HMAC(bot_token, "WebAppData").
  5. Если подпись валидна и auth_date не старше 86400 секунд — создаём JWT-токен, возвращаем клиенту.
  6. Все последующие запросы идут с Authorization: Bearer <JWT>.

Код валидации на FastAPI:

import hmac
import hashlib
from urllib.parse import parse_qsl
from datetime import datetime, timedelta
from fastapi import HTTPException, status

def validate_telegram_init_data(init_data: str, bot_token: str) -> dict:
    """
    Валидирует initData от Telegram WebApp.
    Возвращает распарсенные данные пользователя или выбрасывает 401.
    
    Алгоритм:
    1. Парсим query string в словарь
    2. Извлекаем hash, остальные пары сортируем по ключу
    3. Формируем data_check_string (key=value\nkey=value...)
    4. Вычисляем secret_key = HMAC_SHA256(bot_token, "WebAppData")
    5. Вычисляем hash = HMAC_SHA256(secret_key, data_check_string)
    6. Сравниваем с переданным hash
    """
    parsed = dict(parse_qsl(init_data))
    received_hash = parsed.pop('hash', None)
    
    if not received_hash:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Missing hash in initData"
        )
    
    # Проверка auth_date (не старше 24 часов)
    auth_date = int(parsed.get('auth_date', 0))
    if datetime.utcnow() - datetime.utcfromtimestamp(auth_date) > timedelta(hours=24):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="initData expired"
        )
    
    # Формируем data_check_string
    data_check_string = '\n'.join(
        f"{k}={v}" for k, v in sorted(parsed.items())
    )
    
    # Вычисляем secret_key
    secret_key = hmac.new(
        key=b"WebAppData",
        msg=bot_token.encode(),
        digestmod=hashlib.sha256
    ).digest()
    
    # Вычисляем hash
    calculated_hash = hmac.new(
        key=secret_key,
        msg=data_check_string.encode(),
        digestmod=hashlib.sha256
    ).hexdigest()
    
    # Constant-time comparison для защиты от timing attacks
    if not hmac.compare_digest(calculated_hash, received_hash):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid hash signature"
        )
    
    # Парсим user JSON
    import json
    user_data = json.loads(parsed['user'])
    
    return {
        'telegram_id': user_data['id'],
        'first_name': user_data.get('first_name', ''),
        'last_name': user_data.get('last_name', ''),
        'username': user_data.get('username'),
        'language_code': user_data.get('language_code', 'en'),
    }

Важные моменты:

  • Используем hmac.compare_digest вместо обычного == для защиты от timing attacks.
  • Проверяем auth_date — Telegram рекомендует не принимать данные старше 24 часов.
  • Не храним bot_token в коде — читаем из переменной окружения TELEGRAM_BOT_TOKEN.
  • После валидации создаём пользователя в БД (если новый) или обновляем last_seen.

JWT генерируем с помощью python-jose, payload содержит telegram_id и exp (время жизни 7 дней). Refresh-токены не используем — при истечении пользователь просто переоткрывает Mini App, получаем новый initData.

Интеграция платежей T-Bank внутри Mini App

Telegram Bot API поддерживает встроенные платежи через провайдеров (Stripe, ЮKassa и другие), но для российского рынка мы выбрали T-Bank (ex-Тинькофф) API напрямую. Причины: lower комиссия (2.5% vs 3.5-4% через Telegram Payments), больше контроля над flow, возможность сохранять карты для рекуррентных платежей.

Схема оплаты:

  1. Пользователь выбирает пакет (например, 50 аватаров за 599 рублей), нажимает «Оплатить».
  2. Frontend отправляет POST /api/orders с package_id.
  3. Backend создаёт запись в таблице orders со статусом pending, генерирует order_id.
  4. Вызываем T-Bank API Init (создание платежа), получаем PaymentURL.
  5. Возвращаем клиенту payment_url.
  6. Frontend открывает payment_url через window.Telegram.WebApp.openLink(url) — открывается in-app browser.
  7. Пользователь вводит данные карты, подтверждает 3DS.
  8. T-Bank редиректит на наш SuccessURL (например, https://example.com/payment/success?orderId=...).
  9. Одновременно T-Bank отправляет webhook на NotificationURL с финальным статусом.
  10. Backend обрабатывает webhook, обновляет статус заказа на paid, запускает Celery-задачу генерации аватаров.

Код инициализации платежа:

import httpx
from decimal import Decimal

class TBankPaymentService:
    def __init__(self, terminal_key: str, secret_key: str):
        self.terminal_key = terminal_key
        self.secret_key = secret_key
        self.api_url = "https://securepay.tinkoff.ru/v2/"
    
    def _generate_token(self, params: dict) -> str:
        """
        Генерирует токен для подписи запроса.
        T-Bank требует: сортируем параметры + Password, конкатенируем, SHA256.
        """
        import hashlib
        
        # Добавляем Password (secret_key) и TerminalKey
        token_params = {**params, 'Password': self.secret_key}
        
        # Сортируем по ключу, конкатенируем значения
        concatenated = ''.join(
            str(token_params[k]) for k in sorted(token_params.keys())
        )
        
        return hashlib.sha256(concatenated.encode()).hexdigest()
    
    async def init_payment(
        self,
        order_id: str,
        amount: Decimal,
        description: str,
        success_url: str,
        notification_url: str
    ) -> dict:
        """
        Инициализирует платёж в T-Bank.
        Возвращает PaymentURL для редиректа пользователя.
        
        amount передаётся в копейках (599 руб = 59900 коп).
        """
        amount_cents = int(amount * 100)
        
        params = {
            'TerminalKey': self.terminal_key,
            'Amount': amount_cents,
            'OrderId': order_id,
            'Description': description,
            'SuccessURL': success_url,
            'NotificationURL': notification_url,
        }
        
        # Генерируем токен
        params['Token'] = self._generate_token(params)
        
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.api_url}Init",
                json=params,
                timeout=10.0
            )
            response.raise_for_status()
            data = response.json()
        
        if not data.get('Success'):
            raise Exception(f"T-Bank Init failed: {data.get('Message')}")
        
        return {
            'payment_id': data['PaymentId'],
            'payment_url': data['PaymentURL'],
            'status': data['Status'],
        }
    
    def validate_notification(self, notification: dict) -> bool:
        """
        Валидирует webhook от T-Bank.
        Проверяем Token в уведомлении.
        """
        received_token = notification.pop('Token', None)
        calculated_token = self._generate_token(notification)
        
        return received_token == calculated_token

Важные детали:

  • T-Bank требует amount в копейках (целое число), поэтому умножаем на 100.
  • Токен генерируется из всех параметров + Password (секретный ключ терминала).
  • В webhook обязательно валидируем Token — иначе злоумышленник может отправить фейковое уведомление об оплате.
  • NotificationURL должен быть доступен извне (не localhost), используем ngrok для разработки или публичный домен для прода.

После успешной оплаты обновляем статус заказа и отправляем пользователю сообщение через Telegram Bot API: «Оплата получена, начинаем генерацию аватаров. Это займёт 10-15 минут».

Лимиты и особенности Telegram WebView

Telegram WebView — это не полноценный браузер, а встроенный компонент на базе WebKit (iOS) и Chromium (Android/Desktop). Есть ряд ограничений, которые нужно учитывать:

Cookies и localStorage

WebView поддерживает cookies и localStorage, но с нюансами:

  • На iOS cookies могут сбрасываться при закрытии Mini App (зависит от версии Telegram).
  • localStorage работает стабильно, но ограничен 5-10 МБ.
  • Рекомендация: не храним критичные данные в localStorage, используем его только для кэша UI-состояния (выбранная тема, язык). Токен JWT храним в памяти React-компонента или в sessionStorage (очищается при закрытии вкладки).

Viewport и Safe Area

Telegram добавляет свои UI-элементы (header с названием бота, bottom bar с кнопками). Реальная высота viewport меньше, чем window.innerHeight. Telegram SDK предоставляет:

  • window.Telegram.WebApp.viewportHeight — реальная высота доступной области.
  • window.Telegram.WebApp.viewportStableHeight — высота без учёта появляющейся клавиатуры.
  • window.Telegram.WebApp.safeAreaInset — отступы для iOS (notch, home indicator).

Мы используем CSS-переменные для адаптации:

:root {
  --tg-viewport-height: 100vh;
  --tg-viewport-stable-height: 100vh;
  --tg-safe-area-inset-top: 0px;
  --tg-safe-area-inset-bottom: 0px;
}

.app-container {
  height: var(--tg-viewport-stable-height);
  padding-top: var(--tg-safe-area-inset-top);
  padding-bottom: var(--tg-safe-area-inset-bottom);
  overflow-y: auto;
}

В React при инициализации обновляем эти переменные:

useEffect(() => {
  const tg = window.Telegram.WebApp;
  
  const updateViewport = () => {
    document.documentElement.style.setProperty(
      '--tg-viewport-height',
      `${tg.viewportHeight}px`
    );
    document.documentElement.style.setProperty(
      '--tg-viewport-stable-height',
      `${tg.viewportStableHeight}px`
    );
    document.documentElement.style.setProperty(
      '--tg-safe-area-inset-bottom',
      `${tg.safeAreaInset.bottom}px`
    );
  };
  
  updateViewport();
  tg.onEvent('viewportChanged', updateViewport);
  
  return () => tg.offEvent('viewportChanged', updateViewport);
}, []);

Ограничения по размеру файлов

Telegram WebView имеет лимит на размер загружаемых файлов через <input type="file"> — около 20 МБ на файл (зависит от платформы). Для загрузки 10-15 фотографий пользователя мы:

  • Сжимаем изображения на клиенте с помощью browser-image-compression до 1920x1920, quality 0.85.
  • Отправляем по одному файлу через FormData + fetch, показываем прогресс.
  • На бэкенде дополнительно валидируем MIME-type и размер (не более 5 МБ после сжатия).

Отсутствие поддержки некоторых Web API

WebView не поддерживает:

  • Service Workers (нет офлайн-режима).
  • Push Notifications (используем Telegram Bot API для уведомлений).
  • WebRTC (если нужно — придётся открывать внешний браузер).
  • Clipboard API частично (на iOS navigator.clipboard.writeText может не работать без user gesture).

Архитектура бэкенда: FastAPI + PostgreSQL + Redis

Бэкенд состоит из трёх основных сервисов:

API Service (FastAPI)

Основной HTTP API на FastAPI. Структура проекта:

app/
├── main.py              # Точка входа, создание FastAPI app
├── config.py            # Настройки из env (pydantic BaseSettings)
├── dependencies.py      # Dependency injection (DB session, current user)
├── models/              # SQLAlchemy модели
│   ├── user.py
│   ├── order.py
│   └── avatar.py
├── schemas/             # Pydantic схемы (request/response)
├── routers/             # API endpoints
│   ├── auth.py          # POST /auth/telegram
│   ├── orders.py        # CRUD заказов
│   ├── avatars.py       # Получение сгенерированных аватаров
│   └── webhooks.py      # POST /webhooks/tbank
├── services/            # Бизнес-логика
│   ├── auth_service.py
│   ├── payment_service.py
│   └── avatar_service.py
└── tasks/               # Celery задачи
    └── generate_avatars.py

Используем async SQLAlchemy 2.0 с asyncpg драйвером. Пример модели Order:

from sqlalchemy import Column, Integer, String, Numeric, DateTime, ForeignKey, Enum
from sqlalchemy.orm import relationship
from datetime import datetime
import enum

class OrderStatus(str, enum.Enum):
    pending = "pending"
    paid = "paid"
    processing = "processing"
    completed = "completed"
    failed = "failed"

class Order(Base):
    __tablename__ = "orders"
    
    id = Column(Integer, primary_key=True)
    order_id = Column(String(64), unique=True, index=True)  # UUID для T-Bank
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    package_id = Column(Integer, ForeignKey("packages.id"), nullable=False)
    
    amount = Column(Numeric(10, 2), nullable=False)  # В рублях
    status = Column(Enum(OrderStatus), default=OrderStatus.pending, index=True)
    
    payment_id = Column(String(64), nullable=True)  # PaymentId от T-Bank
    payment_url = Column(String(512), nullable=True)
    
    created_at = Column(DateTime, default=datetime.utcnow, index=True)
    paid_at = Column(DateTime, nullable=True)
    completed_at = Column(DateTime, nullable=True)
    
    # Relationships
    user = relationship("User", back_populates="orders")
    package = relationship("Package")
    avatars = relationship("Avatar", back_populates="order")

Для rate limiting используем Redis + middleware:

from fastapi import Request, HTTPException, status
from redis.asyncio import Redis
import time

class RateLimitMiddleware:
    def __init__(self, redis: Redis, max_requests: int = 100, window: int = 60):
        self.redis = redis
        self.max_requests = max_requests
        self.window = window
    
    async def __call__(self, request: Request, call_next):
        # Получаем telegram_id из JWT (если авторизован)
        user_id = getattr(request.state, 'user_id', None)
        if not user_id:
            # Для неавторизованных — по IP
            user_id = request.client.host
        
        key = f"rate_limit:{user_id}"
        current = int(time.time())
        
        # Используем sorted set для хранения timestamps запросов
        pipe = self.redis.pipeline()
        pipe.zremrangebyscore(key, 0, current - self.window)
        pipe.zadd(key, {str(current): current})
        pipe.zcard(key)
        pipe.expire(key, self.window)
        
        results = await pipe.execute()
        request_count = results[2]
        
        if request_count > self.max_requests:
            raise HTTPException(
                status_code=status.HTTP_429_TOO_MANY_REQUESTS,
                detail=f"Rate limit exceeded: {self.max_requests} requests per {self.window}s"
            )
        
        response = await call_next(request)
        return response

Celery Worker

Обрабатывает долгие задачи (генерация аватаров). Конфигурация:

  • Broker: Redis (очередь задач).
  • Backend: PostgreSQL (результаты задач сохраняем в таблицу celery_taskmeta).
  • Concurrency: 4 worker процесса (на сервере 8 CPU cores, оставляем половину для API).
  • Task timeout: 1800 секунд (30 минут на генерацию, если ML-сервис перегружен).

Пример задачи:

from celery import Celery, Task
from app.services.ml_service import MLService
from app.models import Order, Avatar
from sqlalchemy.ext.asyncio import AsyncSession

celery_app = Celery('tasks', broker='redis://redis:6379/0')

class DatabaseTask(Task):
    """Base task с async DB session"""
    _session = None
    
    @property
    def session(self) -> AsyncSession:
        if self._session is None:
            from app.database import async_session_maker
            self._session = async_session_maker()
        return self._session

@celery_app.task(base=DatabaseTask, bind=True, max_retries=3)
def generate_avatars_task(self, order_id: int):
    """
    Генерирует аватары для заказа.
    1. Получает фото пользователя из S3
    2. Отправляет в ML-сервис (fine-tuning + inference)
    3. Сохраняет результаты в БД и S3
    4. Отправляет уведомление в Telegram
    """
    import asyncio
    
    async def _generate():
        async with self.session as session:
            order = await session.get(Order, order_id)
            if not order:
                raise ValueError(f"Order {order_id} not found")
            
            order.status = OrderStatus.processing
            await session.commit()
            
            try:
                ml_service = MLService()
                
                # Скачиваем фото пользователя
                user_photos = await ml_service.download_user_photos(order.user_id)
                
                # Fine-tuning (8-12 минут)
                model_path = await ml_service.fine_tune(user_photos)
                
                # Inference (50 аватаров, 3-5 минут)
                generated_images = await ml_service.generate_avatars(
                    model_path,
                    count=order.package.avatar_count
                )
                
                # Сохраняем в S3 и БД
                for idx, image_data in enumerate(generated_images):
                    s3_url = await ml_service.upload_to_s3(
                        image_data,
                        f"avatars/{order.user_id}/{order.order_id}/{idx}.jpg"
                    )
                    
                    avatar = Avatar(
                        order_id=order.id,
                        image_url=s3_url,
                        position=idx
                    )
                    session.add(avatar)
                
                order.status = OrderStatus.completed
                order.completed_at = datetime.utcnow()
                await session.commit()
                
                # Отправляем уведомление
                from app.services.telegram_service import send_completion_message
                await send_completion_message(order.user.telegram_id, order.id)
                
            except Exception as e:
                order.status = OrderStatus.failed
                await session.commit()
                raise self.retry(exc=e, countdown=300)  # Retry через 5 минут
    
    asyncio.run(_generate())

PostgreSQL

Схема БД включает 6 основных таблиц:

  • users — пользователи (telegram_id, username, created_at, last_seen).
  • packages — тарифные пакеты (название, цена, количество аватаров).
  • orders — заказы (связь user + package, статус, payment_id).
  • avatars — сгенерированные аватары (order_id, image_url, position).
  • user_photos — загруженные фото пользователя (user_id, s3_url, uploaded_at).
  • celery_taskmeta — результаты Celery задач (task_id, status, result).

Индексы:

  • users.telegram_id — уникальный, для быстрого поиска при аутентификации.
  • orders.status, orders.created_at — композитный, для выборки активных заказов.
  • avatars.order_id — для получения всех аватаров заказа.

Используем Alembic для миграций. Пример миграции добавления поля payment_id:

"""add payment_id to orders

Revision ID: a1b2c3d4e5f6
Revises: previous_revision
Create Date: 2024-01-15 10:30:00
"""
from alembic import op
import sqlalchemy as sa

def upgrade():
    op.add_column('orders', sa.Column('payment_id', sa.String(64), nullable=True))
    op.create_index('ix_orders_payment_id', 'orders', ['payment_id'])

def downgrade():
    op.drop_index('ix_orders_payment_id', 'orders')
    op.drop_column('orders', 'payment_id')

Frontend на React: взаимодействие с Telegram SDK

Frontend — SPA на React 18 с React Router для навигации. Сборка через Vite, деплой статики на Nginx.

Инициализация Telegram WebApp

В корневом компоненте инициализируем SDK:

import { useEffect } from 'react';
import { useTelegram } from './hooks/useTelegram';

function App() {
  const { tg, user } = useTelegram();
  
  useEffect(() => {
    // Разворачиваем WebApp на весь экран
    tg.expand();
    
    // Включаем закрывающую кнопку
    tg.BackButton.show();
    tg.BackButton.onClick(() => {
      window.history.back();
    });
    
    // Устанавливаем цвета темы
    document.documentElement.style.setProperty(
      '--tg-theme-bg-color',
      tg.themeParams.bg_color || '#ffffff'
    );
    document.documentElement.style.setProperty(
      '--tg-theme-text-color',
      tg.themeParams.text_color || '#000000'
    );
    
    // Уведомляем Telegram, что приложение готово
    tg.ready();
  }, [tg]);
  
  return (
    
{/* Router и компоненты */}
); }

Хук useTelegram:

export function useTelegram() {
  const tg = window.Telegram.WebApp;
  
  return {
    tg,
    user: tg.initDataUnsafe?.user,
    queryId: tg.initDataUnsafe?.query_id,
    initData: tg.initData,
  };
}

Аутентификация и API-клиент

Создаём axios instance с interceptor для добавления JWT:

import axios from 'axios';
import { useTelegram } from './useTelegram';

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  timeout: 30000,
});

// Interceptor для добавления токена
api.interceptors.request.use((config) => {
  const token = sessionStorage.getItem('jwt_token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// Interceptor для обработки 401
api.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      // Токен истёк, переаутентифицируемся
      const { initData } = useTelegram();
      const { data } = await axios.post(
        `${import.meta.env.VITE_API_URL}/api/auth/telegram`,
        { init_data: initData }
      );
      sessionStorage.setItem('jwt_token', data.access_token);
      
      // Повторяем исходный запрос
      error.config.headers.Authorization = `Bearer ${data.access_token}`;
      return axios.request(error.config);
    }
    return Promise.reject(error);
  }
);

export default api;

Загрузка фотографий

Компонент загрузки с прогрессом и сжатием:

import { useState } from 'react';
import imageCompression from 'browser-image-compression';
import api from '../api';

function PhotoUploader() {
  const [files, setFiles] = useState([]);
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);
  
  const handleFileChange = async (e) => {
    const selectedFiles = Array.from(e.target.files);
    
    // Сжимаем изображения
    const compressed = await Promise.all(
      selectedFiles.map(file => 
        imageCompression(file, {
          maxSizeMB: 2,
          maxWidthOrHeight: 1920,
          useWebWorker: true,
        })
      )
    );
    
    setFiles(compressed);
  };
  
  const handleUpload = async () => {
    setUploading(true);
    setProgress(0);
    
    for (let i = 0; i < files.length; i++) {
      const formData = new FormData();
      formData.append('photo', files[i]);
      
      await api.post('/api/photos/upload', formData, {
        headers: { 'Content-Type': 'multipart/form-data' },
      });
      
      setProgress(((i + 1) / files.length) * 100);
    }
    
    setUploading(false);
    // Переходим к выбору пакета
  };
  
  return (
    

Выбрано фото: {files.length}

{uploading && }
); }

Деплой через docker-compose с zero-downtime

Используем docker-compose для оркестрации сервисов. Структура:

version: '3.8'

services:
  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: avatarbox
      POSTGRES_USER: avatarbox
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U avatarbox"]
      interval: 10s
      timeout: 5s
      retries: 5
  
  redis:
    image: redis:7.2-alpine
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
  
  api:
    build:
      context: ./backend
      dockerfile: Dockerfile
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
    environment:
      DATABASE_URL: postgresql+asyncpg://avatarbox:${DB_PASSWORD}@postgres:5432/avatarbox
      REDIS_URL: redis://redis:6379/0
      TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
      TBANK_TERMINAL_KEY: ${TBANK_TERMINAL_KEY}
      TBANK_SECRET_KEY: ${TBANK_SECRET_KEY}
    volumes:
      - ./backend/app:/app/app:ro  # Read-only для безопасности
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
    deploy:
      replicas: 2  # Для zero-downtime
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first  # Запускаем новый контейнер до остановки старого
  
  celery:
    build:
      context: ./backend
      dockerfile: Dockerfile
    command: celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4
    environment:
      DATABASE_URL: postgresql+asyncpg://avatarbox:${DB_PASSWORD}@postgres:5432/avatarbox
      REDIS_URL: redis://redis:6379/0
      S3_BUCKET: ${S3_BUCKET}
      S3_ACCESS_KEY: ${S3_ACCESS_KEY}
      S3_SECRET_KEY: ${S3_SECRET_KEY}
    volumes:
      - ./backend/app:/app/app:ro
    depends_on:
      - postgres
      - redis
  
  nginx:
    image: nginx:1.25-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./frontend/dist:/usr/share/nginx/html:ro
      - ./certbot/conf:/etc/letsencrypt:ro
      - ./certbot/www:/var/www/certbot:ro
    depends_on:
      - api
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost/health"]
      interval: 30s
      timeout: 5s
      retries: 3

volumes:
  postgres_data:
  redis_data:

Zero-downtime deployment

Ключевые моменты:

  1. Healthchecks: каждый сервис имеет healthcheck. Docker Compose не направляет трафик на контейнер, пока он не healthy.
  2. Rolling update: deploy.update_config.order: start-first — сначала запускается новый контейнер, проходит healthcheck, только потом останавливается старый.
  3. Graceful shutdown: в FastAPI добавляем обработчик SIGTERM для завершения активных запросов перед остановкой.

Пример graceful shutdown в main.py:

import signal
import asyncio
from fastapi import FastAPI

app = FastAPI()

# Флаг для graceful shutdown
shutdown_event = asyncio.Event()

@app.on_event("startup")
async def startup():
    # Регистрируем обработчик SIGTERM
    loop = asyncio.get_event_loop()
    loop.add_signal_handler(signal.SIGTERM, lambda: shutdown_event.set())

@app.on_event("shutdown")
async def shutdown():
    # Ждём завершения активных запросов (максимум 30 секунд)
    try:
        await asyncio.wait_for(shutdown_event.wait(), timeout=30.0)
    except asyncio.TimeoutError:
        pass
    
    # Закрываем соединения с БД
    await database.disconnect()

Процесс деплоя

  1. Пушим новый код в Git.
  2. На сервере: git pull.
  3. Пересобираем образы: docker-compose build.
  4. Применяем миграции БД: docker-compose run --rm api alembic upgrade head.
  5. Перезапускаем сервисы: docker-compose up -d --no-deps --build api celery.
  6. Проверяем логи: docker-compose logs -f api.

Среднее время деплоя — 2-3 минуты. Downtime — 0 секунд благодаря start-first и healthchecks.

Метрики и мониторинг production-нагрузки

Для мониторинга используем связку Prometheus + Grafana. Метрики собираем через prometheus-fastapi-instrumentator:

from prometheus_fastapi_instrumentator import Instrumentator

app = FastAPI()

Instrumentator().instrument(app).expose(app, endpoint="/metrics")

Ключевые метрики:

  • http_requests_total — количество запросов по endpoint и статус-коду.
  • http_request_duration_seconds — histogram latency запросов (p50, p95, p99).
  • active_orders_count — gauge количества заказов в статусе processing.
  • celery_task_duration_seconds — время выполнения задач генерации.
  • db_connection_pool_size — размер пула соединений SQLAlchemy.

Текущие показатели (за последние 30 дней):

МетрикаЗначение
Всего пользователей250+
Всего заказов180+
Успешных транзакций180 (100%)
Средний RPS (пик)12-15
p95 latency API340 мс
p99 latency API680 мс
Среднее время генерации11 минут 20 секунд
Uptime99.8%

Узкие места:

  • ML-inference: генерация 50 аватаров занимает 3-5 минут на GPU (NVIDIA T4). При очереди из 5+ заказов время ожидания растёт до 20-25 минут. Решение: добавление второго GPU-инстанса с load balancing.
  • S3 upload: загрузка 50 изображений по 2 МБ занимает 30-40 секунд. Решение: параллельная загрузка через asyncio.gather с semaphore (не более 10 одновременно).
  • Database queries: запрос списка аватаров заказа с JOIN на таблицу orders занимал 150-200 мс. Решение: добавили индекс на avatars.order_id, latency упала до 15-20 мс.

Выводы и рекомендации

За три месяца разработки и эксплуатации Telegram Mini App с ИИ-аватарами мы получили следующие инсайты:

Что сработало хорошо

  • FastAPI + async: высокая производительность из коробки, простота разработки, автодокументация API через Swagger.
  • Telegram WebApp SDK: нативная интеграция, пользователи не покидают мессенджер, высокая конверсия в оплату.
  • T-Bank API: надёжные платежи, низкая комиссия, хорошая документация.
  • Docker Compose: достаточно для MVP и небольшой нагрузки, простота деплоя, возможность миграции на Kubernetes при росте.

Что можно улучшить

  • Кэширование: добавить Redis-кэш для часто запрашиваемых данных (список пакетов, аватары пользователя). Сейчас каждый запрос идёт в PostgreSQL.
  • CDN для статики: раздавать сгенерированные аватары через CloudFlare или AWS CloudFront вместо прямой отдачи с S3 — снизит latency на 40-50%.
  • Websockets для real-time обновлений: сейчас пользователь получает уведомление о готовности аватаров через Telegram Bot API. Можно добавить WebSocket-соединение для live-обновления прогресса генерации прямо в Mini App.
  • A/B тестирование: встроить механизм feature flags для тестирования разных UI-вариантов и ценовых стратегий.

Рекомендации для разработчиков

  1. Валидируйте initData на бэкенде: не доверяйте клиенту, всегда проверяйте HMAC-подпись.
  2. Учитывайте лимиты WebView: тестируйте на iOS и Android, viewport и Safe Area могут отличаться.
  3. Используйте healthchecks: это критично для zero-downtime deployment.
  4. Мониторьте метрики: без Prometheus/Grafana вы не увидите узкие места до того, как пользователи начнут жаловаться.
  5. Документируйте API: FastAPI автогенерирует Swagger, но добавьте примеры запросов и описания ошибок — фронтенд-разработчикам будет проще.

Telegram Mini Apps — это мощный инструмент для создания сервисов с высокой вовлечённостью пользователей. Правильная архитектура, внимание к деталям аутентификации и платежей, грамотный деплой — и вы получите стабильный production-сервис, способный обрабатывать сотни пользователей без дорогой инфраструктуры.

Готовы попробовать AvatarBox?

Создать первое видео бесплатно

Читайте также