mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-22 04:12:09 +00:00
Merge pull request #567 from Fr1ngg/bedolaga/update-backup-and-restore-mechanism
Expand backup coverage to include configuration snapshots
This commit is contained in:
@@ -12,6 +12,7 @@ import aiofiles
|
||||
from aiogram.types import FSInputFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, text, inspect
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
@@ -23,7 +24,8 @@ from app.database.models import (
|
||||
ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment,
|
||||
CryptoBotPayment, WelcomeText, Base, PromoGroup, AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration, SupportAuditLog, Ticket, TicketMessage,
|
||||
MulenPayPayment, Pal24Payment
|
||||
MulenPayPayment, Pal24Payment, DiscountOffer, WebApiToken,
|
||||
server_squad_promo_groups
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -32,7 +34,7 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass
|
||||
class BackupMetadata:
|
||||
timestamp: str
|
||||
version: str = "1.1"
|
||||
version: str = "1.2"
|
||||
database_type: str = "postgresql"
|
||||
backup_type: str = "full"
|
||||
tables_count: int = 0
|
||||
@@ -83,17 +85,23 @@ class BackupService:
|
||||
PromoCodeUse,
|
||||
ReferralEarning,
|
||||
SentNotification,
|
||||
DiscountOffer,
|
||||
BroadcastHistory,
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
Ticket,
|
||||
TicketMessage,
|
||||
SupportAuditLog,
|
||||
WebApiToken,
|
||||
]
|
||||
|
||||
if self._settings.include_logs:
|
||||
self.backup_models_ordered.append(MonitoringLog)
|
||||
|
||||
self.association_tables = {
|
||||
"server_squad_promo_groups": server_squad_promo_groups,
|
||||
}
|
||||
|
||||
def _load_settings(self) -> BackupSettings:
|
||||
return BackupSettings(
|
||||
auto_backup_enabled=os.getenv("BACKUP_AUTO_ENABLED", "true").lower() == "true",
|
||||
@@ -171,6 +179,7 @@ class BackupService:
|
||||
models_to_backup.append(MonitoringLog)
|
||||
|
||||
backup_data = {}
|
||||
association_data = {}
|
||||
total_records = 0
|
||||
|
||||
async for db in get_db():
|
||||
@@ -214,7 +223,11 @@ class BackupService:
|
||||
total_records += len(table_data)
|
||||
|
||||
logger.info(f"✅ Экспортировано {len(table_data)} записей из {table_name}")
|
||||
|
||||
|
||||
association_data = await self._export_association_tables(db)
|
||||
for records in association_data.values():
|
||||
total_records += len(records)
|
||||
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте данных: {e}")
|
||||
@@ -226,7 +239,7 @@ class BackupService:
|
||||
timestamp=datetime.utcnow().isoformat(),
|
||||
database_type="postgresql" if settings.is_postgresql() else "sqlite",
|
||||
backup_type="full",
|
||||
tables_count=len(models_to_backup),
|
||||
tables_count=len(models_to_backup) + len(association_data),
|
||||
total_records=total_records,
|
||||
compressed=compress,
|
||||
created_by=created_by,
|
||||
@@ -239,10 +252,17 @@ class BackupService:
|
||||
filename += ".gz"
|
||||
|
||||
backup_path = self.backup_dir / filename
|
||||
|
||||
|
||||
file_snapshots = await self._collect_file_snapshots()
|
||||
|
||||
backup_structure = {
|
||||
"metadata": asdict(metadata),
|
||||
"data": backup_data
|
||||
"data": backup_data,
|
||||
"associations": association_data,
|
||||
"files": file_snapshots,
|
||||
"config": {
|
||||
"backup_settings": asdict(self._settings)
|
||||
}
|
||||
}
|
||||
|
||||
if compress:
|
||||
@@ -271,7 +291,7 @@ class BackupService:
|
||||
size_mb = file_size / 1024 / 1024
|
||||
message = (f"✅ Бекап успешно создан!\n"
|
||||
f"📁 Файл: {filename}\n"
|
||||
f"📊 Таблиц: {len(models_to_backup)}\n"
|
||||
f"📊 Таблиц: {metadata.tables_count}\n"
|
||||
f"📈 Записей: {total_records:,}\n"
|
||||
f"💾 Размер: {size_mb:.2f} MB")
|
||||
|
||||
@@ -319,6 +339,8 @@ class BackupService:
|
||||
|
||||
metadata = backup_structure.get("metadata", {})
|
||||
backup_data = backup_structure.get("data", {})
|
||||
association_data = backup_structure.get("associations", {})
|
||||
file_snapshots = backup_structure.get("files", {})
|
||||
|
||||
if not backup_data:
|
||||
return False, "❌ Файл бекапа не содержит данных"
|
||||
@@ -377,6 +399,14 @@ class BackupService:
|
||||
|
||||
await self._update_user_referrals(db, backup_data)
|
||||
|
||||
assoc_tables, assoc_records = await self._restore_association_tables(
|
||||
db,
|
||||
association_data,
|
||||
clear_existing
|
||||
)
|
||||
restored_tables += assoc_tables
|
||||
restored_records += assoc_records
|
||||
|
||||
await db.commit()
|
||||
|
||||
break
|
||||
@@ -397,6 +427,11 @@ class BackupService:
|
||||
|
||||
if self.bot:
|
||||
await self._send_backup_notification("restore_success", message)
|
||||
|
||||
if file_snapshots:
|
||||
restored_files = await self._restore_file_snapshots(file_snapshots)
|
||||
if restored_files:
|
||||
logger.info(f"📁 Восстановлено файлов конфигурации: {restored_files}")
|
||||
|
||||
return True, message
|
||||
|
||||
@@ -543,6 +578,97 @@ class BackupService:
|
||||
return col.name
|
||||
return None
|
||||
|
||||
async def _export_association_tables(self, db: AsyncSession) -> Dict[str, List[Dict[str, Any]]]:
|
||||
association_data: Dict[str, List[Dict[str, Any]]] = {}
|
||||
|
||||
for table_name, table_obj in self.association_tables.items():
|
||||
try:
|
||||
logger.info(f"📊 Экспортируем таблицу связей: {table_name}")
|
||||
result = await db.execute(select(table_obj))
|
||||
rows = result.mappings().all()
|
||||
association_data[table_name] = [dict(row) for row in rows]
|
||||
logger.info(
|
||||
f"✅ Экспортировано {len(rows)} связей из {table_name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка экспорта таблицы связей {table_name}: {e}")
|
||||
|
||||
return association_data
|
||||
|
||||
async def _restore_association_tables(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
association_data: Dict[str, List[Dict[str, Any]]],
|
||||
clear_existing: bool
|
||||
) -> Tuple[int, int]:
|
||||
if not association_data:
|
||||
return 0, 0
|
||||
|
||||
restored_tables = 0
|
||||
restored_records = 0
|
||||
|
||||
if "server_squad_promo_groups" in association_data:
|
||||
restored = await self._restore_server_squad_promo_groups(
|
||||
db,
|
||||
association_data["server_squad_promo_groups"],
|
||||
clear_existing
|
||||
)
|
||||
restored_tables += 1
|
||||
restored_records += restored
|
||||
|
||||
return restored_tables, restored_records
|
||||
|
||||
async def _restore_server_squad_promo_groups(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
records: List[Dict[str, Any]],
|
||||
clear_existing: bool
|
||||
) -> int:
|
||||
if not records:
|
||||
return 0
|
||||
|
||||
if clear_existing:
|
||||
await db.execute(server_squad_promo_groups.delete())
|
||||
|
||||
restored = 0
|
||||
|
||||
for record in records:
|
||||
server_id = record.get("server_squad_id")
|
||||
promo_id = record.get("promo_group_id")
|
||||
|
||||
if server_id is None or promo_id is None:
|
||||
logger.warning(
|
||||
"Пропущена некорректная запись server_squad_promo_groups: %s",
|
||||
record
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
server_squad_promo_groups.insert().values(
|
||||
server_squad_id=server_id,
|
||||
promo_group_id=promo_id
|
||||
)
|
||||
)
|
||||
restored += 1
|
||||
except IntegrityError:
|
||||
logger.debug(
|
||||
"Запись server_squad_promo_groups (%s, %s) уже существует",
|
||||
server_id,
|
||||
promo_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка при восстановлении связи server_squad_promo_groups (%s, %s): %s",
|
||||
server_id,
|
||||
promo_id,
|
||||
e
|
||||
)
|
||||
await db.rollback()
|
||||
raise e
|
||||
|
||||
return restored
|
||||
|
||||
async def _restore_table_records(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -590,17 +716,18 @@ class BackupService:
|
||||
|
||||
async def _clear_database_tables(self, db: AsyncSession):
|
||||
tables_order = [
|
||||
"server_squad_promo_groups",
|
||||
"ticket_messages", "tickets", "support_audit_logs",
|
||||
"advertising_campaign_registrations", "advertising_campaigns",
|
||||
"subscription_servers", "sent_notifications",
|
||||
"user_messages", "broadcast_history", "subscription_conversions",
|
||||
"discount_offers", "user_messages", "broadcast_history", "subscription_conversions",
|
||||
"referral_earnings", "promocode_uses",
|
||||
"yookassa_payments", "cryptobot_payments",
|
||||
"mulenpay_payments", "pal24_payments",
|
||||
"transactions", "welcome_texts", "subscriptions",
|
||||
"promocodes", "users", "promo_groups",
|
||||
"server_squads", "squads", "service_rules",
|
||||
"system_settings", "monitoring_logs"
|
||||
"system_settings", "web_api_tokens", "monitoring_logs"
|
||||
]
|
||||
|
||||
for table_name in tables_order:
|
||||
@@ -610,6 +737,57 @@ class BackupService:
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось очистить таблицу {table_name}: {e}")
|
||||
|
||||
async def _collect_file_snapshots(self) -> Dict[str, Dict[str, Any]]:
|
||||
snapshots: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
app_config_path = settings.get_app_config_path()
|
||||
if app_config_path:
|
||||
path_obj = Path(app_config_path)
|
||||
if path_obj.exists() and path_obj.is_file():
|
||||
try:
|
||||
async with aiofiles.open(path_obj, 'r', encoding='utf-8') as f:
|
||||
content = await f.read()
|
||||
snapshots["app_config"] = {
|
||||
"path": str(path_obj),
|
||||
"content": content,
|
||||
"modified_at": datetime.fromtimestamp(
|
||||
path_obj.stat().st_mtime
|
||||
).isoformat()
|
||||
}
|
||||
logger.info(
|
||||
"📁 Добавлен в бекап файл конфигурации: %s",
|
||||
path_obj
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка чтения файла конфигурации %s: %s",
|
||||
path_obj,
|
||||
e
|
||||
)
|
||||
|
||||
return snapshots
|
||||
|
||||
async def _restore_file_snapshots(self, file_snapshots: Dict[str, Dict[str, Any]]) -> int:
|
||||
restored_files = 0
|
||||
|
||||
if not file_snapshots:
|
||||
return restored_files
|
||||
|
||||
app_config_snapshot = file_snapshots.get("app_config")
|
||||
if app_config_snapshot:
|
||||
target_path = Path(settings.get_app_config_path())
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
async with aiofiles.open(target_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(app_config_snapshot.get("content", ""))
|
||||
restored_files += 1
|
||||
logger.info("📁 Файл app-config восстановлен по пути %s", target_path)
|
||||
except Exception as e:
|
||||
logger.error("Ошибка восстановления файла %s: %s", target_path, e)
|
||||
|
||||
return restored_files
|
||||
|
||||
async def get_backup_list(self) -> List[Dict[str, Any]]:
|
||||
backups = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user