Update backup_service.py
This commit is contained in:
@@ -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⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
|
||||
|
||||
# Отправляем через 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()
|
||||
|
||||
Reference in New Issue
Block a user