П/ВИН

GPTWEB: чиним блог-генератор и проектируем админку v3.0

·9 мин чтения

GPTWEB — мой AI-агрегатор с биллингом для российского рынка (ask.gptweb.ru). Под капотом — форк LobeChat с YooKassa-интеграцией, Better Auth и кастомной локализацией, плюс standalone-админка на Next.js 16 за Caddy, Telegram-бот и лендинг. На прошлой неделе всё это разом подкинуло мне три параллельные задачи: после переезда сервера сломались автоматический блог-генератор и динамические тарифы на лендинге, а сверху упал запрос на новую версию админки — v3.0 с детальной аналитикой по каждому пользователю. В этой статье собираю всё в одну историю: как я диагностировал обе регрессии, какие архитектурные решения принял для админки и что вынес из исследования AI-провайдеров для wrapper-сервисов.

Контекст: что такое GPTWEB и из чего он состоит

GPTWEB — это classic wrapper-сервис: пользователь платит рублями через YooKassa, получает баланс в токенах и доступ к десяткам моделей (Claude, GPT, Llama, Mistral и так далее) через единый интерфейс. Ниже капота — четыре репозитория:

  • aggregator — основа на форке LobeChat. Next.js 16 + Drizzle ORM + tRPC. База — ParadeDB поверх PostgreSQL 17 в Docker-контейнере.
  • admin — отдельная админка по пути /admin. Standalone-сборка Next.js, контейнер на 3400 порту, проксируется через Caddy.
  • bot — Telegram-уведомления (баланс, оплаты, ошибки).
  • landing — статический промо-сайт, читает тарифы через REST.

Ключ в том, что тарифы и контент блога админка отдаёт наружу: лендинг динамически подтягивает планы из таблицы billing_plans, а блог — из ai_aggregator.blog_posts. Сломалась админка — слетел и публичный сайт.

Проблема №1: тарифы пропали из админки и сломались на лендинге

Симптом: на лендинге вместо тарифов — заглушка, в админке нет страницы редактирования планов. Пошёл по follow-the-data: API-ручки на месте, данные в БД на месте, проблема где-то в UI.

Проверил /api/billing/plans (GET и PUT) — рабочие, возвращают free/basic/pro корректно. Глянул components/sidebar.tsx — секции «Биллинг» нет. Полез в историю app/(admin)/billing/page.tsx и обнаружил находку: в Phase 3 рефакторинга админки эту страницу превратили в банальный redirect("/finance/payments"). То есть страница CRUD тарифов была физически удалена, остался только редирект — и его никто не заметил, потому что кнопки в сайдбаре тоже не было.

// app/(admin)/billing/page.tsx (было после Phase 3)
import { redirect } from "next/navigation";
 
export default function BillingPage() {
  redirect("/finance/payments");
}

Классический случай неполного рефакторинга: финансовую секцию переделали и красиво сгруппировали (date-range picker, KPI-карточки, графики на Recharts, детализация расходов по каналам), но управление тарифами при этом просто потеряли. Не критическое — billing_plans всё это время были в БД. Критическое — лендинг читает из этой таблицы, и никто не может править цены без прямого SQL.

Фикс — восстановить /admin/billing/plans как полноценную CRUD-страницу: список планов, редактор лимитов и цены в рублях, кнопка «активировать/деактивировать», ссылка обратно в Финансы. Сайдбар возвращает группу «Тарифы» с тремя пунктами: Планы, Платежи, Себестоимость. Логически это правильнее, чем размазывать биллинг по двум разным разделам.

Проблема №2: блог перестал писать черновики

Второй симптом: автогенератор блога не создаёт новые статьи. У GPTWEB настроен пайплайн «cron → API-ручка → генерация черновиков → ручное ревью → публикация». Ходить за тёмным углом и спрашивать, что сломалось, бесполезно — нужны логи.

Запросил статусы systemd-таймеров на VPS:

systemctl status blog-generate.timer blog-keywords.timer blog-sync.timer
systemctl status blog-generate.service

Картинка получилась грустная: все три таймера срабатывают, все три сервиса в состоянии failed с кодом status=203/EXEC. Это очень специфическая ошибка — systemd просто не может найти исполняемый файл, на который ссылается ExecStart.

Открыл unit-файлы:

# /etc/systemd/system/blog-generate.service
[Service]
WorkingDirectory=/home/deploy/projects/ai-aggregator
ExecStart=/home/deploy/projects/ai-aggregator/scripts/blog-generate.sh

И всё стало ясно. После переезда на новый сервер и реорганизации проектов директория ai-aggregator была переименована в ai-aggregator-lobechat. Скрипты вместе с проектом «уехали» — а юнит-файлы systemd остались с прежними путями. Таймеры сработали, нашли несуществующий путь, упали, повторили. Тихая регрессия: ни ошибок в основном приложении, ни падений Next.js, ни 500-ок в API. Просто черновики магически перестают создаваться по ночам.

Решение в три действия:

  1. Восстановить три скрипта (blog-generate.sh, blog-keywords.sh, blog-sync.sh) из приватного репо bugle-c/ai-aggregator — на сервере их физически нет, потому что после переименования папки они туда так и не попали.
  2. Поменять в unit-файлах WorkingDirectory и ExecStart на новый путь ai-aggregator-lobechat.
  3. Сделать systemctl daemon-reload и руками тригернуть все три таймера, чтобы убедиться, что фикс рабочий.

Первым прогнал blog-keywords (он самый лёгкий — просто пинг к API подбора SEO-ключей). Зелёный exit code, статья получила ключевики. Дальше blog-sync — отработал и подтянул одну статью из текущей очереди в боевую таблицу. Третий, тяжёлый blog-generate, запустил в фоне: он стучится в Claude API, генерит структурированный черновик и пишет в blog_posts со статусом draft. Через пару минут проверил БД — черновик появился. Чиним.

Урок: переезд сервера — это не только rsync файлов

Прежде чем перейти к админке v3.0, выпишу для себя главное правило по этой регрессии. Когда мигрируешь рабочий сервер, недостаточно скопировать файлы и поднять Docker — есть скрытый слой состояния:

  • systemd-юниты в /etc/systemd/system/
  • cron-таблицы в /var/spool/cron/
  • переменные окружения в .env файлах сервисов
  • права на файлы и владельцы (UID/GID могут не совпадать между серверами)
  • самописные скрипты где-нибудь в /home/deploy/scripts/

Ни один из этих артефактов не лежит в Git и не входит в стандартный backup приложения. После любой миграции нужно делать обход: systemctl list-timers, crontab -l для всех пользователей, ls /etc/cron.d/, find /etc/systemd/system -name '*.service'. Сейчас завожу этот чек-лист как часть dokploy-deploy скилла и в KNOWLEDGE.md проекта.

Админка v3.0: проектирование

Пока чинил блог, параллельно стартовал бренсторм по третьей задаче — админке версии 3.0. Цель пользователя сформулирована широко: «глубокая аналитика пользователей, финансы, моя себестоимость на каждого пользователя». Это не один экран — это переосмысление всей админки от dashboard'а до per-user страницы.

Первый принципиальный вопрос — что считать единицей анализа. Я предложил три фокуса:

  • A. Юнит-экономика: для каждого пользователя выручка минус моя себестоимость = маржа. Кто прибыльный, кто сливает деньги в минус.
  • B. Поведение: какие модели использует, когда активен, какие промпты, кто power-user.
  • C. Финансовый pulse: aggregate-метрики бизнеса по дням/месяцам.

Выбран путь «все три равнозначны». Это означает, что dashboard будет иметь три верхнеуровневых блока, и каждый из них раскрывается в отдельный раздел.

Ключевая проблема: я не знаю свою себестоимость

Самое интересное вылезло на втором вопросе. Чтобы посчитать «моя себестоимость на пользователя X в апреле», нужно знать: сколько он сделал запросов, к каким моделям, сколько input/output токенов и какая у каждой модели цена в $/1M токенов на тот момент.

Проверил, что есть в текущей БД:

Что естьЧего не хватает
billingPayments — все платежи с YooKassa ID и суммойНет per-request лога с model, input_tokens, output_tokens, user_id, ts
userBilling.tokensUsedMonth — агрегат месяцаНет истории по дням/моделям
model-rates.ts — прайс моделей в $/1M
messages.model — модель сообщенияБез счётчиков токенов
recordTokenUsage() — функция есть и вызываетсяНо пишет только агрегат, не лог

Итог: текущая схема позволяет узнать, сколько пользователь потратил в общем, но не позволяет разложить это по моделям и посчитать реальную себестоимость. То есть ключевая фича v3.0 — невозможна без миграции схемы. Решено: стартуем с чистого листа, заводим таблицу usage_logs с полным контекстом каждого запроса.

По масштабу — выбрали baseline на текущий объём (менее 100 активных пользователей, до 1000 запросов/день, обычная таблица + индексы по user_id и created_at), но с прицелом на рост: индексы сразу под будущие выборки, готовность отделить таблицу-rollup для дашбордов и schema-ready под партиционирование, если придётся. Это применение паттерна YAGNI с одним «но» — закладываемся не на функциональность, а на структуру, которую больно менять задним числом.

Параллельный ресёрч: какой провайдер LLM использовать

В ту же сессию пользователь попросил параллельно исследовать рынок AI-провайдеров для wrapper-сервиса. Запустил двух subagent'ов: один на текстовые модели, второй на image/video/audio. Оба упали с ошибкой — у subagent'ов в политике не было доступа к WebSearch и WebFetch. Полез в ~/.claude/settings.json, добавил оба инструмента в user-global allowlist через скилл update-config (это правильное место — глобальные пермишены применяются и к subagent'ам), перезапустил.

По текстовым моделям картина такая (апрель 2026, цены $/1M input/output):

  • OpenRouter — нулевая наценка относительно direct-цены провайдеров, но мультибрендинг и единый ключ. Для wrapper'а это идеал.
  • AI/ML API — 30% наценка, зато прямое пополнение криптой (актуально из РФ).
  • ProxyAPI — ~3x наценка относительно direct, но рублёвый эквайринг.
  • Together, Fireworks, DeepInfra — для open-source моделей (Llama, Mixtral) дешевле, чем у direct-провайдеров.

Вывод по тексту: для основного потока — OpenRouter, для open-source-моделей с приоритетом на цену — DeepInfra или Together как fallback.

По картинкам/видео/аудио: Fal.ai и Replicate — два кита, поверх них WaveSpeed (быстрый inference на коротких задачах) и Runware. Для специализированных моделей (Suno для музыки, ElevenLabs для голоса) — direct API. Mix-and-match выгоднее, чем один универсальный провайдер.

Выводы

Первый урок — рефакторинг без чек-листа теряет фичи. Phase 3 админки переделала финансовую секцию красиво, но управление тарифами просто исчезло. Никто не заметил, потому что лендинг продолжал работать (БД-то не трогали), а в админке для редактирования цен ходят раз в полгода. Я завёл правило: при любом нетривиальном рефакторинге админ-страниц делать diff app/(admin)/** до и после, и для каждой страницы, которую заменили на redirect(), явно подтверждать в плане, куда переехала функциональность. Если функциональность нигде — это красный флаг, не молчаливый «потом добавим».

Второй урок — миграция сервера ломает то, чего не видно. systemd-таймеры, cron-job'ы, самописные скрипты в произвольных папках — всё это не часть Docker-образа и не часть Git. После переезда (особенно если попутно реорганизовал директории) все эти зависимости нужно перепрописывать. У меня в результате этого инцидента появился пункт в дokploy-чек-листе: после миграции пройтись по systemctl list-timers --all, crontab -l для каждого пользователя и ls /etc/cron.d/ — и для каждой найденной единицы убедиться, что пути и переменные актуальны. Дёшево и предотвращает «тихие» регрессии.

Третий урок — данные собирай заранее, считать «по агрегату» — тупик. История с себестоимостью v3.0 показательная: я знаю, сколько пользователь потратил, и знаю прайс моделей, но не знаю, какой моделью он пользовался — потому что в БД нет per-request лога. Любая wrapper-аналитика рано или поздно упирается в этот узкий тренинг: если ты не пишешь usage-лог с момента запуска, переход на детальную метрику = миграция данных назад во времени, что обычно невозможно. Завожу правило: для любого сервиса с pay-per-use всегда сразу есть таблица usage_logs с user_id, ресурс, quantity, cost, ts. Пишется при каждом тарифицируемом действии. Без этого нет ни диагностики, ни оптимизации, ни честной юнит-экономики.

Четвёртый урок — multi-provider стратегия для AI выгоднее single-provider. Это контринтуитивно (хочется один SDK, один ключ, одна биллинг-точка), но реальность: разные модели имеют разную экономику у разных хостов. OpenRouter решает 80% случаев бесплатным агрегированием, но для open-source моделей DeepInfra даёт 30-50% дешевле. Для картинок Fal быстрый, Replicate универсальный, WaveSpeed — самый дешёвый на конкретных диффузионных моделях. Wrapper-сервис выигрывает, когда умеет роутить запросы к самому дешёвому из доступных провайдеров для каждой конкретной модели и держит fallback'и на остальных. Это сложнее в коде, но окупается на масштабе.

Дальше план такой: добиваю восстановление /admin/billing/plans, прохожу по systemd-юнитам и переписываю пути, после этого начинаю проектирование схемы usage_logs и каркаса dashboard'а v3.0. Параллельно — выбор провайдер-стратегии (вероятно, OpenRouter как default + DeepInfra для open-source) и интеграция её в логику биллинга, чтобы себестоимость считалась честно по реальному провайдеру, а не по прайсу OpenAI.

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

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