П/ВИН

TG Army Dashboard: 9 багов, PostgreSQL и мульти-воркер

·8 мин чтения
TG Army Dashboard: 9 багов, PostgreSQL и мульти-воркер

Когда продукт растёт быстрее, чем успеваешь за ним следить, рано или поздно накапливается технический долг, который начинает мешать работать прямо сейчас. Именно это произошло с TG Army — инструментом для автоматизации Telegram-аккаунтов. В один момент дашборд перестал показывать актуальные данные, вкладка Settings не реагировала на клики, а сохранение шаблонов молча проваливалось в никуда. Под капотом скрывалось сразу девять проблем — от отсутствующих CSS-классов до фундаментального несоответствия между базой данных и веб-панелью.

В этой статье разберём, как мы прошли путь от списка багов до работающей системы с PostgreSQL, Redis и поддержкой мульти-воркерной архитектуры.

Контекст проекта

TG Army — система для управления парком Telegram-аккаунтов. Она автоматизирует рассылку по воронке: аккаунты проходят через прогрев, отправляют последовательность сообщений, прикладывают медиафайлы и фиксируют статусы диалогов. Дашборд — это веб-панель на Flask, где оператор видит состояние всех аккаунтов, редактирует шаблоны и управляет воронкой.

Аккаунты живут в нескольких состояниях: active, frozen, warming, expired, deleted, banned, dead. Последнее — аккаунты, которые уже не восстановить. Проблема была в том, что дашборд знал только о части этих статусов, а остальные просто игнорировал.

Девять проблем, которые мы решали

Прежде чем писать код, мы составили исчерпывающий список того, что сломано:

  1. Отсутствуют CSS-классы badge-frozen, badge-warming, badge-deleted — бейджи статусов рендерились без стилей
  2. status_colors в app.py не знает о трёх новых статусах
  3. API-эндпоинт смены статуса принимает только 4 значения вместо 7
  4. Dropdown на странице аккаунта не содержит frozen, warming, deleted
  5. Нет статистики по AI-диалогам на главной странице
  6. Нет статистики по прогреву
  7. Мёртвые аккаунты (dead) не отделяются визуально от живых
  8. Шаг 5 воронки отправляет кейсы как отдельные сообщения, а не как альбом
  9. Веб-панель читает данные из SQLite, хотя продакшн давно переехал на PostgreSQL

Последний пункт оказался самым критичным — он объяснял, почему изменения в шаблонах не сохранялись: они писались в пустой SQLite-файл, который никто не читал.

Фикс CSS и расширение статусной модели

Начали с самого видимого — визуальных стилей. В base.html добавили три новых класса бейджей:

.badge-frozen {
  background-color: #0dcaf0;
  color: #000;
}
 
.badge-warming {
  background-color: #fd7e14;
  color: #fff;
}
 
.badge-deleted {
  background-color: #343a40;
  color: #adb5bd;
  border: 1px solid #6c757d;
}
 
tr.dead-account {
  opacity: 0.5;
}

Затем обновили status_colors в app.py — словарь, который маппит статус на CSS-класс:

# До
status_colors = {
    'active': 'badge-success',
    'expired': 'badge-warning',
    'banned': 'badge-danger',
    'dead': 'badge-secondary',
}
 
# После
status_colors = {
    'active': 'badge-success',
    'frozen': 'badge-frozen',
    'warming': 'badge-warming',
    'expired': 'badge-warning',
    'banned': 'badge-danger',
    'deleted': 'badge-deleted',
    'dead': 'badge-secondary',
}

В API-эндпоинте расширили valid_statuses с 4 до 7 значений. В шаблоне аккаунта обновили dropdown — теперь все 7 статусов. После рестарта сервиса проверили сортировку: activefrozenexpireddeleted, мёртвые аккаунты внизу с opacity: 0.5.

Статистика на дашборде

Добавили метод get_dashboard_stats() в database.py, который агрегирует данные по статусам, считает активные диалоги и аккаунты на прогреве:

async def get_dashboard_stats(self) -> dict:
    rows = await self._fetchall(
        """SELECT status, COUNT(*) as cnt
           FROM accounts GROUP BY status"""
    )
    stats = {row['status']: row['cnt'] for row in rows}
    
    ai_dialogs = await self._fetchone(
        """SELECT COUNT(*) as cnt FROM dialogs
           WHERE ai_enabled = true AND status = 'active'"""
    )
    
    return {
        'active': stats.get('active', 0),
        'frozen': stats.get('frozen', 0),
        'warming': stats.get('warming', 0),
        'total_ai_dialogs': ai_dialogs['cnt'] if ai_dialogs else 0,
        **stats
    }

На дашборде появились 6 stat-карточек вместо 3.

Отправка кейсов альбомом

Шаг 5 воронки — отправка кейсов клиентов. Исторически каждая картинка отправлялась отдельным сообщением, что выглядело спамом. Telegram поддерживает медиагруппы — несколько файлов в одном сообщении.

Обновили sender.py:

async def _send_step_5(self, client, dialog):
    cases_dir = Path('assets/cases')
    case_files = sorted(cases_dir.glob('*.png'))
    
    if not case_files:
        return await client.send_message(dialog.peer, self.template_5)
    
    # Отправляем все картинки как альбом — одно сообщение
    await client.send_file(
        dialog.peer,
        list(case_files),
        caption=self.template_5
    )

Четыре PNG-файла (case_01.pngcase_04.png) скопировали в assets/cases/. Теперь шаг 5 отправляет один альбом с подписью.

Вкладка Settings не работала — проблема с Bootstrap CDN

После первых правок появился новый симптом: вкладки на странице Settings не переключались. HTML был корректным, JS-код выглядел правильным. Проблема оказалась в том, что Bootstrap JS загружался с cdn.jsdelivr.net — CDN, который нестабильно работает из России.

Если Bootstrap JS не загрузился — все интерактивные компоненты молча падают: табы, дропдауны, тосты. Никакой ошибки в консоли, просто ничего не происходит при клике.

Решение в два шага:

1. Скачали Bootstrap локально:

wget -O src/web/static/bootstrap.min.css \
  https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css
wget -O src/web/static/bootstrap.bundle.min.js \
  https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js

2. Переключили base.html на локальные файлы:

<!-- До -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/..." 
      integrity="sha384-..." crossorigin="anonymous">
 
<!-- После -->
<link href="{{ url_for('static', filename='bootstrap.min.css') }}" 
      rel="stylesheet">
<script src="{{ url_for('static', filename='bootstrap.bundle.min.js') }}"></script>

Добавили также vanilla JS fallback для табов — на случай если JS всё же не загрузится по какой-то причине:

document.addEventListener('DOMContentLoaded', function() {
  // Fallback для Bootstrap tabs
  document.querySelectorAll('[data-bs-toggle="tab"]').forEach(function(tab) {
    if (typeof bootstrap === 'undefined') {
      tab.addEventListener('click', function(e) {
        e.preventDefault();
        const target = document.querySelector(this.getAttribute('href'));
        document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('show', 'active'));
        document.querySelectorAll('[data-bs-toggle="tab"]').forEach(t => t.classList.remove('active'));
        target.classList.add('show', 'active');
        this.classList.add('active');
      });
    }
  });
});

Главная проблема: веб-панель читала SQLite вместо PostgreSQL

Самый болезненный баг. Когда мы перевели продакшн на PostgreSQL, воркеры начали писать данные туда. Но функция _get_db() в app.py по-прежнему создавала SQLite-подключение:

# Было — всегда SQLite
def _get_db():
    return Database('data/tg_army.db')

Поэтому:

  • Дашборд показывал данные из пустого SQLite (0 аккаунтов)
  • Сохранение шаблонов писало в SQLite, который воркеры не читали
  • Изменения в настройках пропадали

Переключили _get_db() на PgDatabase, которую разделяет с оркестратором:

# Стало — shared PostgreSQL
async def _get_db() -> PgDatabase:
    return await get_shared_pg_database()

Но это потянуло за собой цепочку несовместимостей.

Проблема 1: плейсхолдеры SQL

SQLite использует ? в параметризованных запросах, asyncpg$1, $2 и т.д.:

# SQLite
await db.execute("UPDATE accounts SET status=? WHERE id=?", (status, account_id))
 
# asyncpg / PostgreSQL
await db.execute("UPDATE accounts SET status=$1 WHERE id=$2", status, account_id)

Прошлись по всем raw SQL-запросам в app.py и обновили синтаксис.

Проблема 2: datetime как объект вместо строки

SQLite возвращал даты как строки ("2026-03-15T12:30:00"), PostgreSQL через asyncpg — как нативные datetime-объекты Python.

Во всех шаблонах Jinja2 были такие конструкции:

<!-- Работало с SQLite -->
{{ account.created_at[:16] }}
{{ account.last_active[11:16] }}

С datetime-объектом срезы строк падали с TypeError. Заменили на Jinja2-фильтры:

<!-- Работает с PostgreSQL -->
{{ account.created_at | datetimeformat }}
{{ account.last_active.strftime('%H:%M') if account.last_active else '—' }}

Аналогично в app.py убрали datetime.fromisoformat() там, где данные уже приходят как datetime:

# До
last_active = datetime.fromisoformat(account['last_active'])
 
# После
last_active = account['last_active']  # уже datetime

Phase 0 и Phase 1: мульти-воркерная архитектура

Параллельно с фиксом дашборда готовили фундамент для следующего этапа — запуск нескольких воркеров одновременно. Для координации между ними нужны были Redis и единая схема в PostgreSQL.

10 задач, ~3100 строк нового кода, 19 файлов. Работа разбита на 4 батча с промежуточными чекпоинтами.

Batch 1: Redis, PG Schema, зависимости

Запустили Redis в Docker на порту 6380 (6379 уже был занят другим контейнером):

# docker-compose.yml
redis-tg-army:
  image: redis:7-alpine
  ports:
    - "6380:6379"
  restart: unless-stopped

Создали схему tg_army в PostgreSQL — 15 таблиц с индексами и правами. Добавили зависимости:

asyncpg==0.31.0
redis==7.2.0

Batch 2: PgDatabase, миграция, RedisClient

Самая объёмная задача — PgDatabase: полный аналог SQLite Database с 47+ методами, но на asyncpg. Все методы возвращают те же структуры данных, что и SQLite-версия, чтобы не ломать совместимость с воркерами.

Миграция данных из SQLite в PostgreSQL:

12 таблиц перенесено
Row counts совпадают
57 тестов, 0 провалов

RedisClient — обёртка для координации воркеров: блокировки аккаунтов, очереди задач, pub/sub для оркестратора.

Результат

После всех правок система работает как единое целое:

  • Дашборд отображает реальные данные из PostgreSQL: все 7 статусов с правильными цветами, мёртвые аккаунты визуально отделены
  • Settings сохраняет изменения — они сразу подхватываются воркерами
  • Шаг 5 воронки отправляет кейсы одним альбомом вместо спама отдельными сообщениями
  • Bootstrap грузится локально — никакой зависимости от CDN
  • 57 тестов проходят без единого провала
  • Архитектурная база для мульти-воркеров готова: Redis, PG-схема, PgDatabase

Выводы

Главный урок этого проекта — несоответствие хранилищ данных убивает продукт тихо. Веб-панель и воркеры жили в параллельных реальностях: одни писали в PostgreSQL, другие читали из SQLite. Никакой явной ошибки — просто изменения пропадали. Если вы переходите с одной СУБД на другую, убедитесь, что все компоненты системы переключены одновременно.

Второй урок — CDN-зависимости в продакшне это риск, особенно если ваши пользователи или серверы находятся в регионах с непредсказуемой фильтрацией трафика. Bootstrap с jsdelivr.net — популярное решение, которое при блокировке CDN ломает весь интерактив на странице без единой ошибки в консоли. Держите критичные JS/CSS-ресурсы локально или хотя бы добавляйте fallback.

Третий урок — тип данных из базы имеет значение. Переходя с SQLite на PostgreSQL через asyncpg, нужно помнить: asyncpg возвращает нативные Python-типы (datetime, Decimal, UUID), а не строки. Весь код, который делал срезы строк [:16] или вызывал fromisoformat(), сломался. Хорошая практика — использовать Jinja2-фильтры для форматирования дат в шаблонах и не полагаться на строковые манипуляции.

Четвёртый урон — батчевая разработка с чекпоинтами работает. Разбить 10 задач на 4 батча с явными зависимостями между ними позволило запускать независимые задачи параллельно и проверять результат на каждом этапе, а не в самом конце. Когда задача Task 4 (1200 строк PgDatabase) заняла больше времени — остальные задачи батча уже были готовы. Итого: 3100 строк нового кода без регрессий в существующих тестах.

Полный стек проекта: Flask + asyncpg + PostgreSQL + Redis + Telethon для работы с Telegram API.

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

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

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

TG Army