П/ВИН

YooKassa Billing и 9 фич за одну итерацию: миграция AI Aggregator на V3.0

·9 мин чтения
YooKassa Billing и 9 фич за одну итерацию: миграция AI Aggregator на V3.0

Когда продукт переходит из стадии MVP в зрелый сервис, наступает момент, который я называю «долговым спринтом» — когда накопившиеся дизайн-решения, которые откладывались ради скорости, нужно реализовать разом. Именно это случилось с AI Aggregator (ask.gptweb.ru) при переходе с V2.0 на V3.0. Аудит выявил 9 нереализованных фич из дизайн-документов, и мы решили закрыть всё за одну большую итерацию — параллельно с третьей фазой миграции LobeChat.

Результат получился внушительным: платёжная система через YooKassa, загрузка файлов с поддержкой vision-моделей, OpenAI-совместимый API для разработчиков, сравнение моделей, ветвление диалогов, голосовые сообщения в боте, генерация изображений через DALL-E и Telegram Mini App. Всё это в одном коммите ee7c698 Implement V3.0: 9 new features. Расскажу, как мы к этому пришли и что узнали по дороге.

Контекст: что такое AI Aggregator и зачем нужен биллинг

AI Aggregator — это мультимодельный чат-сервис, который агрегирует доступ к разным языковым моделям через единый интерфейс. Монорепозиторий состоит из четырёх пакетов: packages/core (бизнес-логика и БД), web (Next.js фронтенд), bot (Telegram-бот) и litellm (прокси к моделям).

До V3.0 весь биллинг существовал только на бумаге: таблицы в БД были, роуты зарегистрированы, но внутри — пустота. Пользователи пользовались сервисом без ограничений, что в боевых условиях означало прямые убытки на токены. Пришло время закрыть этот долг.

При этом мы параллельно работали над форком LobeChat — Phase 3 плана миграции, где нужно было внедрить аналогичный биллинг уже в LobeChat-инфраструктуру. Опыт от AI Aggregator V3.0 напрямую питал решения для LobeChat, и наоборот.

Phase 0: Батчевая DB-миграция как фундамент

Первое правило больших фичевых спринтов — не мигрировать базу по кусочкам. Мы объединили все изменения схемы в одну миграцию, чтобы не плодить зависимые ALTER TABLE в продакшне.

Схема расширилась тремя ключевыми блоками:

Биллинговые таблицы:

-- billing_plans: Free/Basic/Pro
CREATE TABLE billing_plans (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  price_rub INTEGER NOT NULL,
  tokens_limit BIGINT NOT NULL,
  features JSONB DEFAULT '{}'
);
 
-- billing_payments: история транзакций
CREATE TABLE billing_payments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES users(id),
  type TEXT NOT NULL, -- 'subscription' | 'topup'
  amount_rub INTEGER NOT NULL,
  tokens_added BIGINT,
  yookassa_payment_id TEXT UNIQUE,
  status TEXT DEFAULT 'pending',
  created_at TIMESTAMPTZ DEFAULT NOW()
);

Поля для файловых вложений в сообщениях:

ALTER TABLE messages ADD COLUMN attachments JSONB DEFAULT '[]';

Поля для ветвления диалогов:

ALTER TABLE conversations 
  ADD COLUMN parent_conversation_id UUID REFERENCES conversations(id),
  ADD COLUMN branch_from_message_id UUID REFERENCES messages(id);

Этот подход — «батчевая миграция перед фичами» — сэкономил нам как минимум три потенциальных инцидента с несовместимостью схемы в разных окружениях.

Phase 1: YooKassa Billing — от заглушки к реальным платежам

YooKassa — очевидный выбор для российского рынка. В отличие от Stripe, который требует зарубежного юрлица, YooKassa работает с российскими ИП и ООО из коробки. Документация YooKassa API довольно подробная, хотя и менее полирована, чем у Stripe.

Архитектура биллинга получилась трёхуровневой:

1. Core-модуль (packages/core/src/billing.ts) — чистая бизнес-логика без зависимости от HTTP:

export async function createYooKassaPayment(params: {
  userId: string;
  type: 'subscription' | 'topup';
  planId: string;
  returnUrl: string;
}) {
  const plan = BILLING_PLANS[params.planId];
  
  const payment = await yooKassaClient.createPayment({
    amount: { value: plan.price_rub.toFixed(2), currency: 'RUB' },
    confirmation: { type: 'redirect', return_url: params.returnUrl },
    capture: true,
    description: `${plan.name} — WebGPT`,
    metadata: { userId: params.userId, planId: params.planId, type: params.type }
  });
 
  await db.insert(billingPayments).values({
    userId: params.userId,
    type: params.type,
    amountRub: plan.price_rub,
    yookassaPaymentId: payment.id,
    status: 'pending'
  });
 
  return payment;
}

2. API Routes (/api/billing/) — тонкие роуты, которые только валидируют и делегируют:

  • /api/billing/create-payment — создаёт платёж, возвращает URL для редиректа
  • /api/billing/webhook — принимает уведомления от YooKassa, обновляет статус
  • /api/billing/payments — история платежей пользователя

3. Webhook-обработчик — самая критичная часть. YooKassa присылает уведомления асинхронно, поэтому логика начисления токенов живёт именно здесь:

// app/api/billing/webhook/route.ts
export async function POST(req: Request) {
  const event = await req.json();
  
  if (event.object.status === 'succeeded') {
    const { userId, planId, type } = event.object.metadata;
    
    if (type === 'subscription') {
      const expiresAt = new Date();
      expiresAt.setMonth(expiresAt.getMonth() + 1);
      
      await updateUser(userId, {
        subscription_plan: planId,
        subscription_expires_at: expiresAt,
        tokens_balance: BILLING_PLANS[planId].tokens_limit
      });
    } else {
      // topup — просто добавляем токены
      await addTokensToUser(userId, TOPUP_PACKAGES[planId].tokens);
    }
    
    await updatePaymentStatus(event.object.id, 'succeeded');
  }
  
  return new Response('ok');
}

Тарифная структура получилась простой и понятной для пользователя: Free (0₽, 50K токенов/мес), Basic (490₽, 500K), Pro (1490₽, 5M). Плюс разовые пополнения: 199₽/500K, 699₽/2M, 1499₽/5M.

Phase 2: File Upload с поддержкой Vision-моделей

Загрузка файлов — одна из самых трудоёмких фич, потому что затрагивает весь стек: хранилище, API, модели, UI и бот.

Для хранения выбрали Supabase Storage — он уже был в инфраструктуре, добавлять новый сервис не пришлось. Документация Supabase Storage хорошо описывает политики доступа и подписанные URL.

Ключевое решение — добавить флаг supportsVision к моделям в конфиге, чтобы UI знал, когда показывать кнопку прикрепления файла:

// packages/core/src/models.ts
export const MODELS = [
  { id: 'gpt-4o', name: 'GPT-4o', supportsVision: true },
  { id: 'gpt-4o-mini', name: 'GPT-4o Mini', supportsVision: true },
  { id: 'claude-3-5-sonnet', name: 'Claude 3.5 Sonnet', supportsVision: true },
  { id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo', supportsVision: false },
  // ...
];

При отправке сообщения с вложениями, API-роут чата формирует multimodal-контент в формате OpenAI:

// Если есть вложения — конвертируем в multimodal формат
const content = attachments.length > 0
  ? [
      { type: 'text', text: message },
      ...attachments.map(url => ({
        type: 'image_url',
        image_url: { url, detail: 'auto' }
      }))
    ]
  : message;

Телеграм-бот получил обработчики фото и документов — теперь пользователь может прислать картинку в бот, и она уйдёт в модель как attachment.

Phase 3: OpenAI-совместимый API для разработчиков

Эта фича превращает AI Aggregator из пользовательского сервиса в платформу. Разработчики могут подключить свои приложения через стандартный OpenAI API, просто поменяв base_url.

Реализация — тонкая обёртка над внутренним роутингом:

// app/v1/chat/completions/route.ts
export async function POST(req: Request) {
  // Аутентификация по API-ключу
  const apiKey = req.headers.get('Authorization')?.replace('Bearer ', '');
  const user = await validateApiKey(apiKey);
  
  if (!user) return new Response('Unauthorized', { status: 401 });
  
  // Проверка лимитов биллинга
  await checkUserLimits(user.id);
  
  // Проксируем запрос через litellm
  const body = await req.json();
  return streamingChatResponse(user.id, body);
}

Управление API-ключами вынесено в настройки — пользователь может создавать и отзывать ключи, видеть их использование. Сами ключи хранятся хешированными в БД, в интерфейсе показываются только при создании.

Phase 4–9: Быстрые победы и интеграции

Сравнение моделей (Phase 4) — по сути одна страница /chat/compare с двумя колонками чата, но её часто просят пользователи, которые хотят понять разницу между GPT-4o и Claude. Добавили ссылку в сайдбар.

Ветвление диалогов (Phase 5) — кнопка «Branch» на пузыре ассистента создаёт форк разговора от этого сообщения. Полезно, когда хочешь попробовать другой промпт, не теряя оригинальный диалог. В сайдбаре ветки визуально отделены индикатором.

Голосовые сообщения в боте (Phase 6) — интеграция с Whisper через litellm для Speech-to-Text. Пользователь присылает голосовое, бот транскрибирует и отвечает как на текстовое.

Inline-режим бота (Phase 7) — позволяет использовать бота в любом чате через @webgpt_bot запрос.

Генерация изображений (Phase 9) — добавили dall-e-3 как отдельную модель в селектор с категорией «image». Чат-роут определяет, что выбрана image-модель, и вместо streaming-текста вызывает generateImage, возвращая URL картинки:

if (selectedModel.category === 'image') {
  const imageUrl = await generateImage({
    model: selectedModel.id,
    prompt: lastMessage,
    size: '1024x1024'
  });
  
  return Response.json({ 
    type: 'image',
    url: imageUrl 
  });
}

Telegram Mini App (Phase 8) — кнопка Web App в стартовом сообщении бота открывает полноценный веб-интерфейс прямо внутри Telegram. Аутентификация через initData от Telegram, верификация подписи на сервере.

Параллельная работа: замена маскота LobeChat

Параллельно с V3.0 шла работа над форком LobeChat. Одна из первых задач — дебрендинг: замена маскота LobeChat на логотип WebGPT.

Маскот жил в нескольких местах одновременно:

  • public/avatars/lobe-ai.png — оранжевый персонаж в чатах
  • public/avatars/agent-default.png — серый робот для агентов
  • public/avatars/agent-builder.png и doc-copilot.png — тематические вариации
  • Хардкод в packages/builtin-agents/src/agents/inbox/index.ts

Решение оказалось элегантным: заменить все 4 файла аватаров на logo.png WebGPT, а хардкод в коде заменить на константу BRANDING_LOGO_URL из @lobechat/business-const. Это покрыло все 40+ мест использования автоматически, без правки каждого компонента.

// До: хардкод
avatar: '/avatars/lobe-ai.png'
 
// После: через константу брендинга
import { BRANDING_LOGO_URL } from '@lobechat/business-const';
avatar: BRANDING_LOGO_URL

Результат: что получилось

Билд прошёл успешно, все новые роуты появились в Next.js output:

  • /api/billing/create-payment, /api/billing/payments, /api/billing/webhook
  • /api/upload
  • /v1/chat/completions, /v1/models
  • /api/settings/api-keys
  • /chat/compare
  • /api/conversations/[id]/branch
  • /api/auth/telegram-webapp, /tg-app

TypeScript-ошибки в боте оказались pre-existing проблемами монорепо с rootDir — не связанными с нашими изменениями. Все новые модули типизированы чисто.

Гит-история отражает объём работы: ee7c698 Implement V3.0: 9 new features — один коммит, закрывший весь долг. Следом пошли исправления критических багов в c997bc7 fix: 4 critical bugs, потом UI-редизайн в стиле glass-morphism.

Выводы и уроки

Главный урок этой итерации — батчевая миграция схемы перед фичами стоит своего времени. Когда несколько фич трогают одну и ту же таблицу (сообщения получили и attachments для Phase 2, и поля ветвления для Phase 5), гораздо чище сделать одну миграцию в самом начале. Альтернатива — цепочка ALTER TABLE в продакшне с рисками блокировок и несогласованного состояния между деплоями.

Второй урок — разделение бизнес-логики и HTTP-слоя. Всё, что можно вынести в packages/core — нужно выносить. YooKassa-логика в core-модуле означает, что её можно использовать из API-роута, из бота, из webhook-обработчика без дублирования. Это особенно важно в монорепо, где несколько приложений делят одну кодовую базу.

Третий урок — флаги возможностей на уровне модели. Добавив supportsVision: boolean к конфигу моделей, мы решили сразу несколько проблем: UI знает, когда показывать кнопку прикрепления файла; API знает, как форматировать запрос; тесты могут покрыть оба пути. Это лучше, чем хардкодить список model ID в каждом компоненте.

Четвёртый урок касается дебрендинга форков: никогда не хардкодьте брендинговые ассеты в коде. В LobeChat маскот был захардкожен в нескольких десятках мест, но ключевая проблема была в одном файле с прямым путём к PNG вместо использования константы. Один такой хардкод может пережить сотни рефакторингов и всплыть именно тогда, когда ты уверен, что всё заменил.

V3.0 закрыл технический долг и открыл путь к монетизации. Следующий шаг — аналитика использования по тарифам, A/B-тестирование onboarding-флоу для конверсии в платные планы и более глубокая интеграция LobeChat как white-label фронтенда поверх той же биллинговой инфраструктуры.

Полезные ссылки

Паша Вин
Паша Вин

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.

Связанный проект

WebGPT Telegram Bot