first commit

This commit is contained in:
brusnitsyn
2026-03-08 20:21:15 +09:00
commit c201d36ae6
24 changed files with 3770 additions and 0 deletions

421
app/api/routes.py Normal file
View File

@@ -0,0 +1,421 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, BackgroundTasks, Query
from app.models.replication import ReplicationMetadata
from app.repository.replication_metadata_repo import replication_metadata_repo
from app.services.scheduler import scheduler
from app.services.migrator import migrator
from app.services.replication_state import replication_state
from app.core.logging import migration_logger
from app.core.config import settings
from app.utils.email_sender import email_sender
from app.taskiq.broker import refresh_schedules
router = APIRouter(prefix="/api/v1")
@router.post("/migrate/start")
async def start_migration(background_tasks: BackgroundTasks, full_reload: bool = False):
"""Запуск миграции"""
if migrator.is_running:
raise HTTPException(status_code=400, detail="Миграция уже выполняется")
background_tasks.add_task(run_migration_task, full_reload)
return {"message": "Миграция запущена", "full_reload": full_reload}
@router.post("/migrate/stop")
async def stop_migration():
migrator.stop_migration()
return {"message": "Миграция останавливается"}
@router.get("/migrate/status")
async def get_status():
return migrator.get_status()
@router.get("/replication/last")
async def get_last_replication():
"""Получить информацию о последней репликации (максимальное время по всем таблицам)"""
return replication_state.get_last_replication_info()
@router.get("/replication/tables")
async def get_tables_status():
"""Получить статус всех таблиц (из replication_metadata)"""
stats = replication_state.get_all_stats()
# Форматируем для API
result = []
for table in stats['tables']:
result.append({
"table": table['name'],
"last_id": table['last_id'],
"rows_count": table['rows'],
"last_sync": table['last_sync'].isoformat() if table['last_sync'] else None,
"active": table['active']
})
return {
"total_rows": stats['total_rows'],
"tables_count": stats['tables_count'],
"active_tables": stats['active_tables'],
"tables": result
}
@router.get("/replication/tables/{table_name}")
async def get_table_status(table_name: str):
"""Получить статус конкретной таблицы"""
from app.repository.replication_metadata_repo import replication_metadata_repo
metadata = replication_metadata_repo.get_table_metadata(table_name)
if not metadata:
raise HTTPException(status_code=404, detail=f"Таблица {table_name} не найдена")
return {
"table": metadata.table_name,
"last_id": metadata.last_id,
"last_sync_time": metadata.last_sync_time.isoformat() if metadata.last_sync_time else None,
"total_rows": metadata.total_rows,
"is_active": metadata.is_active,
"created_at": metadata.created_at.isoformat() if metadata.created_at else None,
"updated_at": metadata.updated_at.isoformat() if metadata.updated_at else None,
"last_error": metadata.last_error
}
@router.post("/replication/tables/{table_name}/reset")
async def reset_table(table_name: str):
"""Сбросить состояние таблицы (обнулить last_id и last_sync_time)"""
from app.repository.replication_metadata_repo import replication_metadata_repo
session = replication_metadata_repo.get_session()
try:
metadata = session.query(ReplicationMetadata).filter_by(table_name=table_name).first()
if metadata:
metadata.last_id = 0
metadata.last_sync_time = datetime(1900, 1, 1)
metadata.total_rows = 0
metadata.last_error = None
metadata.updated_at = datetime.now()
session.commit()
migration_logger.info(f"Сброшено состояние таблицы {table_name}")
return {"message": f"Состояние таблицы {table_name} сброшено"}
else:
raise HTTPException(status_code=404, detail=f"Таблица {table_name} не найдена")
finally:
session.close()
@router.get("/replication/logs")
async def get_replication_logs(
table_name: Optional[str] = None,
limit: int = Query(100, ge=1, le=1000),
status: Optional[str] = None
):
"""Получить логи репликации"""
from app.repository.replication_metadata_repo import replication_metadata_repo
from app.models.replication import ReplicationLog
session = replication_metadata_repo.get_session()
try:
query = session.query(ReplicationLog).order_by(ReplicationLog.created_at.desc())
if table_name:
query = query.filter(ReplicationLog.table_name == table_name)
if status:
query = query.filter(ReplicationLog.status == status.upper())
logs = query.limit(limit).all()
return [
{
"id": log.id,
"table_name": log.table_name,
"operation": log.operation,
"records_count": log.records_count,
"status": log.status,
"error_message": log.error_message,
"created_at": log.created_at.isoformat()
}
for log in logs
]
finally:
session.close()
@router.post("/test-email")
async def test_email():
"""Тест отправки email"""
success = email_sender.send_email(
subject="Тестовое письмо",
body=f"Это тестовое письмо от Migration Service\n\nВремя: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
)
if success:
return {"message": "Тестовое письмо отправлено"}
else:
raise HTTPException(status_code=500, detail="Ошибка отправки письма")
def run_migration_task(full_reload: bool):
try:
migrator.run_migration(full_reload=full_reload)
except Exception as e:
migration_logger.error("Ошибка в фоновой задаче", e)
# ==================== РАСПИСАНИЯ ====================
@router.get("/schedules")
async def get_schedules():
"""Получить все расписания миграций"""
schedules = replication_metadata_repo.get_all_schedules()
return [s.to_dict() for s in schedules]
@router.get("/schedules/next-runs")
async def get_next_runs(limit: int = 10):
"""Получить следующие запуски"""
from datetime import timedelta
now = datetime.now()
runs = []
# Получаем все расписания
schedules = replication_metadata_repo.get_all_schedules()
for minute_offset in range(60 * 24 * 7): # Проверяем на неделю вперед
check_time = now + timedelta(minutes=minute_offset)
check_time_obj = check_time.time()
check_weekday = check_time.weekday()
for schedule in schedules:
if not schedule.enabled:
continue
# Проверяем совпадение времени и дня
time_diff = abs(
(schedule.schedule_time.hour * 60 + schedule.schedule_time.minute) -
(check_time_obj.hour * 60 + check_time_obj.minute)
)
if time_diff <= 1 and check_weekday in schedule.days_list:
# Получаем статистику таблицы
metadata = replication_metadata_repo.get_table_metadata(schedule.table_name)
runs.append({
'table': schedule.table_name,
'time': check_time.strftime('%Y-%m-%d %H:%M'),
'day': check_time.strftime('%A'),
'days_schedule': schedule.days_display,
'full_reload': schedule.full_reload,
'rows_count': metadata.total_rows if metadata else 0,
'last_sync': metadata.last_sync_time.isoformat() if metadata and metadata.last_sync_time else None
})
if len(runs) >= limit:
break
if len(runs) >= limit:
break
return runs[:limit]
@router.post("/schedules/run-now")
async def run_scheduled_now(background_tasks: BackgroundTasks):
"""Принудительно запустить все запланированные на текущее время миграции"""
due = scheduler.get_due_tables()
if not due:
return {'message': 'Нет таблиц для миграции в текущее время и день'}
for schedule in due:
background_tasks.add_task(
run_scheduled_migration,
schedule.table_name,
schedule.full_reload
)
return {
'message': f'Запущено {len(due)} миграций',
'tables': [
{
'name': s.table_name,
'time': s.schedule_time.strftime("%H:%M"),
'days': s.days_display,
'full_reload': s.full_reload
}
for s in due
]
}
@router.post("/schedules/{table_name}")
async def set_schedule(
table_name: str,
schedule_time: str = Query("00:00", description="Время в формате HH:MM"),
days: Optional[str] = Query(None, description="Дни недели через запятую: пн,вт,ср,чт,пт,сб,вс"),
full_reload: bool = Query(False, description="Полная перезагрузка"),
enabled: bool = Query(True, description="Включено"),
name: Optional[str] = Query(None, description="Название расписания"),
description: Optional[str] = Query(None, description="Описание")
):
"""Добавить новое расписание для таблицы"""
try:
if table_name not in settings.TABLES_TO_COPY:
raise HTTPException(status_code=404, detail=f"Таблица {table_name} не найдена")
days_list = None
if days:
days_list = [d.strip() for d in days.split(',')]
from app.repository.replication_metadata_repo import replication_metadata_repo
schedule = replication_metadata_repo.add_schedule(
table_name=table_name,
schedule_time=schedule_time,
days=days_list,
full_reload=full_reload,
enabled=enabled,
name=name,
description=description
)
if schedule:
await refresh_schedules()
return {
"message": f"Расписание добавлено для {table_name} в {schedule_time}",
"schedule": schedule
}
else:
raise HTTPException(status_code=500, detail="Ошибка добавления расписания")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/schedules/{table_name}")
async def get_table_schedule(table_name: str):
"""Получить расписание для конкретной таблицы"""
schedule = replication_metadata_repo.get_schedule(table_name)
if not schedule:
raise HTTPException(status_code=404, detail="Расписание не найдено")
# Получаем статистику таблицы
metadata = replication_metadata_repo.get_table_metadata(table_name)
result = schedule.to_dict()
if metadata:
result['table_stats'] = {
'rows_count': metadata.total_rows,
'last_sync': metadata.last_sync_time.isoformat() if metadata.last_sync_time else None,
'last_id': metadata.last_id
}
return result
@router.put("/schedules/{schedule_id}")
async def update_schedule(
schedule_id: int,
schedule_time: Optional[str] = Query(None, description="Время в формате HH:MM"),
days: Optional[str] = Query(None, description="Дни недели через запятую"),
full_reload: Optional[bool] = Query(None, description="Полная перезагрузка"),
enabled: Optional[bool] = Query(None, description="Включено"),
name: Optional[str] = Query(None, description="Название"),
description: Optional[str] = Query(None, description="Описание")
):
"""Обновить существующее расписание по ID"""
from app.repository.replication_metadata_repo import replication_metadata_repo
update_kwargs = {}
if schedule_time:
update_kwargs['schedule_time'] = schedule_time
if days:
update_kwargs['days'] = [d.strip() for d in days.split(',')]
if full_reload is not None:
update_kwargs['full_reload'] = full_reload
if enabled is not None:
update_kwargs['enabled'] = enabled
if name:
update_kwargs['name'] = name
if description:
update_kwargs['description'] = description
success = replication_metadata_repo.update_schedule(schedule_id, **update_kwargs)
if success:
await refresh_schedules()
return {"message": f"Расписание {schedule_id} обновлено"}
else:
raise HTTPException(status_code=404, detail=f"Расписание {schedule_id} не найдено")
@router.post("/schedules/{table_name}/disable")
async def disable_schedule(table_name: str):
"""Отключить расписание"""
success = replication_metadata_repo.disable_schedule(table_name)
if success:
await refresh_schedules()
return {'message': f'Расписание для {table_name} отключено'}
else:
raise HTTPException(status_code=404, detail=f"Расписание для {table_name} не найдено")
@router.post("/schedules/{table_name}/enable")
async def enable_schedule(table_name: str):
"""Включить расписание"""
success = replication_metadata_repo.enable_schedule(table_name)
if success:
await refresh_schedules()
return {'message': f'Расписание для {table_name} включено'}
else:
raise HTTPException(status_code=404, detail=f"Расписание для {table_name} не найдено")
# ==================== Фоновые задачи ====================
def run_migration_task(full_reload: bool):
"""Фоновая задача для миграции всех таблиц"""
try:
migrator.run_migration(full_reload=full_reload)
except Exception as e:
migration_logger.error(f"Ошибка в фоновой задаче: {e}")
migration_logger.exception(e)
def run_scheduled_migration(table_name: str, full_reload: bool):
"""Фоновая задача для запланированной миграции одной таблицы"""
try:
migration_logger.info(f"Запуск запланированной миграции для {table_name}")
migrator.run_migration(
tables=[table_name],
full_reload=full_reload,
send_email=True
)
# Обновляем время последнего запуска в расписании
replication_metadata_repo.update_schedule_last_run(table_name)
# Логируем успешный запуск
replication_metadata_repo.log_operation(
table_name=table_name,
operation='SCHEDULED',
records_count=0,
status='SUCCESS'
)
migration_logger.info(f"Запланированная миграция для {table_name} завершена")
except Exception as e:
error_msg = f"Ошибка в запланированной миграции для {table_name}: {e}"
migration_logger.error(error_msg)
migration_logger.exception(e)
# Логируем ошибку
replication_metadata_repo.log_operation(
table_name=table_name,
operation='SCHEDULED',
records_count=0,
status='ERROR',
error_message=str(e)[:500]
)