diff --git a/.env.example b/.env.example
index d129df68..01f64add 100644
--- a/.env.example
+++ b/.env.example
@@ -221,6 +221,26 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
+# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
+# Конфигурация приложений для гайда подключения
+APP_CONFIG_PATH=app-config.json
+ENABLE_DEEP_LINKS=true
+APP_CONFIG_CACHE_TTL=3600
+
+# ===== СИСТЕМА БЕКАПОВ =====
+BACKUP_AUTO_ENABLED=true
+BACKUP_INTERVAL_HOURS=24
+BACKUP_TIME=03:00
+BACKUP_MAX_KEEP=7
+BACKUP_COMPRESSION=true
+BACKUP_INCLUDE_LOGS=false
+BACKUP_LOCATION=/app/data/backups
+
+# ===== ПРОВЕРКА ОБНОВЛЕНИЙ БОТА =====
+VERSION_CHECK_ENABLED=true
+VERSION_CHECK_REPO=fr1ngg/remnawave-bedolaga-telegram-bot
+VERSION_CHECK_INTERVAL_HOURS=1
+
# ===== ЛОГИРОВАНИЕ =====
LOG_LEVEL=INFO
LOG_FILE=logs/bot.log
@@ -229,13 +249,3 @@ LOG_FILE=logs/bot.log
DEBUG=false
WEBHOOK_URL=
WEBHOOK_PATH=/webhook
-
-# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
-# Конфигурация приложений для гайда подключения
-APP_CONFIG_PATH=app-config.json
-ENABLE_DEEP_LINKS=true
-APP_CONFIG_CACHE_TTL=3600
-
-VERSION_CHECK_ENABLED=true
-VERSION_CHECK_REPO=fr1ngg/remnawave-bedolaga-telegram-bot
-VERSION_CHECK_INTERVAL_HOURS=1
diff --git a/README.md b/README.md
index a225d92b..8af483c2 100644
--- a/README.md
+++ b/README.md
@@ -70,7 +70,9 @@ cp .env.example .env
nano .env # Заполни токены и настройки
# 3. Создай необходимые директории
-mkdir -p logs data
+mkdir -p ./logs ./data ./data/backups ./data/referral_qr
+chmod -R 755 ./logs ./data
+sudo chown -R 1000:1000 ./logs ./data
# 4. Запусти всё разом
docker compose up -d
diff --git a/app/bot.py b/app/bot.py
index dba6b636..c378705e 100644
--- a/app/bot.py
+++ b/app/bot.py
@@ -25,10 +25,11 @@ from app.handlers.admin import (
statistics as admin_statistics, servers as admin_servers,
maintenance as admin_maintenance,
user_messages as admin_user_messages,
- updates as admin_updates
+ updates as admin_updates, backup as admin_backup
)
from app.handlers.stars_payments import register_stars_handlers
+
logger = logging.getLogger(__name__)
@@ -104,6 +105,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_maintenance.register_handlers(dp)
admin_user_messages.register_handlers(dp)
admin_updates.register_handlers(dp)
+ admin_backup.register_handlers(dp)
common.register_handlers(dp)
diff --git a/app/config.py b/app/config.py
index 9fb5c14a..a5a46c7d 100644
--- a/app/config.py
+++ b/app/config.py
@@ -156,6 +156,14 @@ class Settings(BaseSettings):
VERSION_CHECK_ENABLED: bool = True
VERSION_CHECK_REPO: str = "fr1ngg/remnawave-bedolaga-telegram-bot"
VERSION_CHECK_INTERVAL_HOURS: int = 1
+
+ BACKUP_AUTO_ENABLED: bool = True
+ BACKUP_INTERVAL_HOURS: int = 24
+ BACKUP_TIME: str = "03:00"
+ BACKUP_MAX_KEEP: int = 7
+ BACKUP_COMPRESSION: bool = True
+ BACKUP_INCLUDE_LOGS: bool = False
+ BACKUP_LOCATION: str = "/app/data/backups"
@field_validator('LOG_FILE', mode='before')
@classmethod
diff --git a/app/handlers/admin/backup.py b/app/handlers/admin/backup.py
new file mode 100644
index 00000000..ce5b0cea
--- /dev/null
+++ b/app/handlers/admin/backup.py
@@ -0,0 +1,647 @@
+import logging
+import os
+from datetime import datetime
+from pathlib import Path
+from aiogram import Dispatcher, types, F
+from aiogram.fsm.context import FSMContext
+from aiogram.fsm.state import State, StatesGroup
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import settings
+from app.database.models import User
+from app.services.backup_service import backup_service
+from app.utils.decorators import admin_required, error_handler
+
+logger = logging.getLogger(__name__)
+
+
+class BackupStates(StatesGroup):
+ waiting_backup_file = State()
+ waiting_settings_update = State()
+
+
+def get_backup_main_keyboard(language: str = "ru"):
+ return InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(text="🚀 Создать бекап", callback_data="backup_create"),
+ InlineKeyboardButton(text="📥 Восстановить", callback_data="backup_restore")
+ ],
+ [
+ InlineKeyboardButton(text="📋 Список бекапов", callback_data="backup_list"),
+ InlineKeyboardButton(text="⚙️ Настройки", callback_data="backup_settings")
+ ],
+ [
+ InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")
+ ]
+ ])
+
+
+def get_backup_list_keyboard(backups: list, page: int = 1, per_page: int = 5):
+ keyboard = []
+
+ start_idx = (page - 1) * per_page
+ end_idx = start_idx + per_page
+ page_backups = backups[start_idx:end_idx]
+
+ for backup in page_backups:
+ try:
+ if backup.get("timestamp"):
+ dt = datetime.fromisoformat(backup["timestamp"].replace('Z', '+00:00'))
+ date_str = dt.strftime("%d.%m %H:%M")
+ else:
+ date_str = "?"
+ except:
+ date_str = "?"
+
+ size_str = f"{backup.get('file_size_mb', 0):.1f}MB"
+ records_str = backup.get('total_records', '?')
+
+ button_text = f"📦 {date_str} • {size_str} • {records_str} записей"
+ callback_data = f"backup_manage_{backup['filename']}"
+
+ keyboard.append([InlineKeyboardButton(text=button_text, callback_data=callback_data)])
+
+ if len(backups) > per_page:
+ total_pages = (len(backups) + per_page - 1) // per_page
+ nav_row = []
+
+ if page > 1:
+ nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"backup_list_page_{page-1}"))
+
+ nav_row.append(InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="noop"))
+
+ if page < total_pages:
+ nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"backup_list_page_{page+1}"))
+
+ keyboard.append(nav_row)
+
+ keyboard.extend([
+ [InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")]
+ ])
+
+ return InlineKeyboardMarkup(inline_keyboard=keyboard)
+
+
+def get_backup_manage_keyboard(backup_filename: str):
+ return InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(text="📥 Восстановить", callback_data=f"backup_restore_file_{backup_filename}")
+ ],
+ [
+ InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"backup_delete_{backup_filename}")
+ ],
+ [
+ InlineKeyboardButton(text="◀️ К списку", callback_data="backup_list")
+ ]
+ ])
+
+
+def get_backup_settings_keyboard(settings_obj):
+ auto_status = "✅ Включены" if settings_obj.auto_backup_enabled else "❌ Отключены"
+ compression_status = "✅ Включено" if settings_obj.compression_enabled else "❌ Отключено"
+ logs_status = "✅ Включены" if settings_obj.include_logs else "❌ Отключены"
+
+ return InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=f"🔄 Автобекапы: {auto_status}",
+ callback_data="backup_toggle_auto"
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=f"🗜️ Сжатие: {compression_status}",
+ callback_data="backup_toggle_compression"
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=f"📋 Логи в бекапе: {logs_status}",
+ callback_data="backup_toggle_logs"
+ )
+ ],
+ [
+ InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")
+ ]
+ ])
+
+
+@admin_required
+@error_handler
+async def show_backup_panel(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ settings_obj = await backup_service.get_backup_settings()
+
+ status_auto = "✅ Включены" if settings_obj.auto_backup_enabled else "❌ Отключены"
+
+ text = f"""🗄️ СИСТЕМА БЕКАПОВ
+
+📊 Статус:
+• Автобекапы: {status_auto}
+• Интервал: {settings_obj.backup_interval_hours} часов
+• Хранить: {settings_obj.max_backups_keep} файлов
+• Сжатие: {'Да' if settings_obj.compression_enabled else 'Нет'}
+
+📁 Расположение: /app/data/backups
+
+⚡ Доступные операции:
+• Создание полного бекапа всех данных
+• Восстановление из файла бекапа
+• Управление автоматическими бекапами
+"""
+
+ await callback.message.edit_text(
+ text,
+ parse_mode="HTML",
+ reply_markup=get_backup_main_keyboard(db_user.language)
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def create_backup_handler(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ await callback.answer("🔄 Создание бекапа запущено...")
+
+ progress_msg = await callback.message.edit_text(
+ "🔄 Создание бекапа...\n\n"
+ "⏳ Экспортируем данные из базы...\n"
+ "Это может занять несколько минут.",
+ parse_mode="HTML"
+ )
+
+ # Создаем бекап
+ success, message, file_path = await backup_service.create_backup(
+ created_by=db_user.telegram_id,
+ compress=True
+ )
+
+ if success:
+ await progress_msg.edit_text(
+ f"✅ Бекап создан успешно!\n\n{message}",
+ parse_mode="HTML",
+ reply_markup=get_backup_main_keyboard(db_user.language)
+ )
+ else:
+ await progress_msg.edit_text(
+ f"❌ Ошибка создания бекапа\n\n{message}",
+ parse_mode="HTML",
+ reply_markup=get_backup_main_keyboard(db_user.language)
+ )
+
+
+@admin_required
+@error_handler
+async def show_backup_list(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ page = 1
+ if callback.data.startswith("backup_list_page_"):
+ try:
+ page = int(callback.data.split("_")[-1])
+ except:
+ page = 1
+
+ backups = await backup_service.get_backup_list()
+
+ if not backups:
+ text = "📦 Список бекапов пуст\n\nБекапы еще не создавались."
+ keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="🚀 Создать первый бекап", callback_data="backup_create")],
+ [InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")]
+ ])
+ else:
+ text = f"📦 Список бекапов (всего: {len(backups)})\n\n"
+ text += "Выберите бекап для управления:"
+ keyboard = get_backup_list_keyboard(backups, page)
+
+ await callback.message.edit_text(
+ text,
+ parse_mode="HTML",
+ reply_markup=keyboard
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def manage_backup_file(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ filename = callback.data.replace("backup_manage_", "")
+
+ backups = await backup_service.get_backup_list()
+ backup_info = None
+
+ for backup in backups:
+ if backup["filename"] == filename:
+ backup_info = backup
+ break
+
+ if not backup_info:
+ await callback.answer("❌ Файл бекапа не найден", show_alert=True)
+ return
+
+ try:
+ if backup_info.get("timestamp"):
+ dt = datetime.fromisoformat(backup_info["timestamp"].replace('Z', '+00:00'))
+ date_str = dt.strftime("%d.%m.%Y %H:%M:%S")
+ else:
+ date_str = "Неизвестно"
+ except:
+ date_str = "Ошибка формата даты"
+
+ text = f"""📦 Информация о бекапе
+
+📄 Файл: {filename}
+📅 Создан: {date_str}
+💾 Размер: {backup_info.get('file_size_mb', 0):.2f} MB
+📊 Таблиц: {backup_info.get('tables_count', '?')}
+📈 Записей: {backup_info.get('total_records', '?'):,}
+🗜️ Сжатие: {'Да' if backup_info.get('compressed') else 'Нет'}
+🗄️ БД: {backup_info.get('database_type', 'unknown')}
+"""
+
+ if backup_info.get("error"):
+ text += f"\n⚠️ Ошибка: {backup_info['error']}"
+
+ await callback.message.edit_text(
+ text,
+ parse_mode="HTML",
+ reply_markup=get_backup_manage_keyboard(filename)
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def delete_backup_confirm(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ filename = callback.data.replace("backup_delete_", "")
+
+ text = f"🗑️ Удаление бекапа\n\n"
+ text += f"Вы уверены, что хотите удалить бекап?\n\n"
+ text += f"📄 {filename}\n\n"
+ text += "⚠️ Это действие нельзя отменить!"
+
+ keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(text="✅ Да, удалить", callback_data=f"backup_delete_confirm_{filename}"),
+ InlineKeyboardButton(text="❌ Отмена", callback_data=f"backup_manage_{filename}")
+ ]
+ ])
+
+ await callback.message.edit_text(
+ text,
+ parse_mode="HTML",
+ reply_markup=keyboard
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def delete_backup_execute(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ filename = callback.data.replace("backup_delete_confirm_", "")
+
+ success, message = await backup_service.delete_backup(filename)
+
+ if success:
+ await callback.message.edit_text(
+ f"✅ Бекап удален\n\n{message}",
+ parse_mode="HTML",
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="📋 К списку бекапов", callback_data="backup_list")]
+ ])
+ )
+ else:
+ await callback.message.edit_text(
+ f"❌ Ошибка удаления\n\n{message}",
+ parse_mode="HTML",
+ reply_markup=get_backup_manage_keyboard(filename)
+ )
+
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def restore_backup_start(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext
+):
+ if callback.data.startswith("backup_restore_file_"):
+ # Восстановление из конкретного файла
+ filename = callback.data.replace("backup_restore_file_", "")
+
+ text = f"📥 Восстановление из бекапа\n\n"
+ text += f"📄 Файл: {filename}\n\n"
+ text += "⚠️ ВНИМАНИЕ!\n"
+ text += "• Процесс может занять несколько минут\n"
+ text += "• Рекомендуется создать бекап перед восстановлением\n"
+ text += "• Существующие данные будут дополнены\n\n"
+ text += "Продолжить восстановление?"
+
+ keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(text="✅ Да, восстановить", callback_data=f"backup_restore_execute_{filename}"),
+ InlineKeyboardButton(text="🗑️ Очистить и восстановить", callback_data=f"backup_restore_clear_{filename}")
+ ],
+ [
+ InlineKeyboardButton(text="❌ Отмена", callback_data=f"backup_manage_{filename}")
+ ]
+ ])
+ else:
+ text = """📥 Восстановление из бекапа
+
+📎 Отправьте файл бекапа (.json или .json.gz)
+
+⚠️ ВАЖНО:
+• Файл должен быть создан этой системой бекапов
+• Процесс может занять несколько минут
+• Рекомендуется создать бекап перед восстановлением
+
+💡 Или выберите из существующих бекапов ниже."""
+
+ keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="📋 Выбрать из списка", callback_data="backup_list")],
+ [InlineKeyboardButton(text="❌ Отмена", callback_data="backup_panel")]
+ ])
+
+ await state.set_state(BackupStates.waiting_backup_file)
+
+ await callback.message.edit_text(
+ text,
+ parse_mode="HTML",
+ reply_markup=keyboard
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def restore_backup_execute(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ if callback.data.startswith("backup_restore_execute_"):
+ filename = callback.data.replace("backup_restore_execute_", "")
+ clear_existing = False
+ elif callback.data.startswith("backup_restore_clear_"):
+ filename = callback.data.replace("backup_restore_clear_", "")
+ clear_existing = True
+ else:
+ await callback.answer("❌ Неверный формат команды", show_alert=True)
+ return
+
+ await callback.answer("🔄 Восстановление запущено...")
+
+ # Показываем прогресс
+ action_text = "очисткой и восстановлением" if clear_existing else "восстановлением"
+ progress_msg = await callback.message.edit_text(
+ f"📥 Восстановление из бекапа...\n\n"
+ f"⏳ Работаем с {action_text} данных...\n"
+ f"📄 Файл: {filename}\n\n"
+ f"Это может занять несколько минут.",
+ parse_mode="HTML"
+ )
+
+ backup_path = backup_service.backup_dir / filename
+
+ success, message = await backup_service.restore_backup(
+ str(backup_path),
+ clear_existing=clear_existing
+ )
+
+ if success:
+ await progress_msg.edit_text(
+ f"✅ Восстановление завершено!\n\n{message}",
+ parse_mode="HTML",
+ reply_markup=get_backup_main_keyboard(db_user.language)
+ )
+ else:
+ await progress_msg.edit_text(
+ f"❌ Ошибка восстановления\n\n{message}",
+ parse_mode="HTML",
+ reply_markup=get_backup_manage_keyboard(filename)
+ )
+
+
+@admin_required
+@error_handler
+async def handle_backup_file_upload(
+ message: types.Message,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext
+):
+ if not message.document:
+ await message.answer(
+ "❌ Пожалуйста, отправьте файл бекапа (.json или .json.gz)",
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
+ ])
+ )
+ return
+
+ document = message.document
+
+ if not (document.file_name.endswith('.json') or document.file_name.endswith('.json.gz')):
+ await message.answer(
+ "❌ Неподдерживаемый формат файла. Загрузите .json или .json.gz файл",
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
+ ])
+ )
+ return
+
+ if document.file_size > 50 * 1024 * 1024:
+ await message.answer(
+ "❌ Файл слишком большой (максимум 50MB)",
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
+ ])
+ )
+ return
+
+ try:
+ file = await message.bot.get_file(document.file_id)
+
+ temp_path = backup_service.backup_dir / f"uploaded_{document.file_name}"
+
+ await message.bot.download_file(file.file_path, temp_path)
+
+ text = f"""📥 Файл загружен
+
+📄 Имя: {document.file_name}
+💾 Размер: {document.file_size / 1024 / 1024:.2f} MB
+
+⚠️ ВНИМАНИЕ!
+Процесс восстановления изменит данные в базе.
+Рекомендуется создать бекап перед восстановлением.
+
+Продолжить?"""
+
+ keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(text="✅ Восстановить", callback_data=f"backup_restore_uploaded_{temp_path.name}"),
+ InlineKeyboardButton(text="🗑️ Очистить и восстановить", callback_data=f"backup_restore_uploaded_clear_{temp_path.name}")
+ ],
+ [
+ InlineKeyboardButton(text="❌ Отмена", callback_data="backup_panel")
+ ]
+ ])
+
+ await message.answer(text, parse_mode="HTML", reply_markup=keyboard)
+ await state.clear()
+
+ except Exception as e:
+ logger.error(f"Ошибка загрузки файла бекапа: {e}")
+ await message.answer(
+ f"❌ Ошибка загрузки файла: {str(e)}",
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
+ ])
+ )
+
+
+@admin_required
+@error_handler
+async def show_backup_settings(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ settings_obj = await backup_service.get_backup_settings()
+
+ text = f"""⚙️ Настройки системы бекапов
+
+🔄 Автоматические бекапы:
+• Статус: {'✅ Включены' if settings_obj.auto_backup_enabled else '❌ Отключены'}
+• Интервал: {settings_obj.backup_interval_hours} часов
+• Время запуска: {settings_obj.backup_time}
+
+📦 Хранение:
+• Максимум файлов: {settings_obj.max_backups_keep}
+• Сжатие: {'✅ Включено' if settings_obj.compression_enabled else '❌ Отключено'}
+• Включать логи: {'✅ Да' if settings_obj.include_logs else '❌ Нет'}
+
+📁 Расположение: {settings_obj.backup_location}
+"""
+
+ await callback.message.edit_text(
+ text,
+ parse_mode="HTML",
+ reply_markup=get_backup_settings_keyboard(settings_obj)
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def toggle_backup_setting(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ settings_obj = await backup_service.get_backup_settings()
+
+ if callback.data == "backup_toggle_auto":
+ new_value = not settings_obj.auto_backup_enabled
+ await backup_service.update_backup_settings(auto_backup_enabled=new_value)
+ status = "включены" if new_value else "отключены"
+ await callback.answer(f"Автобекапы {status}")
+
+ elif callback.data == "backup_toggle_compression":
+ new_value = not settings_obj.compression_enabled
+ await backup_service.update_backup_settings(compression_enabled=new_value)
+ status = "включено" if new_value else "отключено"
+ await callback.answer(f"Сжатие {status}")
+
+ elif callback.data == "backup_toggle_logs":
+ new_value = not settings_obj.include_logs
+ await backup_service.update_backup_settings(include_logs=new_value)
+ status = "включены" if new_value else "отключены"
+ await callback.answer(f"Логи в бекапе {status}")
+
+ await show_backup_settings(callback, db_user, db)
+
+
+def register_handlers(dp: Dispatcher):
+
+ dp.callback_query.register(
+ show_backup_panel,
+ F.data == "backup_panel"
+ )
+
+ dp.callback_query.register(
+ create_backup_handler,
+ F.data == "backup_create"
+ )
+
+ dp.callback_query.register(
+ show_backup_list,
+ F.data.startswith("backup_list")
+ )
+
+ dp.callback_query.register(
+ manage_backup_file,
+ F.data.startswith("backup_manage_")
+ )
+
+ dp.callback_query.register(
+ delete_backup_confirm,
+ F.data.startswith("backup_delete_") & ~F.data.startswith("backup_delete_confirm_")
+ )
+
+ dp.callback_query.register(
+ delete_backup_execute,
+ F.data.startswith("backup_delete_confirm_")
+ )
+
+ dp.callback_query.register(
+ restore_backup_start,
+ F.data.in_(["backup_restore"]) | F.data.startswith("backup_restore_file_")
+ )
+
+ dp.callback_query.register(
+ restore_backup_execute,
+ F.data.startswith("backup_restore_execute_") | F.data.startswith("backup_restore_clear_")
+ )
+
+ dp.callback_query.register(
+ show_backup_settings,
+ F.data == "backup_settings"
+ )
+
+ dp.callback_query.register(
+ toggle_backup_setting,
+ F.data.in_(["backup_toggle_auto", "backup_toggle_compression", "backup_toggle_logs"])
+ )
+
+ dp.message.register(
+ handle_backup_file_upload,
+ BackupStates.waiting_backup_file
+ )
diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py
index 05d3aee7..67a74a87 100644
--- a/app/keyboards/admin.py
+++ b/app/keyboards/admin.py
@@ -32,6 +32,9 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel"),
InlineKeyboardButton(text="🔄 Обновления", callback_data="admin_updates")
],
+ [
+ InlineKeyboardButton(text="🗄️ Бекапы", callback_data="backup_panel")
+ ],
[
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
]
@@ -660,3 +663,6 @@ def get_sync_simplified_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)
+
+
+
diff --git a/app/services/backup_service.py b/app/services/backup_service.py
new file mode 100644
index 00000000..12c48166
--- /dev/null
+++ b/app/services/backup_service.py
@@ -0,0 +1,574 @@
+import asyncio
+import json as json_lib
+import logging
+import gzip
+import os
+import tempfile
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Dict, Any, Optional, List, Tuple
+from dataclasses import dataclass, asdict
+import aiofiles
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, text, inspect
+from sqlalchemy.orm import selectinload
+
+from app.config import settings
+from app.database.database import get_db, engine
+from app.database.models import (
+ User, Subscription, Transaction, PromoCode, PromoCodeUse,
+ ReferralEarning, Squad, ServiceRule, SystemSetting, MonitoringLog,
+ SubscriptionConversion, SentNotification, BroadcastHistory,
+ ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment,
+ CryptoBotPayment, Base
+)
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class BackupMetadata:
+ timestamp: str
+ version: str = "1.0"
+ database_type: str = "postgresql"
+ backup_type: str = "full"
+ tables_count: int = 0
+ total_records: int = 0
+ compressed: bool = True
+ file_size_bytes: int = 0
+ created_by: Optional[int] = None
+
+
+@dataclass
+class BackupSettings:
+ auto_backup_enabled: bool = True
+ backup_interval_hours: int = 24
+ backup_time: str = "03:00"
+ max_backups_keep: int = 7
+ compression_enabled: bool = True
+ include_logs: bool = False
+ backup_location: str = "/app/data/backups"
+
+
+class BackupService:
+
+ def __init__(self, bot=None):
+ self.bot = bot
+ self.backup_dir = Path(settings.SQLITE_PATH).parent / "backups"
+ self.backup_dir.mkdir(exist_ok=True)
+ self._auto_backup_task = None
+ self._settings = self._load_settings()
+
+ self.backup_models = [
+ User, Subscription, Transaction, PromoCode, PromoCodeUse,
+ ReferralEarning, ServiceRule, SystemSetting,
+ SubscriptionConversion, SentNotification, BroadcastHistory,
+ ServerSquad, SubscriptionServer, UserMessage,
+ YooKassaPayment, CryptoBotPayment
+ ]
+
+ if self._settings.include_logs:
+ self.backup_models.append(MonitoringLog)
+
+ def _load_settings(self) -> BackupSettings:
+ return BackupSettings(
+ auto_backup_enabled=os.getenv("BACKUP_AUTO_ENABLED", "true").lower() == "true",
+ backup_interval_hours=int(os.getenv("BACKUP_INTERVAL_HOURS", "24")),
+ backup_time=os.getenv("BACKUP_TIME", "03:00"),
+ max_backups_keep=int(os.getenv("BACKUP_MAX_KEEP", "7")),
+ compression_enabled=os.getenv("BACKUP_COMPRESSION", "true").lower() == "true",
+ include_logs=os.getenv("BACKUP_INCLUDE_LOGS", "false").lower() == "true",
+ backup_location=os.getenv("BACKUP_LOCATION", "/app/data/backups")
+ )
+
+ async def create_backup(
+ self,
+ created_by: Optional[int] = None,
+ compress: bool = True,
+ include_logs: bool = None
+ ) -> Tuple[bool, str, Optional[str]]:
+ try:
+ logger.info("🔄 Начинаем создание бекапа...")
+
+ if include_logs is None:
+ include_logs = self._settings.include_logs
+
+ models_to_backup = self.backup_models.copy()
+ if not include_logs and MonitoringLog in models_to_backup:
+ models_to_backup.remove(MonitoringLog)
+ elif include_logs and MonitoringLog not in models_to_backup:
+ models_to_backup.append(MonitoringLog)
+
+ backup_data = {}
+ total_records = 0
+
+ async for db in get_db():
+ try:
+ for model in models_to_backup:
+ table_name = model.__tablename__
+ logger.info(f"📊 Экспортируем таблицу: {table_name}")
+
+ result = await db.execute(select(model))
+ records = result.scalars().all()
+
+ table_data = []
+ for record in records:
+ record_dict = {}
+ for column in model.__table__.columns:
+ value = getattr(record, column.name)
+
+ if isinstance(value, datetime):
+ record_dict[column.name] = value.isoformat()
+ elif hasattr(value, '__dict__'):
+ record_dict[column.name] = str(value)
+ else:
+ record_dict[column.name] = value
+
+ table_data.append(record_dict)
+
+ backup_data[table_name] = table_data
+ total_records += len(table_data)
+
+ logger.info(f"✅ Экспортировано {len(table_data)} записей из {table_name}")
+
+ break
+ except Exception as e:
+ logger.error(f"Ошибка при экспорте данных: {e}")
+ raise e
+ finally:
+ await db.close()
+
+ metadata = BackupMetadata(
+ timestamp=datetime.utcnow().isoformat(),
+ database_type="postgresql" if settings.is_postgresql() else "sqlite",
+ backup_type="full",
+ tables_count=len(models_to_backup),
+ total_records=total_records,
+ compressed=compress,
+ created_by=created_by,
+ file_size_bytes=0
+ )
+
+ timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
+ filename = f"backup_{timestamp}.json"
+ if compress:
+ filename += ".gz"
+
+ backup_path = self.backup_dir / filename
+
+ backup_structure = {
+ "metadata": asdict(metadata),
+ "data": backup_data
+ }
+
+ if compress:
+ backup_json_str = json_lib.dumps(backup_structure, ensure_ascii=False, indent=2)
+ async with aiofiles.open(backup_path, 'wb') as f:
+ compressed_data = gzip.compress(backup_json_str.encode('utf-8'))
+ await f.write(compressed_data)
+ else:
+ async with aiofiles.open(backup_path, 'w', encoding='utf-8') as f:
+ await f.write(json_lib.dumps(backup_structure, ensure_ascii=False, indent=2))
+
+ file_size = backup_path.stat().st_size
+ backup_structure["metadata"]["file_size_bytes"] = file_size
+
+ if compress:
+ backup_json_str = json_lib.dumps(backup_structure, ensure_ascii=False, indent=2)
+ async with aiofiles.open(backup_path, 'wb') as f:
+ compressed_data = gzip.compress(backup_json_str.encode('utf-8'))
+ await f.write(compressed_data)
+ else:
+ async with aiofiles.open(backup_path, 'w', encoding='utf-8') as f:
+ await f.write(json_lib.dumps(backup_structure, ensure_ascii=False, indent=2))
+
+ await self._cleanup_old_backups()
+
+ size_mb = file_size / 1024 / 1024
+ message = (f"✅ Бекап успешно создан!\n"
+ f"📁 Файл: {filename}\n"
+ f"📊 Таблиц: {len(models_to_backup)}\n"
+ f"📈 Записей: {total_records:,}\n"
+ f"💾 Размер: {size_mb:.2f} MB")
+
+ logger.info(message)
+
+ if self.bot:
+ await self._send_backup_notification(
+ "success", message, str(backup_path)
+ )
+
+ return True, message, str(backup_path)
+
+ except Exception as e:
+ error_msg = f"❌ Ошибка создания бекапа: {str(e)}"
+ logger.error(error_msg, exc_info=True)
+
+ if self.bot:
+ await self._send_backup_notification("error", error_msg)
+
+ return False, error_msg, None
+
+ async def restore_backup(
+ self,
+ backup_file_path: str,
+ clear_existing: bool = False
+ ) -> Tuple[bool, str]:
+ try:
+ logger.info(f"🔄 Начинаем восстановление из {backup_file_path}")
+
+ backup_path = Path(backup_file_path)
+ if not backup_path.exists():
+ return False, f"❌ Файл бекапа не найден: {backup_file_path}"
+
+ if backup_path.suffix == '.gz':
+ async with aiofiles.open(backup_path, 'rb') as f:
+ compressed_data = await f.read()
+ uncompressed_data = gzip.decompress(compressed_data).decode('utf-8')
+ backup_structure = json_lib.loads(uncompressed_data)
+ else:
+ async with aiofiles.open(backup_path, 'r', encoding='utf-8') as f:
+ file_content = await f.read()
+ backup_structure = json_lib.loads(file_content)
+
+ metadata = backup_structure.get("metadata", {})
+ backup_data = backup_structure.get("data", {})
+
+ if not backup_data:
+ return False, "❌ Файл бекапа не содержит данных"
+
+ logger.info(f"📊 Загружен бекап от {metadata.get('timestamp')}")
+ logger.info(f"📈 Содержит {metadata.get('total_records', 0)} записей")
+
+ restored_records = 0
+ restored_tables = 0
+
+ async for db in get_db():
+ try:
+ if clear_existing:
+ logger.warning("🗑️ Очищаем существующие данные...")
+ await self._clear_database_tables(db)
+
+ for table_name, records in backup_data.items():
+ if not records:
+ continue
+
+ model = None
+ for m in self.backup_models:
+ if m.__tablename__ == table_name:
+ model = m
+ break
+
+ if not model:
+ logger.warning(f"⚠️ Модель для таблицы {table_name} не найдена, пропускаем")
+ continue
+
+ logger.info(f"📥 Восстанавливаем таблицу {table_name} ({len(records)} записей)")
+
+ for record_data in records:
+ try:
+ processed_data = {}
+ for key, value in record_data.items():
+ if value is None:
+ processed_data[key] = None
+ continue
+
+ column = getattr(model.__table__.columns, key, None)
+ if column is None:
+ continue
+
+ column_type_str = str(column.type).upper()
+ if ('DATETIME' in column_type_str or 'TIMESTAMP' in column_type_str) and isinstance(value, str):
+ try:
+ if 'T' in value:
+ processed_data[key] = datetime.fromisoformat(value.replace('Z', '+00:00'))
+ else:
+ processed_data[key] = datetime.strptime(value, '%Y-%m-%d %H:%M:%S')
+ except (ValueError, TypeError) as e:
+ logger.warning(f"Не удалось парсить дату {value} для поля {key}: {e}")
+ processed_data[key] = datetime.utcnow()
+ elif ('BOOLEAN' in column_type_str or 'BOOL' in column_type_str) and isinstance(value, str):
+ processed_data[key] = value.lower() in ('true', '1', 'yes', 'on')
+ elif ('INTEGER' in column_type_str or 'INT' in column_type_str) and isinstance(value, str):
+ try:
+ processed_data[key] = int(value)
+ except ValueError:
+ processed_data[key] = 0
+ elif ('FLOAT' in column_type_str or 'REAL' in column_type_str or 'NUMERIC' in column_type_str) and isinstance(value, str):
+ try:
+ processed_data[key] = float(value)
+ except ValueError:
+ processed_data[key] = 0.0
+ elif 'JSON' in column_type_str and isinstance(value, str):
+ try:
+ processed_data[key] = json_lib.loads(value)
+ except (ValueError, TypeError):
+ processed_data[key] = value
+ else:
+ processed_data[key] = value
+
+ # Проверяем существует ли запись с таким ID
+ primary_key_col = None
+ for col in model.__table__.columns:
+ if col.primary_key:
+ primary_key_col = col.name
+ break
+
+ if primary_key_col and primary_key_col in processed_data:
+ # Проверяем существование записи
+ existing_record = await db.execute(
+ select(model).where(
+ getattr(model, primary_key_col) == processed_data[primary_key_col]
+ )
+ )
+ existing = existing_record.scalar_one_or_none()
+
+ if existing:
+ # Обновляем существующую запись
+ for key, value in processed_data.items():
+ if key != primary_key_col: # Не обновляем primary key
+ setattr(existing, key, value)
+ logger.debug(f"Обновлена существующая запись {primary_key_col}={processed_data[primary_key_col]} в {table_name}")
+ else:
+ # Создаем новую запись
+ instance = model(**processed_data)
+ db.add(instance)
+ else:
+ # Если нет primary key или он не в данных, просто добавляем
+ instance = model(**processed_data)
+ db.add(instance)
+
+ restored_records += 1
+
+ except Exception as e:
+ logger.error(f"Ошибка восстановления записи в {table_name}: {e}")
+ continue
+
+ restored_tables += 1
+ logger.info(f"✅ Таблица {table_name} восстановлена")
+
+ await db.commit()
+
+ break
+
+ except Exception as e:
+ await db.rollback()
+ logger.error(f"Ошибка при восстановлении: {e}")
+ raise e
+ finally:
+ await db.close()
+
+ message = (f"✅ Восстановление завершено!\n"
+ f"📊 Таблиц: {restored_tables}\n"
+ f"📈 Записей: {restored_records:,}\n"
+ f"📅 Дата бекапа: {metadata.get('timestamp', 'неизвестно')}")
+
+ logger.info(message)
+
+ if self.bot:
+ await self._send_backup_notification("restore_success", message)
+
+ return True, message
+
+ except Exception as e:
+ error_msg = f"❌ Ошибка восстановления: {str(e)}"
+ logger.error(error_msg, exc_info=True)
+
+ if self.bot:
+ await self._send_backup_notification("restore_error", error_msg)
+
+ return False, error_msg
+
+ async def _clear_database_tables(self, db: AsyncSession):
+ tables_order = [
+ "subscription_servers", "sent_notifications", "broadcast_history",
+ "subscription_conversions", "referral_earnings", "promocode_uses",
+ "transactions", "yookassa_payments", "cryptobot_payments",
+ "subscriptions", "users", "promocodes", "server_squads",
+ "service_rules", "system_settings", "monitoring_logs", "user_messages"
+ ]
+
+ for table_name in tables_order:
+ try:
+ await db.execute(text(f"DELETE FROM {table_name}"))
+ logger.info(f"🗑️ Очищена таблица {table_name}")
+ except Exception as e:
+ logger.warning(f"⚠️ Не удалось очистить таблицу {table_name}: {e}")
+
+ async def get_backup_list(self) -> List[Dict[str, Any]]:
+ backups = []
+
+ try:
+ for backup_file in sorted(self.backup_dir.glob("backup_*.json*"), reverse=True):
+ try:
+ if backup_file.suffix == '.gz':
+ with gzip.open(backup_file, 'rt', encoding='utf-8') as f:
+ backup_structure = json_lib.load(f)
+ else:
+ with open(backup_file, 'r', encoding='utf-8') as f:
+ backup_structure = json_lib.load(f)
+
+ metadata = backup_structure.get("metadata", {})
+ file_stats = backup_file.stat()
+
+ backup_info = {
+ "filename": backup_file.name,
+ "filepath": str(backup_file),
+ "timestamp": metadata.get("timestamp"),
+ "tables_count": metadata.get("tables_count", 0),
+ "total_records": metadata.get("total_records", 0),
+ "compressed": metadata.get("compressed", False),
+ "file_size_bytes": file_stats.st_size,
+ "file_size_mb": round(file_stats.st_size / 1024 / 1024, 2),
+ "created_by": metadata.get("created_by"),
+ "database_type": metadata.get("database_type", "unknown")
+ }
+
+ backups.append(backup_info)
+
+ except Exception as e:
+ logger.error(f"Ошибка чтения метаданных {backup_file}: {e}")
+ file_stats = backup_file.stat()
+ backups.append({
+ "filename": backup_file.name,
+ "filepath": str(backup_file),
+ "timestamp": datetime.fromtimestamp(file_stats.st_mtime).isoformat(),
+ "tables_count": "?",
+ "total_records": "?",
+ "compressed": backup_file.suffix == '.gz',
+ "file_size_bytes": file_stats.st_size,
+ "file_size_mb": round(file_stats.st_size / 1024 / 1024, 2),
+ "created_by": None,
+ "database_type": "unknown",
+ "error": f"Ошибка чтения: {str(e)}"
+ })
+
+ except Exception as e:
+ logger.error(f"Ошибка получения списка бекапов: {e}")
+
+ return backups
+
+ async def delete_backup(self, backup_filename: str) -> Tuple[bool, str]:
+ try:
+ backup_path = self.backup_dir / backup_filename
+
+ if not backup_path.exists():
+ return False, f"❌ Файл бекапа не найден: {backup_filename}"
+
+ backup_path.unlink()
+ message = f"✅ Бекап {backup_filename} удален"
+ logger.info(message)
+
+ return True, message
+
+ except Exception as e:
+ error_msg = f"❌ Ошибка удаления бекапа: {str(e)}"
+ logger.error(error_msg)
+ return False, error_msg
+
+ async def _cleanup_old_backups(self):
+ try:
+ backups = await self.get_backup_list()
+
+ if len(backups) > self._settings.max_backups_keep:
+ backups.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
+
+ for backup in backups[self._settings.max_backups_keep:]:
+ try:
+ await self.delete_backup(backup["filename"])
+ logger.info(f"🗑️ Удален старый бекап: {backup['filename']}")
+ except Exception as e:
+ logger.error(f"Ошибка удаления старого бекапа {backup['filename']}: {e}")
+
+ except Exception as e:
+ logger.error(f"Ошибка очистки старых бекапов: {e}")
+
+ async def get_backup_settings(self) -> BackupSettings:
+ return self._settings
+
+ async def update_backup_settings(self, **kwargs) -> bool:
+ try:
+ for key, value in kwargs.items():
+ if hasattr(self._settings, key):
+ setattr(self._settings, key, value)
+
+ if self._settings.auto_backup_enabled:
+ await self.start_auto_backup()
+ else:
+ await self.stop_auto_backup()
+
+ return True
+
+ except Exception as e:
+ logger.error(f"Ошибка обновления настроек бекапов: {e}")
+ return False
+
+ async def start_auto_backup(self):
+ if self._auto_backup_task and not self._auto_backup_task.done():
+ self._auto_backup_task.cancel()
+
+ if self._settings.auto_backup_enabled:
+ self._auto_backup_task = asyncio.create_task(self._auto_backup_loop())
+ logger.info(f"🔄 Автобекапы включены, интервал: {self._settings.backup_interval_hours}ч")
+
+ async def stop_auto_backup(self):
+ if self._auto_backup_task and not self._auto_backup_task.done():
+ self._auto_backup_task.cancel()
+ logger.info("⏹️ Автобекапы остановлены")
+
+ async def _auto_backup_loop(self):
+ while True:
+ try:
+ await asyncio.sleep(self._settings.backup_interval_hours * 3600)
+
+ logger.info("🔄 Запуск автоматического бекапа...")
+ success, message, _ = await self.create_backup()
+
+ if success:
+ logger.info(f"✅ Автобекап завершен: {message}")
+ else:
+ logger.error(f"❌ Ошибка автобекапа: {message}")
+
+ except asyncio.CancelledError:
+ break
+ except Exception as e:
+ logger.error(f"Ошибка в цикле автобекапов: {e}")
+ await asyncio.sleep(3600)
+
+ async def _send_backup_notification(
+ self,
+ event_type: str,
+ message: str,
+ file_path: str = None
+ ):
+ try:
+ if not settings.is_admin_notifications_enabled():
+ return
+
+ icons = {
+ "success": "✅",
+ "error": "❌",
+ "restore_success": "📥",
+ "restore_error": "❌"
+ }
+
+ icon = icons.get(event_type, "ℹ️")
+ notification_text = f"{icon} СИСТЕМА БЕКАПОВ\n\n{message}"
+
+ if file_path:
+ notification_text += f"\n📁 {Path(file_path).name}"
+
+ notification_text += f"\n\n⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}"
+
+ try:
+ from app.services.admin_notification_service import AdminNotificationService
+ admin_service = AdminNotificationService(self.bot)
+ await admin_service._send_message(notification_text)
+ except Exception as e:
+ logger.error(f"Ошибка отправки уведомления через AdminNotificationService: {e}")
+
+ except Exception as e:
+ logger.error(f"Ошибка отправки уведомления о бекапе: {e}")
+
+
+backup_service = BackupService()
diff --git a/main.py b/main.py
index c3292b2b..e600106e 100644
--- a/main.py
+++ b/main.py
@@ -17,6 +17,7 @@ from app.services.version_service import version_service
from app.external.webhook_server import WebhookServer
from app.external.yookassa_webhook import start_yookassa_webhook_server
from app.database.universal_migration import run_universal_migration
+from app.services.backup_service import backup_service
class GracefulExit:
@@ -89,6 +90,20 @@ async def main():
logger.info(f"📦 Текущая версия: {version_service.current_version}")
logger.info("🔗 Бот подключен к сервисам мониторинга и техработ")
+
+ logger.info("🗄️ Инициализация сервиса бекапов...")
+ try:
+ backup_service.bot = bot
+
+ # Запускаем автобекапы если они включены
+ settings_obj = await backup_service.get_backup_settings()
+ if settings_obj.auto_backup_enabled:
+ await backup_service.start_auto_backup()
+ logger.info("✅ Автобекапы запущены")
+
+ logger.info("✅ Сервис бекапов инициализирован")
+ except Exception as e:
+ logger.error(f"❌ Ошибка инициализации сервиса бекапов: {e}")
payment_service = PaymentService(bot)
@@ -221,6 +236,12 @@ async def main():
await version_check_task
except asyncio.CancelledError:
pass
+
+ logger.info("ℹ️ Остановка сервиса бекапов...")
+ try:
+ await backup_service.stop_auto_backup()
+ except Exception as e:
+ logger.error(f"Ошибка остановки сервиса бекапов: {e}")
if polling_task and not polling_task.done():
logger.info("ℹ️ Остановка polling...")
diff --git a/requirements.txt b/requirements.txt
index 31f3fd5c..86531135 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -29,3 +29,5 @@ qrcode[pil]==7.4.2
# Для работы с версиями
packaging==23.2
+
+aiofiles==23.2.1