From b915c65c41ade074c435d68da0c2c010613c61c7 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 10 Sep 2025 22:42:35 +0300 Subject: [PATCH 01/20] Create backup_service.py --- app/services/backup_service.py | 525 +++++++++++++++++++++++++++++++++ 1 file changed, 525 insertions(+) create mode 100644 app/services/backup_service.py diff --git a/app/services/backup_service.py b/app/services/backup_service.py new file mode 100644 index 00000000..0acf84c9 --- /dev/null +++ b/app/services/backup_service.py @@ -0,0 +1,525 @@ +import asyncio +import json +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 = json.dumps(backup_structure, ensure_ascii=False, indent=2) + async with aiofiles.open(backup_path, 'wb') as f: + compressed_data = gzip.compress(backup_json.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.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 = json.dumps(backup_structure, ensure_ascii=False, indent=2) + async with aiofiles.open(backup_path, 'wb') as f: + compressed_data = gzip.compress(backup_json.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.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() + json_data = gzip.decompress(compressed_data).decode('utf-8') + backup_structure = json.loads(json_data) + else: + async with aiofiles.open(backup_path, 'r', encoding='utf-8') as f: + content = await f.read() + backup_structure = json.loads(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 + + if 'DateTime' in str(column.type) and isinstance(value, str): + try: + processed_data[key] = datetime.fromisoformat(value) + except: + processed_data[key] = value + else: + processed_data[key] = value + + 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.load(f) + else: + with open(backup_file, 'r', encoding='utf-8') as f: + backup_structure = json.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() From f8e2e23082991888416b24fe5e293b63fe841a20 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 10 Sep 2025 22:45:42 +0300 Subject: [PATCH 02/20] Create backup.py --- app/handlers/admin/backup.py | 749 +++++++++++++++++++++++++++++++++++ 1 file changed, 749 insertions(+) create mode 100644 app/handlers/admin/backup.py diff --git a/app/handlers/admin/backup.py b/app/handlers/admin/backup.py new file mode 100644 index 00000000..273baa6d --- /dev/null +++ b/app/handlers/admin/backup.py @@ -0,0 +1,749 @@ +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 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 +from app.keyboards.admin import ( + get_admin_main_keyboard, + get_confirmation_keyboard, + get_admin_pagination_keyboard +) + +logger = logging.getLogger(__name__) + + +class BackupStates(StatesGroup): + waiting_backup_file = State() + waiting_settings_update = State() + + +def get_backup_main_keyboard(language: str = "ru"): + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_backup_manage_keyboard(backup_filename: str): + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="📥 Восстановить", callback_data=f"backup_restore_file_{backup_filename}"), + InlineKeyboardButton(text="📄 Информация", callback_data=f"backup_info_{backup_filename}") + ], + [ + InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"backup_delete_{backup_filename}") + ], + [ + InlineKeyboardButton(text="◀️ К списку", callback_data="backup_list") + ] + ]) + + +def get_backup_settings_keyboard(settings_obj): + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + 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=f"⏰ Интервал: {settings_obj.backup_interval_hours}ч", + callback_data="backup_set_interval" + ), + InlineKeyboardButton( + text=f"📦 Хранить: {settings_obj.max_backups_keep}шт", + callback_data="backup_set_retention" + ) + ], + [ + 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 restore_uploaded_backup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + if callback.data.startswith("backup_restore_uploaded_clear_"): + filename = callback.data.replace("backup_restore_uploaded_clear_", "") + clear_existing = True + else: + filename = callback.data.replace("backup_restore_uploaded_", "") + clear_existing = False + + await callback.answer("🔄 Восстановление запущено...") + + temp_path = backup_service.backup_dir / filename + + if not temp_path.exists(): + await callback.message.edit_text( + "❌ Временный файл не найден. Попробуйте загрузить файл заново.", + reply_markup=get_backup_main_keyboard(db_user.language) + ) + return + + action_text = "очисткой и восстановлением" if clear_existing else "восстановлением" + progress_msg = await callback.message.edit_text( + f"📥 Восстановление из загруженного файла...\n\n" + f"⏳ Работаем с {action_text} данных...\n" + f"Это может занять несколько минут.", + parse_mode="HTML" + ) + + try: + success, message = await backup_service.restore_backup( + str(temp_path), + clear_existing=clear_existing + ) + + try: + temp_path.unlink() + except: + pass + + 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) + ) + + except Exception as e: + try: + temp_path.unlink() + except: + pass + + await progress_msg.edit_text( + f"❌ Ошибка восстановления\n\n{str(e)}", + parse_mode="HTML", + reply_markup=get_backup_main_keyboard(db_user.language) + ) + + +@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( + restore_uploaded_backup, + F.data.startswith("backup_restore_uploaded_") + ) + + 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 + )d=[ + [ + InlineKeyboardButton(text="🚀 Создать бекап", callback_data="backup_create"), + InlineKeyboardButton(text="📥 Восстановить", callback_data="backup_restore") + ], + [ + InlineKeyboardButton(text="📋 Список бекапов", callback_data="backup_list"), + InlineKeyboardButton(text="📊 Журнал операций", callback_data="backup_logs") + ], + [ + InlineKeyboardButton(text="⚙️ Настройки", callback_data="backup_settings"), + InlineKeyboardButton(text="🔄 Автобекапы", callback_data="backup_auto_toggle") + ], + [ + InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel") + ] + ]) + + +def get_backup_list_keyboard(backups: list, page: int = 1, per_page: int = 5): + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + 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_list")], + [InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")] + ]) + + return InlineKeyboardMarkup(inline_keyboar From f0f8c4e4cf33a68a958f370e1c43256715efc585 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 10 Sep 2025 22:47:12 +0300 Subject: [PATCH 03/20] Update admin.py --- app/keyboards/admin.py | 6 ++++++ 1 file changed, 6 insertions(+) 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) + + + From fe8cafdd99918f716ec2d356bebf7cd294aec157 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 10 Sep 2025 22:54:23 +0300 Subject: [PATCH 04/20] Update main.py --- main.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) 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...") From 92bd0c362ebb5225026681952eecf2a1983d96b4 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 10 Sep 2025 22:55:22 +0300 Subject: [PATCH 05/20] Update bot.py --- app/bot.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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) From c33e49661ca2e911e6ceba3ea9834ca2ae656c30 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 10 Sep 2025 22:55:50 +0300 Subject: [PATCH 06/20] Update config.py --- app/config.py | 8 ++++++++ 1 file changed, 8 insertions(+) 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 From 5443fdf064e413b0bb849979b4617bef6a600c88 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 10 Sep 2025 22:57:35 +0300 Subject: [PATCH 07/20] Update requirements.txt --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) 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 From f00fdcb7c84861e23c13ddd066c1c2afc43f29ba Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 11 Sep 2025 01:55:56 +0300 Subject: [PATCH 08/20] Update backup.py --- app/handlers/admin/backup.py | 47 ++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/app/handlers/admin/backup.py b/app/handlers/admin/backup.py index 273baa6d..168c4883 100644 --- a/app/handlers/admin/backup.py +++ b/app/handlers/admin/backup.py @@ -26,12 +26,14 @@ class BackupStates(StatesGroup): def get_backup_main_keyboard(language: str = "ru"): + """Главная клавиатура настроек бекапов""" from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_backup_manage_keyboard(backup_filename: str): + """Клавиатура управления конкретным бекапом""" from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton return InlineKeyboardMarkup(inline_keyboard=[ @@ -49,6 +51,7 @@ def get_backup_manage_keyboard(backup_filename: str): def get_backup_settings_keyboard(settings_obj): + """Клавиатура настроек бекапов""" from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton auto_status = "✅ Включены" if settings_obj.auto_backup_enabled else "❌ Отключены" @@ -97,6 +100,7 @@ async def show_backup_panel( db_user: User, db: AsyncSession ): + """Показывает главную панель бекапов""" settings_obj = await backup_service.get_backup_settings() status_auto = "✅ Включены" if settings_obj.auto_backup_enabled else "❌ Отключены" @@ -133,8 +137,10 @@ async def create_backup_handler( db_user: User, db: AsyncSession ): + """Запускает создание бекапа""" await callback.answer("🔄 Создание бекапа запущено...") + # Показываем сообщение о начале процесса progress_msg = await callback.message.edit_text( "🔄 Создание бекапа...\n\n" "⏳ Экспортируем данные из базы...\n" @@ -142,6 +148,7 @@ async def create_backup_handler( parse_mode="HTML" ) + # Создаем бекап success, message, file_path = await backup_service.create_backup( created_by=db_user.telegram_id, compress=True @@ -168,6 +175,8 @@ async def show_backup_list( db_user: User, db: AsyncSession ): + """Показывает список бекапов""" + # Извлекаем номер страницы из callback_data page = 1 if callback.data.startswith("backup_list_page_"): try: @@ -203,6 +212,7 @@ async def manage_backup_file( db_user: User, db: AsyncSession ): + """Управление конкретным файлом бекапа""" filename = callback.data.replace("backup_manage_", "") backups = await backup_service.get_backup_list() @@ -217,6 +227,7 @@ async def manage_backup_file( await callback.answer("❌ Файл бекапа не найден", show_alert=True) return + # Форматируем информацию о бекапе try: if backup_info.get("timestamp"): dt = datetime.fromisoformat(backup_info["timestamp"].replace('Z', '+00:00')) @@ -255,6 +266,7 @@ async def delete_backup_confirm( db_user: User, db: AsyncSession ): + """Подтверждение удаления бекапа""" filename = callback.data.replace("backup_delete_", "") text = f"🗑️ Удаление бекапа\n\n" @@ -284,6 +296,7 @@ async def delete_backup_execute( db_user: User, db: AsyncSession ): + """Выполняет удаление бекапа""" filename = callback.data.replace("backup_delete_confirm_", "") success, message = await backup_service.delete_backup(filename) @@ -314,7 +327,9 @@ async def restore_backup_start( db: AsyncSession, state: FSMContext ): + """Начинает процесс восстановления из бекапа""" if callback.data.startswith("backup_restore_file_"): + # Восстановление из конкретного файла filename = callback.data.replace("backup_restore_file_", "") text = f"📥 Восстановление из бекапа\n\n" @@ -335,6 +350,7 @@ async def restore_backup_start( ] ]) else: + # Восстановление из загруженного файла text = """📥 Восстановление из бекапа 📎 Отправьте файл бекапа (.json или .json.gz) @@ -368,6 +384,7 @@ async def restore_backup_execute( db_user: User, db: AsyncSession ): + """Выполняет восстановление бекапа""" if callback.data.startswith("backup_restore_execute_"): filename = callback.data.replace("backup_restore_execute_", "") clear_existing = False @@ -380,6 +397,7 @@ async def restore_backup_execute( await callback.answer("🔄 Восстановление запущено...") + # Показываем прогресс action_text = "очисткой и восстановлением" if clear_existing else "восстановлением" progress_msg = await callback.message.edit_text( f"📥 Восстановление из бекапа...\n\n" @@ -389,8 +407,10 @@ async def restore_backup_execute( parse_mode="HTML" ) + # Формируем путь к файлу backup_path = backup_service.backup_dir / filename + # Выполняем восстановление success, message = await backup_service.restore_backup( str(backup_path), clear_existing=clear_existing @@ -418,6 +438,7 @@ async def handle_backup_file_upload( db: AsyncSession, state: FSMContext ): + """Обрабатывает загрузку файла бекапа""" if not message.document: await message.answer( "❌ Пожалуйста, отправьте файл бекапа (.json или .json.gz)", @@ -429,6 +450,7 @@ async def handle_backup_file_upload( document = message.document + # Проверяем расширение файла if not (document.file_name.endswith('.json') or document.file_name.endswith('.json.gz')): await message.answer( "❌ Неподдерживаемый формат файла. Загрузите .json или .json.gz файл", @@ -438,6 +460,7 @@ async def handle_backup_file_upload( ) return + # Проверяем размер файла (максимум 50MB) if document.file_size > 50 * 1024 * 1024: await message.answer( "❌ Файл слишком большой (максимум 50MB)", @@ -448,12 +471,15 @@ async def handle_backup_file_upload( 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} @@ -495,6 +521,7 @@ async def restore_uploaded_backup( db_user: User, db: AsyncSession ): + """Восстанавливает загруженный бекап""" if callback.data.startswith("backup_restore_uploaded_clear_"): filename = callback.data.replace("backup_restore_uploaded_clear_", "") clear_existing = True @@ -513,6 +540,7 @@ async def restore_uploaded_backup( ) return + # Показываем прогресс action_text = "очисткой и восстановлением" if clear_existing else "восстановлением" progress_msg = await callback.message.edit_text( f"📥 Восстановление из загруженного файла...\n\n" @@ -522,11 +550,13 @@ async def restore_uploaded_backup( ) try: + # Выполняем восстановление success, message = await backup_service.restore_backup( str(temp_path), clear_existing=clear_existing ) + # Удаляем временный файл try: temp_path.unlink() except: @@ -546,6 +576,7 @@ async def restore_uploaded_backup( ) except Exception as e: + # Удаляем временный файл при ошибке try: temp_path.unlink() except: @@ -565,6 +596,7 @@ async def show_backup_settings( db_user: User, db: AsyncSession ): + """Показывает настройки бекапов""" settings_obj = await backup_service.get_backup_settings() text = f"""⚙️ Настройки системы бекапов @@ -618,31 +650,38 @@ async def toggle_backup_setting( 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_") @@ -653,6 +692,7 @@ def register_handlers(dp: Dispatcher): F.data.startswith("backup_delete_confirm_") ) + # Восстановление dp.callback_query.register( restore_backup_start, F.data.in_(["backup_restore"]) | F.data.startswith("backup_restore_file_") @@ -668,6 +708,7 @@ def register_handlers(dp: Dispatcher): F.data.startswith("backup_restore_uploaded_") ) + # Настройки dp.callback_query.register( show_backup_settings, F.data == "backup_settings" @@ -678,6 +719,7 @@ def register_handlers(dp: Dispatcher): F.data.in_(["backup_toggle_auto", "backup_toggle_compression", "backup_toggle_logs"]) ) + # Загрузка файла dp.message.register( handle_backup_file_upload, BackupStates.waiting_backup_file @@ -701,15 +743,18 @@ def register_handlers(dp: Dispatcher): def get_backup_list_keyboard(backups: list, page: int = 1, per_page: int = 5): + """Клавиатура со списком бекапов""" from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton 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')) @@ -727,6 +772,7 @@ def get_backup_list_keyboard(backups: list, page: int = 1, per_page: int = 5): 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 = [] @@ -741,6 +787,7 @@ def get_backup_list_keyboard(backups: list, page: int = 1, per_page: int = 5): keyboard.append(nav_row) + # Управляющие кнопки keyboard.extend([ [InlineKeyboardButton(text="🔄 Обновить", callback_data="backup_list")], [InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")] From ca55d8942ee6da746058465aa30cfec443996a01 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 11 Sep 2025 01:59:37 +0300 Subject: [PATCH 09/20] Update backup.py --- app/handlers/admin/backup.py | 230 +++++++++++------------------------ 1 file changed, 69 insertions(+), 161 deletions(-) diff --git a/app/handlers/admin/backup.py b/app/handlers/admin/backup.py index 168c4883..4c6eb3cf 100644 --- a/app/handlers/admin/backup.py +++ b/app/handlers/admin/backup.py @@ -5,17 +5,13 @@ 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 -from app.keyboards.admin import ( - get_admin_main_keyboard, - get_confirmation_keyboard, - get_admin_pagination_keyboard -) logger = logging.getLogger(__name__) @@ -27,15 +23,79 @@ class BackupStates(StatesGroup): def get_backup_main_keyboard(language: str = "ru"): """Главная клавиатура настроек бекапов""" - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + 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_logs") + ], + [ + InlineKeyboardButton(text="⚙️ Настройки", callback_data="backup_settings"), + InlineKeyboardButton(text="🔄 Автобекапы", callback_data="backup_auto_toggle") + ], + [ + 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_list")], + [InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")] + ]) return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_backup_manage_keyboard(backup_filename: str): """Клавиатура управления конкретным бекапом""" - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text="📥 Восстановить", callback_data=f"backup_restore_file_{backup_filename}"), @@ -52,8 +112,6 @@ def get_backup_manage_keyboard(backup_filename: str): def get_backup_settings_keyboard(settings_obj): """Клавиатура настроек бекапов""" - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - 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 "❌ Отключены" @@ -514,81 +572,6 @@ async def handle_backup_file_upload( ) -@admin_required -@error_handler -async def restore_uploaded_backup( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - """Восстанавливает загруженный бекап""" - if callback.data.startswith("backup_restore_uploaded_clear_"): - filename = callback.data.replace("backup_restore_uploaded_clear_", "") - clear_existing = True - else: - filename = callback.data.replace("backup_restore_uploaded_", "") - clear_existing = False - - await callback.answer("🔄 Восстановление запущено...") - - temp_path = backup_service.backup_dir / filename - - if not temp_path.exists(): - await callback.message.edit_text( - "❌ Временный файл не найден. Попробуйте загрузить файл заново.", - reply_markup=get_backup_main_keyboard(db_user.language) - ) - return - - # Показываем прогресс - action_text = "очисткой и восстановлением" if clear_existing else "восстановлением" - progress_msg = await callback.message.edit_text( - f"📥 Восстановление из загруженного файла...\n\n" - f"⏳ Работаем с {action_text} данных...\n" - f"Это может занять несколько минут.", - parse_mode="HTML" - ) - - try: - # Выполняем восстановление - success, message = await backup_service.restore_backup( - str(temp_path), - clear_existing=clear_existing - ) - - # Удаляем временный файл - try: - temp_path.unlink() - except: - pass - - 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) - ) - - except Exception as e: - # Удаляем временный файл при ошибке - try: - temp_path.unlink() - except: - pass - - await progress_msg.edit_text( - f"❌ Ошибка восстановления\n\n{str(e)}", - parse_mode="HTML", - reply_markup=get_backup_main_keyboard(db_user.language) - ) - - @admin_required @error_handler async def show_backup_settings( @@ -703,11 +686,6 @@ def register_handlers(dp: Dispatcher): F.data.startswith("backup_restore_execute_") | F.data.startswith("backup_restore_clear_") ) - dp.callback_query.register( - restore_uploaded_backup, - F.data.startswith("backup_restore_uploaded_") - ) - # Настройки dp.callback_query.register( show_backup_settings, @@ -723,74 +701,4 @@ def register_handlers(dp: Dispatcher): dp.message.register( handle_backup_file_upload, BackupStates.waiting_backup_file - )d=[ - [ - InlineKeyboardButton(text="🚀 Создать бекап", callback_data="backup_create"), - InlineKeyboardButton(text="📥 Восстановить", callback_data="backup_restore") - ], - [ - InlineKeyboardButton(text="📋 Список бекапов", callback_data="backup_list"), - InlineKeyboardButton(text="📊 Журнал операций", callback_data="backup_logs") - ], - [ - InlineKeyboardButton(text="⚙️ Настройки", callback_data="backup_settings"), - InlineKeyboardButton(text="🔄 Автобекапы", callback_data="backup_auto_toggle") - ], - [ - InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel") - ] - ]) - - -def get_backup_list_keyboard(backups: list, page: int = 1, per_page: int = 5): - """Клавиатура со списком бекапов""" - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - - 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_list")], - [InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")] - ]) - - return InlineKeyboardMarkup(inline_keyboar + ) From d54b455007209b779114fa2e65ce10ba7d4cac4d Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 11 Sep 2025 02:09:45 +0300 Subject: [PATCH 10/20] Update backup_service.py --- app/services/backup_service.py | 36 ++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/app/services/backup_service.py b/app/services/backup_service.py index 0acf84c9..d67b31fe 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -71,6 +71,7 @@ class BackupService: 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")), @@ -146,7 +147,7 @@ class BackupService: total_records=total_records, compressed=compress, created_by=created_by, - file_size_bytes=0 + file_size_bytes=0 ) timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") @@ -277,10 +278,33 @@ class BackupService: if column is None: continue - if 'DateTime' in str(column.type) and isinstance(value, str): + column_type_str = str(column.type).upper() + if ('DATETIME' in column_type_str or 'TIMESTAMP' in column_type_str) and isinstance(value, str): try: - processed_data[key] = datetime.fromisoformat(value) - except: + 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: + import json + processed_data[key] = json.loads(value) + except (ValueError, TypeError): processed_data[key] = value else: processed_data[key] = value @@ -422,7 +446,6 @@ class BackupService: 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"]) @@ -463,6 +486,7 @@ class BackupService: 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("⏹️ Автобекапы остановлены") @@ -484,7 +508,7 @@ class BackupService: break except Exception as e: logger.error(f"Ошибка в цикле автобекапов: {e}") - await asyncio.sleep(3600) + await asyncio.sleep(3600) async def _send_backup_notification( self, From 1292046646417e5aebc3d217516af57e325736a3 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 11 Sep 2025 02:20:32 +0300 Subject: [PATCH 11/20] Update backup_service.py --- app/services/backup_service.py | 82 ++++++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 8 deletions(-) diff --git a/app/services/backup_service.py b/app/services/backup_service.py index d67b31fe..6ddfc2da 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -28,6 +28,7 @@ logger = logging.getLogger(__name__) @dataclass class BackupMetadata: + """Метаданные бекапа""" timestamp: str version: str = "1.0" database_type: str = "postgresql" @@ -41,9 +42,10 @@ class BackupMetadata: @dataclass class BackupSettings: + """Настройки системы бекапов""" auto_backup_enabled: bool = True backup_interval_hours: int = 24 - backup_time: str = "03:00" + backup_time: str = "03:00" # время бекапа в формате HH:MM max_backups_keep: int = 7 compression_enabled: bool = True include_logs: bool = False @@ -59,6 +61,7 @@ class BackupService: self._auto_backup_task = None self._settings = self._load_settings() + # Определяем все модели для бекапа self.backup_models = [ User, Subscription, Transaction, PromoCode, PromoCodeUse, ReferralEarning, ServiceRule, SystemSetting, @@ -67,6 +70,7 @@ class BackupService: YooKassaPayment, CryptoBotPayment ] + # Опционально включаем логи if self._settings.include_logs: self.backup_models.append(MonitoringLog) @@ -88,12 +92,20 @@ class BackupService: compress: bool = True, include_logs: bool = None ) -> Tuple[bool, str, Optional[str]]: + """ + Создает полный бекап базы данных + + Returns: + (success, message, backup_file_path) + """ 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) @@ -105,22 +117,27 @@ class BackupService: 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__'): + # Для enum и других сложных типов record_dict[column.name] = str(value) else: record_dict[column.name] = value @@ -139,6 +156,7 @@ class BackupService: finally: await db.close() + # Создаем метаданные metadata = BackupMetadata( timestamp=datetime.utcnow().isoformat(), database_type="postgresql" if settings.is_postgresql() else "sqlite", @@ -147,9 +165,10 @@ class BackupService: total_records=total_records, compressed=compress, created_by=created_by, - file_size_bytes=0 + file_size_bytes=0 # будет обновлено после сохранения ) + # Формируем имя файла timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") filename = f"backup_{timestamp}.json" if compress: @@ -157,32 +176,37 @@ class BackupService: backup_path = self.backup_dir / filename + # Создаем итоговую структуру бекапа backup_structure = { "metadata": asdict(metadata), "data": backup_data } + # Сохраняем файл if compress: - backup_json = json.dumps(backup_structure, ensure_ascii=False, indent=2) + backup_json_str = json.dumps(backup_structure, ensure_ascii=False, indent=2) async with aiofiles.open(backup_path, 'wb') as f: - compressed_data = gzip.compress(backup_json.encode('utf-8')) + 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.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 = json.dumps(backup_structure, ensure_ascii=False, indent=2) + backup_json_str = json.dumps(backup_structure, ensure_ascii=False, indent=2) async with aiofiles.open(backup_path, 'wb') as f: - compressed_data = gzip.compress(backup_json.encode('utf-8')) + 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.dumps(backup_structure, ensure_ascii=False, indent=2)) + # Очищаем старые бекапы await self._cleanup_old_backups() size_mb = file_size / 1024 / 1024 @@ -194,6 +218,7 @@ class BackupService: logger.info(message) + # Отправляем уведомление админам if self.bot: await self._send_backup_notification( "success", message, str(backup_path) @@ -215,6 +240,13 @@ class BackupService: backup_file_path: str, clear_existing: bool = False ) -> Tuple[bool, str]: + """ + Восстанавливает данные из бекапа + + Args: + backup_file_path: путь к файлу бекапа + clear_existing: очистить существующие данные перед восстановлением + """ try: logger.info(f"🔄 Начинаем восстановление из {backup_file_path}") @@ -222,6 +254,7 @@ class BackupService: 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() @@ -246,14 +279,18 @@ class BackupService: async for db in get_db(): try: + # Начинаем транзакцию if clear_existing: logger.warning("🗑️ Очищаем существующие данные...") + # Очищаем таблицы в правильном порядке (с учетом foreign keys) 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: @@ -268,38 +305,48 @@ class BackupService: 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 + # Обрабатываем datetime column_type_str = str(column.type).upper() if ('DATETIME' in column_type_str or 'TIMESTAMP' in column_type_str) and isinstance(value, str): try: + # Пробуем разные форматы datetime if 'T' in value: + # ISO формат 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() + processed_data[key] = datetime.utcnow() # Fallback на текущее время + # Обрабатываем Boolean 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') + # Обрабатываем Integer 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 + # Обрабатываем Float 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 + # Обрабатываем JSON elif 'JSON' in column_type_str and isinstance(value, str): try: import json @@ -309,6 +356,7 @@ class BackupService: else: processed_data[key] = value + # Создаем объект модели instance = model(**processed_data) db.add(instance) restored_records += 1 @@ -320,6 +368,7 @@ class BackupService: restored_tables += 1 logger.info(f"✅ Таблица {table_name} восстановлена") + # Коммитим все изменения await db.commit() break @@ -353,6 +402,8 @@ class BackupService: return False, error_msg async def _clear_database_tables(self, db: AsyncSession): + """Очищает все таблицы в правильном порядке""" + # Порядок очистки с учетом foreign key constraints tables_order = [ "subscription_servers", "sent_notifications", "broadcast_history", "subscription_conversions", "referral_earnings", "promocode_uses", @@ -369,11 +420,13 @@ class BackupService: 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.load(f) @@ -401,6 +454,7 @@ class BackupService: except Exception as e: logger.error(f"Ошибка чтения метаданных {backup_file}: {e}") + # Добавляем базовую информацию file_stats = backup_file.stat() backups.append({ "filename": backup_file.name, @@ -422,6 +476,7 @@ class BackupService: return backups async def delete_backup(self, backup_filename: str) -> Tuple[bool, str]: + """Удаляет файл бекапа""" try: backup_path = self.backup_dir / backup_filename @@ -440,12 +495,15 @@ class BackupService: 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"]) @@ -457,14 +515,18 @@ class BackupService: 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: @@ -492,6 +554,7 @@ class BackupService: logger.info("⏹️ Автобекапы остановлены") async def _auto_backup_loop(self): + """Цикл автоматических бекапов""" while True: try: await asyncio.sleep(self._settings.backup_interval_hours * 3600) @@ -508,7 +571,7 @@ class BackupService: break except Exception as e: logger.error(f"Ошибка в цикле автобекапов: {e}") - await asyncio.sleep(3600) + await asyncio.sleep(3600) # Ждем час при ошибке async def _send_backup_notification( self, @@ -516,6 +579,7 @@ class BackupService: message: str, file_path: str = None ): + """Отправляет уведомление о бекапе админам""" try: if not settings.is_admin_notifications_enabled(): return @@ -535,6 +599,7 @@ class BackupService: notification_text += f"\n\n⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}" + # Отправляем через AdminNotificationService если доступен try: from app.services.admin_notification_service import AdminNotificationService admin_service = AdminNotificationService(self.bot) @@ -546,4 +611,5 @@ class BackupService: logger.error(f"Ошибка отправки уведомления о бекапе: {e}") +# Глобальный экземпляр сервиса backup_service = BackupService() From 7f431e1485fc3463d9561bfc4c593f53d767b8ac Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 11 Sep 2025 02:32:44 +0300 Subject: [PATCH 12/20] Update backup_service.py From 26efc70042e48f34f41d4895fd5621788bd543e4 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 11 Sep 2025 03:02:48 +0300 Subject: [PATCH 13/20] Update backup.py --- app/handlers/admin/backup.py | 61 ++---------------------------------- 1 file changed, 2 insertions(+), 59 deletions(-) diff --git a/app/handlers/admin/backup.py b/app/handlers/admin/backup.py index 4c6eb3cf..ce5b0cea 100644 --- a/app/handlers/admin/backup.py +++ b/app/handlers/admin/backup.py @@ -22,7 +22,6 @@ class BackupStates(StatesGroup): def get_backup_main_keyboard(language: str = "ru"): - """Главная клавиатура настроек бекапов""" return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text="🚀 Создать бекап", callback_data="backup_create"), @@ -30,11 +29,7 @@ def get_backup_main_keyboard(language: str = "ru"): ], [ InlineKeyboardButton(text="📋 Список бекапов", callback_data="backup_list"), - InlineKeyboardButton(text="📊 Журнал операций", callback_data="backup_logs") - ], - [ - InlineKeyboardButton(text="⚙️ Настройки", callback_data="backup_settings"), - InlineKeyboardButton(text="🔄 Автобекапы", callback_data="backup_auto_toggle") + InlineKeyboardButton(text="⚙️ Настройки", callback_data="backup_settings") ], [ InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel") @@ -43,16 +38,13 @@ def get_backup_main_keyboard(language: str = "ru"): 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')) @@ -70,7 +62,6 @@ def get_backup_list_keyboard(backups: list, page: int = 1, per_page: int = 5): 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 = [] @@ -85,9 +76,7 @@ def get_backup_list_keyboard(backups: list, page: int = 1, per_page: int = 5): keyboard.append(nav_row) - # Управляющие кнопки keyboard.extend([ - [InlineKeyboardButton(text="🔄 Обновить", callback_data="backup_list")], [InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")] ]) @@ -95,11 +84,9 @@ def get_backup_list_keyboard(backups: list, page: int = 1, per_page: int = 5): 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_info_{backup_filename}") + InlineKeyboardButton(text="📥 Восстановить", callback_data=f"backup_restore_file_{backup_filename}") ], [ InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"backup_delete_{backup_filename}") @@ -111,7 +98,6 @@ def get_backup_manage_keyboard(backup_filename: str): 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 "❌ Отключены" @@ -135,16 +121,6 @@ def get_backup_settings_keyboard(settings_obj): callback_data="backup_toggle_logs" ) ], - [ - InlineKeyboardButton( - text=f"⏰ Интервал: {settings_obj.backup_interval_hours}ч", - callback_data="backup_set_interval" - ), - InlineKeyboardButton( - text=f"📦 Хранить: {settings_obj.max_backups_keep}шт", - callback_data="backup_set_retention" - ) - ], [ InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel") ] @@ -158,7 +134,6 @@ async def show_backup_panel( db_user: User, db: AsyncSession ): - """Показывает главную панель бекапов""" settings_obj = await backup_service.get_backup_settings() status_auto = "✅ Включены" if settings_obj.auto_backup_enabled else "❌ Отключены" @@ -177,7 +152,6 @@ async def show_backup_panel( • Создание полного бекапа всех данных • Восстановление из файла бекапа • Управление автоматическими бекапами -• Просмотр истории операций """ await callback.message.edit_text( @@ -195,10 +169,8 @@ async def create_backup_handler( db_user: User, db: AsyncSession ): - """Запускает создание бекапа""" await callback.answer("🔄 Создание бекапа запущено...") - # Показываем сообщение о начале процесса progress_msg = await callback.message.edit_text( "🔄 Создание бекапа...\n\n" "⏳ Экспортируем данные из базы...\n" @@ -233,8 +205,6 @@ async def show_backup_list( db_user: User, db: AsyncSession ): - """Показывает список бекапов""" - # Извлекаем номер страницы из callback_data page = 1 if callback.data.startswith("backup_list_page_"): try: @@ -270,7 +240,6 @@ async def manage_backup_file( db_user: User, db: AsyncSession ): - """Управление конкретным файлом бекапа""" filename = callback.data.replace("backup_manage_", "") backups = await backup_service.get_backup_list() @@ -285,7 +254,6 @@ async def manage_backup_file( await callback.answer("❌ Файл бекапа не найден", show_alert=True) return - # Форматируем информацию о бекапе try: if backup_info.get("timestamp"): dt = datetime.fromisoformat(backup_info["timestamp"].replace('Z', '+00:00')) @@ -324,7 +292,6 @@ async def delete_backup_confirm( db_user: User, db: AsyncSession ): - """Подтверждение удаления бекапа""" filename = callback.data.replace("backup_delete_", "") text = f"🗑️ Удаление бекапа\n\n" @@ -354,7 +321,6 @@ async def delete_backup_execute( db_user: User, db: AsyncSession ): - """Выполняет удаление бекапа""" filename = callback.data.replace("backup_delete_confirm_", "") success, message = await backup_service.delete_backup(filename) @@ -385,7 +351,6 @@ async def restore_backup_start( db: AsyncSession, state: FSMContext ): - """Начинает процесс восстановления из бекапа""" if callback.data.startswith("backup_restore_file_"): # Восстановление из конкретного файла filename = callback.data.replace("backup_restore_file_", "") @@ -408,7 +373,6 @@ async def restore_backup_start( ] ]) else: - # Восстановление из загруженного файла text = """📥 Восстановление из бекапа 📎 Отправьте файл бекапа (.json или .json.gz) @@ -442,7 +406,6 @@ async def restore_backup_execute( db_user: User, db: AsyncSession ): - """Выполняет восстановление бекапа""" if callback.data.startswith("backup_restore_execute_"): filename = callback.data.replace("backup_restore_execute_", "") clear_existing = False @@ -465,10 +428,8 @@ async def restore_backup_execute( parse_mode="HTML" ) - # Формируем путь к файлу backup_path = backup_service.backup_dir / filename - # Выполняем восстановление success, message = await backup_service.restore_backup( str(backup_path), clear_existing=clear_existing @@ -496,7 +457,6 @@ async def handle_backup_file_upload( db: AsyncSession, state: FSMContext ): - """Обрабатывает загрузку файла бекапа""" if not message.document: await message.answer( "❌ Пожалуйста, отправьте файл бекапа (.json или .json.gz)", @@ -508,7 +468,6 @@ async def handle_backup_file_upload( document = message.document - # Проверяем расширение файла if not (document.file_name.endswith('.json') or document.file_name.endswith('.json.gz')): await message.answer( "❌ Неподдерживаемый формат файла. Загрузите .json или .json.gz файл", @@ -518,7 +477,6 @@ async def handle_backup_file_upload( ) return - # Проверяем размер файла (максимум 50MB) if document.file_size > 50 * 1024 * 1024: await message.answer( "❌ Файл слишком большой (максимум 50MB)", @@ -529,15 +487,12 @@ async def handle_backup_file_upload( 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} @@ -579,7 +534,6 @@ async def show_backup_settings( db_user: User, db: AsyncSession ): - """Показывает настройки бекапов""" settings_obj = await backup_service.get_backup_settings() text = f"""⚙️ Настройки системы бекапов @@ -612,7 +566,6 @@ async def toggle_backup_setting( db_user: User, db: AsyncSession ): - """Переключает настройки бекапов""" settings_obj = await backup_service.get_backup_settings() if callback.data == "backup_toggle_auto": @@ -633,38 +586,31 @@ async def toggle_backup_setting( 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_") @@ -675,7 +621,6 @@ def register_handlers(dp: Dispatcher): F.data.startswith("backup_delete_confirm_") ) - # Восстановление dp.callback_query.register( restore_backup_start, F.data.in_(["backup_restore"]) | F.data.startswith("backup_restore_file_") @@ -686,7 +631,6 @@ def register_handlers(dp: Dispatcher): F.data.startswith("backup_restore_execute_") | F.data.startswith("backup_restore_clear_") ) - # Настройки dp.callback_query.register( show_backup_settings, F.data == "backup_settings" @@ -697,7 +641,6 @@ def register_handlers(dp: Dispatcher): F.data.in_(["backup_toggle_auto", "backup_toggle_compression", "backup_toggle_logs"]) ) - # Загрузка файла dp.message.register( handle_backup_file_upload, BackupStates.waiting_backup_file From ac962c43e82074378fc25d5daaf3692f712974bd Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 11 Sep 2025 03:03:25 +0300 Subject: [PATCH 14/20] Update backup_service.py --- app/services/backup_service.py | 135 ++++++++++++--------------------- 1 file changed, 47 insertions(+), 88 deletions(-) diff --git a/app/services/backup_service.py b/app/services/backup_service.py index 6ddfc2da..12c48166 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -1,5 +1,5 @@ import asyncio -import json +import json as json_lib import logging import gzip import os @@ -28,7 +28,6 @@ logger = logging.getLogger(__name__) @dataclass class BackupMetadata: - """Метаданные бекапа""" timestamp: str version: str = "1.0" database_type: str = "postgresql" @@ -42,10 +41,9 @@ class BackupMetadata: @dataclass class BackupSettings: - """Настройки системы бекапов""" auto_backup_enabled: bool = True backup_interval_hours: int = 24 - backup_time: str = "03:00" # время бекапа в формате HH:MM + backup_time: str = "03:00" max_backups_keep: int = 7 compression_enabled: bool = True include_logs: bool = False @@ -61,7 +59,6 @@ class BackupService: self._auto_backup_task = None self._settings = self._load_settings() - # Определяем все модели для бекапа self.backup_models = [ User, Subscription, Transaction, PromoCode, PromoCodeUse, ReferralEarning, ServiceRule, SystemSetting, @@ -70,12 +67,10 @@ class BackupService: 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")), @@ -92,20 +87,12 @@ class BackupService: compress: bool = True, include_logs: bool = None ) -> Tuple[bool, str, Optional[str]]: - """ - Создает полный бекап базы данных - - Returns: - (success, message, backup_file_path) - """ 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) @@ -117,27 +104,22 @@ class BackupService: 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__'): - # Для enum и других сложных типов record_dict[column.name] = str(value) else: record_dict[column.name] = value @@ -156,7 +138,6 @@ class BackupService: finally: await db.close() - # Создаем метаданные metadata = BackupMetadata( timestamp=datetime.utcnow().isoformat(), database_type="postgresql" if settings.is_postgresql() else "sqlite", @@ -165,10 +146,9 @@ class BackupService: total_records=total_records, compressed=compress, created_by=created_by, - file_size_bytes=0 # будет обновлено после сохранения + file_size_bytes=0 ) - # Формируем имя файла timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") filename = f"backup_{timestamp}.json" if compress: @@ -176,37 +156,32 @@ class BackupService: backup_path = self.backup_dir / filename - # Создаем итоговую структуру бекапа backup_structure = { "metadata": asdict(metadata), "data": backup_data } - # Сохраняем файл if compress: - backup_json_str = json.dumps(backup_structure, ensure_ascii=False, indent=2) + 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.dumps(backup_structure, ensure_ascii=False, indent=2)) + 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.dumps(backup_structure, ensure_ascii=False, indent=2) + 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.dumps(backup_structure, ensure_ascii=False, indent=2)) + await f.write(json_lib.dumps(backup_structure, ensure_ascii=False, indent=2)) - # Очищаем старые бекапы await self._cleanup_old_backups() size_mb = file_size / 1024 / 1024 @@ -218,7 +193,6 @@ class BackupService: logger.info(message) - # Отправляем уведомление админам if self.bot: await self._send_backup_notification( "success", message, str(backup_path) @@ -240,13 +214,6 @@ class BackupService: backup_file_path: str, clear_existing: bool = False ) -> Tuple[bool, str]: - """ - Восстанавливает данные из бекапа - - Args: - backup_file_path: путь к файлу бекапа - clear_existing: очистить существующие данные перед восстановлением - """ try: logger.info(f"🔄 Начинаем восстановление из {backup_file_path}") @@ -254,16 +221,15 @@ class BackupService: 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() - json_data = gzip.decompress(compressed_data).decode('utf-8') - backup_structure = json.loads(json_data) + 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: - content = await f.read() - backup_structure = json.loads(content) + file_content = await f.read() + backup_structure = json_lib.loads(file_content) metadata = backup_structure.get("metadata", {}) backup_data = backup_structure.get("data", {}) @@ -279,18 +245,14 @@ class BackupService: async for db in get_db(): try: - # Начинаем транзакцию if clear_existing: logger.warning("🗑️ Очищаем существующие данные...") - # Очищаем таблицы в правильном порядке (с учетом foreign keys) 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: @@ -305,60 +267,77 @@ class BackupService: 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 - # Обрабатываем datetime column_type_str = str(column.type).upper() if ('DATETIME' in column_type_str or 'TIMESTAMP' in column_type_str) and isinstance(value, str): try: - # Пробуем разные форматы datetime if 'T' in value: - # ISO формат 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() # Fallback на текущее время - # Обрабатываем Boolean + 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') - # Обрабатываем Integer 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 - # Обрабатываем Float 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 - # Обрабатываем JSON elif 'JSON' in column_type_str and isinstance(value, str): try: - import json - processed_data[key] = json.loads(value) + processed_data[key] = json_lib.loads(value) except (ValueError, TypeError): processed_data[key] = value else: processed_data[key] = value - # Создаем объект модели - instance = model(**processed_data) - db.add(instance) + # Проверяем существует ли запись с таким 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: @@ -368,7 +347,6 @@ class BackupService: restored_tables += 1 logger.info(f"✅ Таблица {table_name} восстановлена") - # Коммитим все изменения await db.commit() break @@ -402,8 +380,6 @@ class BackupService: return False, error_msg async def _clear_database_tables(self, db: AsyncSession): - """Очищает все таблицы в правильном порядке""" - # Порядок очистки с учетом foreign key constraints tables_order = [ "subscription_servers", "sent_notifications", "broadcast_history", "subscription_conversions", "referral_earnings", "promocode_uses", @@ -420,19 +396,17 @@ class BackupService: 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.load(f) + backup_structure = json_lib.load(f) else: with open(backup_file, 'r', encoding='utf-8') as f: - backup_structure = json.load(f) + backup_structure = json_lib.load(f) metadata = backup_structure.get("metadata", {}) file_stats = backup_file.stat() @@ -454,7 +428,6 @@ class BackupService: except Exception as e: logger.error(f"Ошибка чтения метаданных {backup_file}: {e}") - # Добавляем базовую информацию file_stats = backup_file.stat() backups.append({ "filename": backup_file.name, @@ -476,7 +449,6 @@ class BackupService: return backups async def delete_backup(self, backup_filename: str) -> Tuple[bool, str]: - """Удаляет файл бекапа""" try: backup_path = self.backup_dir / backup_filename @@ -495,15 +467,12 @@ class BackupService: 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"]) @@ -515,18 +484,14 @@ class BackupService: 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: @@ -539,7 +504,6 @@ class BackupService: 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() @@ -548,13 +512,11 @@ class BackupService: 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) @@ -571,7 +533,7 @@ class BackupService: break except Exception as e: logger.error(f"Ошибка в цикле автобекапов: {e}") - await asyncio.sleep(3600) # Ждем час при ошибке + await asyncio.sleep(3600) async def _send_backup_notification( self, @@ -579,7 +541,6 @@ class BackupService: message: str, file_path: str = None ): - """Отправляет уведомление о бекапе админам""" try: if not settings.is_admin_notifications_enabled(): return @@ -599,7 +560,6 @@ class BackupService: notification_text += f"\n\n⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}" - # Отправляем через AdminNotificationService если доступен try: from app.services.admin_notification_service import AdminNotificationService admin_service = AdminNotificationService(self.bot) @@ -611,5 +571,4 @@ class BackupService: logger.error(f"Ошибка отправки уведомления о бекапе: {e}") -# Глобальный экземпляр сервиса backup_service = BackupService() From f95367d12ec0594c538e4ab232ee19f69fbe93d5 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 11 Sep 2025 03:09:02 +0300 Subject: [PATCH 15/20] Update .env.example --- .env.example | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) 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 From 305026c37db6e9f78942183324156383ab5b865a Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 11 Sep 2025 03:14:30 +0300 Subject: [PATCH 16/20] Update Dockerfile --- Dockerfile | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 04cd39ce..065eb631 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,14 @@ FROM python:3.11-slim AS builder - RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ && rm -rf /var/lib/apt/lists/* - RUN python -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" - COPY requirements.txt . - RUN pip install --no-cache-dir --upgrade pip && \ pip install --no-cache-dir -r requirements.txt FROM python:3.11-slim - ARG VERSION="v2.2.6" ARG BUILD_DATE ARG VCS_REF @@ -33,8 +28,9 @@ WORKDIR /app COPY --chown=app:app . . -RUN mkdir -p logs data && \ - chown -R app:app /app logs data +RUN mkdir -p logs data data/backups data/referral_qr && \ + chmod -R 777 data/backups && \ + chown -R 1000:1000 logs data USER app From ceb0ac7538707ca5a341a62d3b36d5adde2b8ae6 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 11 Sep 2025 03:25:04 +0300 Subject: [PATCH 17/20] Create 2 --- app/data/2 | 1 + 1 file changed, 1 insertion(+) create mode 100644 app/data/2 diff --git a/app/data/2 b/app/data/2 new file mode 100644 index 00000000..9c558e35 --- /dev/null +++ b/app/data/2 @@ -0,0 +1 @@ +. From d5c8795e756e0b99be2e4e553c8327096b01976d Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 11 Sep 2025 03:25:20 +0300 Subject: [PATCH 18/20] Delete app/data/2 --- app/data/2 | 1 - 1 file changed, 1 deletion(-) delete mode 100644 app/data/2 diff --git a/app/data/2 b/app/data/2 deleted file mode 100644 index 9c558e35..00000000 --- a/app/data/2 +++ /dev/null @@ -1 +0,0 @@ -. From cdcdd7abdcf8e4e39f4757e2205267b6715a9318 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 11 Sep 2025 03:31:18 +0300 Subject: [PATCH 19/20] Update README.md --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From 548dd2fdc6dd95fb038ecab72c42a86278dc943f Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 11 Sep 2025 03:31:44 +0300 Subject: [PATCH 20/20] Update Dockerfile --- Dockerfile | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 065eb631..04cd39ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,19 @@ FROM python:3.11-slim AS builder + RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ && rm -rf /var/lib/apt/lists/* + RUN python -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" + COPY requirements.txt . + RUN pip install --no-cache-dir --upgrade pip && \ pip install --no-cache-dir -r requirements.txt FROM python:3.11-slim + ARG VERSION="v2.2.6" ARG BUILD_DATE ARG VCS_REF @@ -28,9 +33,8 @@ WORKDIR /app COPY --chown=app:app . . -RUN mkdir -p logs data data/backups data/referral_qr && \ - chmod -R 777 data/backups && \ - chown -R 1000:1000 logs data +RUN mkdir -p logs data && \ + chown -R app:app /app logs data USER app