Большинство наших «проектов мечты» умирают не потому, что идея плохая, а потому что мы останавливаемся на уровне «ну вот, фронт есть, бэк вроде тоже, как-нибудь допилю оплаты и выложу». Не допиливаем. Потому что платежи, вебхуки, витрина, SEO, публикации — это уже не «интересный код», а «организационная скука».

Проект как раз про то, чтобы скучное сделать готовым и многоразовым. Мы один раз собираем связку: AI → Django/DRF → ЮKassa → деплой → Web Stories → SEO, а дальше в неё можно подставлять вашу идею — не только Mermaid. Mermaid здесь как манекен: на нём удобно показывать, куда вешать оплату, куда прикручивать экспорт, где пускать трафик.

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

1. Философия проекта: почему микро-SaaS и при чём здесь ваша идея

Мы не делаем “проект про Mermaid”. Мы делаем каркас для монетизации вашей идеи. Mermaid выбран, потому что он наглядный: написал текст → получил диаграмму → экспортнул SVG → взял деньги. На его месте может быть «генератор учебных планов», «сборщик контент-постов», «AI-подсказки для разработчиков» — механизм тот же.

Что ломаем:

  • путь «сначала идеальный UI, потом когда-нибудь монетизация»;

  • привычку откладывать платежи «на следующую версию»;

  • бесконечный выбор стека.

Что даём вместо:

  1. Короткий цикл: идея → минимальный интерфейс → платёж → доработка по данным.

  2. Единый вход: Next.js, который можно показать и на GH Pages, и в проде.

  3. Нормальный бэкенд: Django/DRF, который не стыдно развивать.

  4. Реальные платежи: ЮKassa с вебхуками и подписками.

  5. Трафик: Google Web Stories + SEO, чтобы не жить в пустоте.

Формула простая: рулит идея, а не деньги. Деньги всего лишь подтверждают, что идея кому-то нужна. Поэтому вся дальнейшая техника в статье — это обслуживание одной задачи: сделать так, чтобы вашу идею можно было не только показать, но и купить.

2. Архитектура: как всё устроено под капотом

Технический стек и почему именно он:

  • Frontend: Next.js (SSR, статика, Web Stories)

  • Backend: Django/DRF (быстрое прототипирование API)

  • Платежи: ЮKassa (вебхуки, подписки, работа с р/ф)

  • Хостинг: Render (бэкенд/фронтенд) + GitHub Pages (SEO-витрина)

Вот как выглядит общая архитектура (диаграмма сгенерирована нашим же проектом):

graph TD
    A[Пользователь] --> B[Web Stories Витрина]
    B --> C[Next.js Фронтенд]
    C --> D[Django API]
    D --> E[LLM Модели]
    D --> F[ЮKassa]
    F --> G[Вебхуки]
    G --> D
    D --> H[База данных]
    C --> I[Статика GitHub Pages]

3. Аутентификация: NextAuth + Django JWT

Одна из сложных задач — сделать бесшовную аутентификацию между Next.js и Django. Решение: NextAuth для фронтенда и кастомные JWT-токены для бэкенда.

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

# Django - кастомный вход по email
class CustomLoginView(APIView):
    def post(self, request):
        email = request.data.get('email')
        password = request.data.get('password')
        user = authenticate(request, email=email, password=password)
        if user:
            refresh = RefreshToken.for_user(user)
            return Response({
                'access': str(refresh.access_token),
                'refresh': str(refresh),
                'user': UserSerializer(user).data
            })
// Next.js - перехватчик запросов с авто-обновлением токена
apiClient.interceptors.request.use(async config => {
  const session = await getSession();
  
  if (session?.accessToken && isTokenExpired(session.accessToken)) {
    try {
      const { data } = await axios.post(
        `${baseURL}/api/auth/refresh/`,
        { refresh: session.refreshToken }
      );
      session.accessToken = data.accessToken;
      session.refreshToken = data.refreshToken;
    } catch {
      handleSignOut();
      throw new axios.Cancel('Сессия истекла');
    }
  }
  
  if (session?.accessToken) {
    config.headers["Authorization"] = `Bearer ${session.accessToken}`;
  }
  
  return config;
});

4. LLM-агрегатор: умный пул моделей

Вместо привязки к одному провайдеру AI, я сделал агрегатор, который работает с десятками моделей через OpenRouter. Это даёт гибкость и отказоустойчивость.

sequenceDiagram
    participant User
    participant Frontend
    participant Django
    participant OpenRouter
    participant Cache

    User->>Frontend: Отправляет запрос
    Frontend->>Django: POST /api/chat/questions/
    Django->>Cache: Проверяет кэш
    alt В кэше есть ответ
        Cache-->>Django: Возвращает кэшированный результат
    else Нет в кэше
        Django->>OpenRouter: Запрос к AI-модели
        OpenRouter-->>Django: Ответ
        Django->>Cache: Сохраняет в кэш (1 час)
    end
    Django-->>Frontend: Ответ
    Frontend-->>User: Показывает результат
Архитектура диспетчеризации
Архитектура диспетчеризации

Код селектора моделей:

def get_top_models() -> dict:
    """Берём последние free-модели по каждому бренду"""
    models = fetch_models()
    free_models = [m for m in models if is_model_free(m)]

    brand_groups = defaultdict(list)
    for m in free_models:
        mid = m["id"]
        brand = mid.split("/")[0].lower()
        brand_groups[brand].append(mid)

    # Берём ТОП-10 брендов по числу моделей
    top = sorted(brand_groups.items(), key=lambda x: len(x[1]), reverse=True)[:10]
    
    # Для каждого бренда выбираем ПОСЛЕДНЮЮ free-модель
    def pick_latest(ids: list[str]) -> str:
        for mid in reversed(ids):
            low = mid.lower()
            if not any(h in low for h in BAD_HINTS):
                return mid
        return ids[-1]

    split_idx = max(1, len(top) // 2)
    code_brands = top[:split_idx]
    text_brands = top[split_idx:]

    return {
        "code_models": [{"brand": b, "model_id": pick_latest(ids)} for b, ids in code_brands],
        "text_models": [{"brand": b, "model_id": pick_latest(ids)} for b, ids in text_brands],
    }

5. Генератор Mermaid: текст → диаграмма → SVG

Сердце проекта — превращение текстового описания в красивые диаграммы с помощью AI.

Интерфейс Mermaid
Интерфейс Mermaid

Код генерации:

@api_view(["POST"])
@permission_classes([IsAuthenticated])
def generate_mermaid(request):
    text = (request.data.get("text") or "").strip()
    prefer_type = (request.data.get("type") or "").strip()
    lang = request.data.get("language") or "ru"
    model_id = request.data.get("model_id")
    
    if not text:
        return Response({"error": "empty text"}, status=400)
        
    try:
        t, code, warnings, used_model = generate(text, prefer_type, lang, model_id)
        
        # Валидация, что AI вернул валидный Mermaid-код
        if not code.strip().lower().startswith(MERMAID_HEADS):
            return Response(
                {"error": "OpenRouter сейчас недоступен. Попробуйте позже."}, 
                status=503
            )
            
        return Response({
            "type": t, 
            "code": code, 
            "warnings": warnings, 
            "used_model": used_model
        }, status=200)
        
    except RuntimeError:
        return Response(
            {"error": "OpenRouter сейчас недоступен. Попробуйте позже."}, 
            status=503
        )

Корректировка диаграмм AI:

@api_view(["POST"])
@permission_classes([IsAuthenticated])
def adjust_mermaid(request):
    """
    Итерируем код по инструкции пользователя
    """
    code = (request.data.get("code") or "").strip()
    t = request.data.get("type") or "flowchart"
    instr = (request.data.get("instruction") or "").strip()
    lang = "ru"
    model = request.data.get("model_id")

    system = f"""
        Ты модифицируешь Mermaid {t} код.
        Верни ТОЛЬКО код Mermaid в тройных кавычках, без пояснений.
        Комментарии внутри кода — только через '%%'
        """.strip()
        
    user = f"Инструкция:\n{instr}\n\nТекущий код:\n```mermaid\n{code}\n```"
    
    out, used = query_openrouter(
        prompt=user, 
        model_id=model, 
        language=lang, 
        system_prompt=system, 
        temperature=0.2
    )

    fixed = extract_fenced(out)
    fixed = sanitize_mermaid(out)
    fixed = normalize_brand_names(fixed)
    
    if looks_like_mermaid(fixed):
        return Response({
            "type": t, 
            "code": fixed, 
            "used_model": used, 
            "warnings": []
        })

6. Платежи ЮKassa: от первой оплаты до подписок

Самая ответственная часть — бесперебойная работа платежей с защитой от дублей и корректной обработкой вебхуков.

Интерфейс оплаты:

Страница оплаты
Страница оплаты

Защита от дублей:

@api_view(['POST'])
@permission_classes([IsAuthenticated])
def process_kassa(request):
    subscription_type = (request.data.get('subscription_type') or '').lower()
    coupon_code = (request.data.get('coupon_code') or '').strip()

    # 1) Блок повторной покупки
    if subscription_type in ('monthly', 'yearly'):
        existing = Subscription.objects.filter(
            user=request.user,
            plan=subscription_type,
            status='active',
            next_charge_at__gt=timezone.now(),
        ).first()
        if existing:
            return Response({
                "error": "У вас уже есть активная подписка этого типа.",
                "plan": existing.plan,
                "next_charge_at": existing.next_charge_at.isoformat(),
            }, status=status.HTTP_409_CONFLICT)

    # 2) Анти-даблклик (недавний запуск того же типа)
    if recent_inflight_payment_exists(request.user, subscription_type, window_seconds=90):
        return Response(
            {"error": "Платёж уже создаётся, подождите пару секунд"},
            status=status.HTTP_429_TOO_MANY_REQUESTS
        )

Обработка вебхуков:

@csrf_exempt
@api_view(['POST'])
@permission_classes([AllowAny])
def kassa_webhook(request):
    # IP-проверка Яндекс-серверов
    if not is_valid_webhook_signature(request):
        return Response(status=403)

    data = json.loads(request.body.decode('utf-8'))
    event = (data.get('event') or '').strip()
    obj = data.get('object', {}) or {}
    
    # Логируем все события
    log = PaymentEventLog.objects.create(
        event_id=obj.get('id'),
        event_type=event,
        payload=data,
        applied=False,
    )

    # Маршрутизация событий
    if event == 'payment.waiting_for_capture':
        resp = webhook_waiting_for_capture(data)
    elif event == 'payment.succeeded':
        resp = webhook_succeeded(data)
    elif event == 'payment.canceled':
        resp = webhook_canceled(data)
    elif event == 'refund.succeeded':
        resp = webhook_refund(data)
    else:
        return Response(status=200)  # Неизвестные события подтверждаем

    # Отмечаем успешно обработанные события
    if resp.status_code == 200:
        log.applied = True
        log.save()

    return resp

Автосписания через GitHub Actions:

name: charge-subscriptions (daily)

on:
  workflow_dispatch: {}
  schedule:
    - cron: "0 15 * * *"  # Ежедневно в 18:00 Мск

jobs:
  run-charges:
    runs-on: ubuntu-latest
    env:
      BACKEND_URL: ${{ secrets.BACKEND_URL }}
      CRON_SECRET: ${{ secrets.CRON_SECRET }}
    steps:
      - name: Charge subscriptions
        run: |
          curl -sS \
            -H "X-CRON-SECRET: $CRON_SECRET" \
            -d "{\"limit\": 100}" \
            "$BACKEND_URL/api/payment/charge-subscriptions/"

7. Web Stories: SEO-витрина как точка входа

Чтобы проект не остался в вакууме, сделал AMP Web Stories витрину, которая отлично индексируется и даёт мобильный трафик.

Витрина проектов:

Web Stories витрина
Web Stories витрина

Структура AMP Story:

<amp-story standalone
  title="A · Chat & Aggregator"
  publisher="lemon1964"
  poster-portrait-src="./assets/c3-chat-hero-1080x1920.webp">
  
  <amp-story-page id="cover">
    <amp-story-grid-layer template="fill">
      <amp-img src="./assets/c3-chat-hero-1080x1920.webp"
               width="1080" height="1920" layout="fill" alt="Chat hero">
      </amp-img>
    </amp-story-grid-layer>
    <amp-story-grid-layer template="vertical" class="layer">
      <h1 class="title"><span class="pill">LLM-чат как агрегатор</span></h1>
      <p class="text"><span class="pill">Все модели в одном окне. Текст и голос.</span></p>
    </amp-story-grid-layer>
  </amp-story-page>

  <amp-story-cta-layer>
    <a href="https://lemon1964.github.io/ai-chat-pages/?utm_source=webstories"
       target="_top">Открыть чат</a>
  </amp-story-cta-layer>
</amp-story>

8. Деплой и инфраструктура

Frontend/Backend: Render.com
Витрина: GitHub Pages
CI/CD: GitHub Actions
База: PostgreSQL на Render

Roadmap разработки:

9. Что получилось в итоге

Живые ссылки:

Технические итоги:

  • ? 7 модулей готового микро-SaaS

  • ? Работающие платежи с подписками

  • ? AI-агрегатор с 20+ моделями

  • ? Mermaid-генератор как пример монетизации

  • ? SEO-витрина с Web Stories

  • ☁️ Полностью развёрнутый продакшен

10. Заключение

Mermaid в этом проекте — лишь пример. Рулит идея, а не деньги, вторые лишь ресурс для жизни первого'. Этот каркас — ваш конструктор. Замените генератор диаграмм на свою AI-логику: персональный ассистент, аналитика текстов, генератор документов...

Ваш первый платеж не должен быть целью — он должен стать следствием реализации стоящей идеи. Проект даёт вам инструмент, чтобы проверить это на практике."

Что дальше:

  1. Можете взять идеи из статьи и повторить архитектуру

  2. Сходить на Stepik и получить все паттерны «под ключ»

  3. Главное — подставить свою логику вместо Mermaid и запустить

P.S. Если есть вопросы по реализации — спрашивайте в комментариях. Расскажу про подводные камни, которые встретил на пути.

Комментарии (0)