diff --git a/app/bot.py b/app/bot.py index 369fccc8..dd657a2b 100644 --- a/app/bot.py +++ b/app/bot.py @@ -1,6 +1,5 @@ -import logging - import redis.asyncio as redis +import structlog from aiogram import Bot, Dispatcher, types from aiogram.fsm.storage.memory import MemoryStorage from aiogram.fsm.storage.redis import RedisStorage @@ -63,6 +62,7 @@ from app.handlers.stars_payments import register_stars_handlers from app.middlewares.auth import AuthMiddleware from app.middlewares.blacklist import BlacklistMiddleware from app.middlewares.button_stats import ButtonStatsMiddleware +from app.middlewares.context_binding import ContextVarsMiddleware from app.middlewares.global_error import GlobalErrorMiddleware from app.middlewares.logging import LoggingMiddleware from app.middlewares.maintenance import MaintenanceMiddleware @@ -75,14 +75,14 @@ from app.utils.message_patch import patch_message_methods patch_message_methods() -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def debug_callback_handler(callback: types.CallbackQuery): logger.info('🔍 DEBUG CALLBACK:') - logger.info(f' - Data: {callback.data}') - logger.info(f' - User: {callback.from_user.id}') - logger.info(f' - Username: {callback.from_user.username}') + logger.info('Data', callback_data=callback.data) + logger.info('User', from_user_id=callback.from_user.id) + logger.info('Username', username=callback.from_user.username) async def setup_bot() -> tuple[Bot, Dispatcher]: @@ -90,7 +90,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: await cache.connect() logger.info('Кеш инициализирован') except Exception as e: - logger.warning(f'Кеш не инициализирован: {e}') + logger.warning('Кеш не инициализирован', error=e) from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode @@ -106,12 +106,15 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: storage = RedisStorage(redis_client) logger.info('Подключено к Redis для FSM storage') except Exception as e: - logger.warning(f'Не удалось подключиться к Redis: {e}') + logger.warning('Не удалось подключиться к Redis', error=e) logger.info('Используется MemoryStorage для FSM') storage = MemoryStorage() dp = Dispatcher(storage=storage) + dp.message.middleware(ContextVarsMiddleware()) + dp.callback_query.middleware(ContextVarsMiddleware()) + dp.pre_checkout_query.middleware(ContextVarsMiddleware()) dp.message.middleware(GlobalErrorMiddleware()) dp.callback_query.middleware(GlobalErrorMiddleware()) dp.pre_checkout_query.middleware(GlobalErrorMiddleware()) @@ -205,7 +208,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: await maintenance_service.start_monitoring() logger.info('Мониторинг техработ запущен') except Exception as e: - logger.error(f'Ошибка запуска мониторинга техработ: {e}') + logger.error('Ошибка запуска мониторинга техработ', error=e) else: logger.info('Мониторинг техработ отключен настройками') @@ -236,7 +239,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: 'Установите MINIAPP_CUSTOM_URL.' ) elif settings.is_cabinet_mode(): - logger.info(f'🏠 Режим Cabinet активен, базовый URL: {settings.MINIAPP_CUSTOM_URL}') + logger.info('🏠 Режим Cabinet активен, базовый URL', MINIAPP_CUSTOM_URL=settings.MINIAPP_CUSTOM_URL) # Load per-section button styles cache if settings.is_cabinet_mode(): @@ -245,7 +248,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: await load_button_styles_cache() except Exception as e: - logger.warning(f'Failed to load button styles cache: {e}') + logger.warning('Failed to load button styles cache', error=e) logger.info('Бот успешно настроен') @@ -257,10 +260,10 @@ async def shutdown_bot(): await maintenance_service.stop_monitoring() logger.info('Мониторинг техработ остановлен') except Exception as e: - logger.error(f'Ошибка остановки мониторинга: {e}') + logger.error('Ошибка остановки мониторинга', error=e) try: await cache.close() logger.info('Соединения с кешем закрыты') except Exception as e: - logger.error(f'Ошибка закрытия кеша: {e}') + logger.error('Ошибка закрытия кеша', error=e) diff --git a/app/cabinet/auth/oauth_providers.py b/app/cabinet/auth/oauth_providers.py index e83cab85..0a22a881 100644 --- a/app/cabinet/auth/oauth_providers.py +++ b/app/cabinet/auth/oauth_providers.py @@ -1,18 +1,18 @@ """OAuth 2.0 provider implementations for cabinet authentication.""" -import logging import secrets from abc import ABC, abstractmethod from typing import Any, TypedDict import httpx +import structlog from pydantic import BaseModel from app.config import settings from app.utils.cache import cache, cache_key -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) STATE_TTL_SECONDS = 600 # 10 minutes diff --git a/app/cabinet/dependencies.py b/app/cabinet/dependencies.py index 73bdcc08..e10c76bd 100644 --- a/app/cabinet/dependencies.py +++ b/app/cabinet/dependencies.py @@ -1,8 +1,8 @@ """FastAPI dependencies for cabinet module.""" import asyncio -import logging +import structlog from aiogram import Bot from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer @@ -18,7 +18,7 @@ from app.services.maintenance_service import maintenance_service from .auth.jwt_handler import get_token_payload -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) security = HTTPBearer(auto_error=False) @@ -161,10 +161,12 @@ async def get_current_cabinet_user( except HTTPException: raise except TimeoutError: - logger.warning(f'Timeout checking channel subscription for user {user.telegram_id}') + logger.warning('Timeout checking channel subscription for user', telegram_id=user.telegram_id) # Don't block user if check times out except Exception as e: - logger.warning(f'Failed to check channel subscription for user {user.telegram_id}: {e}') + logger.warning( + 'Failed to check channel subscription for user', telegram_id=user.telegram_id, error=e + ) # Don't block user if check fails return user diff --git a/app/cabinet/routes/admin_apps.py b/app/cabinet/routes/admin_apps.py index 10169673..b3b1a7ec 100644 --- a/app/cabinet/routes/admin_apps.py +++ b/app/cabinet/routes/admin_apps.py @@ -1,9 +1,9 @@ """Admin routes for managing VPN applications in app-config.json.""" import json -import logging from pathlib import Path +import structlog from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -16,7 +16,7 @@ from app.services.system_settings_service import bot_configuration_service from ..dependencies import get_cabinet_db, get_current_admin_user -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/apps', tags=['Cabinet Admin Apps']) @@ -231,7 +231,7 @@ async def create_app( config['platforms'] = platforms _save_config(config) - logger.info(f"Admin {admin.id} created app '{request.app.id}' for platform '{platform}'") + logger.info('Admin created app for platform', admin_id=admin.id, app_id=request.app.id, platform=platform) return request.app @@ -274,7 +274,7 @@ async def update_app( config['platforms'] = platforms _save_config(config) - logger.info(f"Admin {admin.id} updated app '{app_id}' in platform '{platform}'") + logger.info('Admin updated app in platform', admin_id=admin.id, app_id=app_id, platform=platform) return request.app @@ -310,7 +310,7 @@ async def delete_app( config['platforms'] = platforms _save_config(config) - logger.info(f"Admin {admin.id} deleted app '{app_id}' from platform '{platform}'") + logger.info('Admin deleted app from platform', admin_id=admin.id, app_id=app_id, platform=platform) return {'status': 'deleted', 'app_id': app_id} @@ -355,7 +355,7 @@ async def reorder_apps( config['platforms'] = platforms _save_config(config) - logger.info(f"Admin {admin.id} reordered apps in platform '{platform}'") + logger.info('Admin reordered apps in platform', admin_id=admin.id, platform=platform) return {'status': 'reordered', 'order': request.app_ids} @@ -374,7 +374,7 @@ async def update_branding( config['config']['branding'] = request.branding.model_dump() _save_config(config) - logger.info(f'Admin {admin.id} updated branding') + logger.info('Admin updated branding', admin_id=admin.id) return request.branding @@ -434,7 +434,14 @@ async def copy_app_to_platform( config['platforms'] = platforms _save_config(config) - logger.info(f"Admin {admin.id} copied app '{app_id}' from '{platform}' to '{target_platform}' as '{new_id}'") + logger.info( + 'Admin copied app from to as', + admin_id=admin.id, + app_id=app_id, + platform=platform, + target_platform=target_platform, + new_id=new_id, + ) return {'status': 'copied', 'new_id': new_id, 'target_platform': target_platform} @@ -498,9 +505,9 @@ async def set_remnawave_config_uuid( try: await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid_value) await db.commit() - logger.info(f"Admin {admin.id} updated CABINET_REMNA_SUB_CONFIG to '{uuid_value}'") + logger.info('Admin updated CABINET_REMNA_SUB_CONFIG to', admin_id=admin.id, uuid_value=uuid_value) except Exception as e: - logger.error(f'Error saving RemnaWave config UUID: {e}') + logger.error('Error saving RemnaWave config UUID', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to save configuration', @@ -547,7 +554,7 @@ async def get_remnawave_subscription_config( except HTTPException: raise except Exception as e: - logger.error(f'Error fetching RemnaWave config: {e}') + logger.error('Error fetching RemnaWave config', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Failed to fetch config from RemnaWave: {e!s}', @@ -572,7 +579,7 @@ async def list_remnawave_subscription_configs( for c in configs ] except Exception as e: - logger.error(f'Error listing RemnaWave configs: {e}') + logger.error('Error listing RemnaWave configs', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Failed to fetch configs from RemnaWave: {e!s}', diff --git a/app/cabinet/routes/admin_ban_system.py b/app/cabinet/routes/admin_ban_system.py index 16c57166..000183f8 100644 --- a/app/cabinet/routes/admin_ban_system.py +++ b/app/cabinet/routes/admin_ban_system.py @@ -1,8 +1,8 @@ """Admin routes for Ban System monitoring in cabinet.""" -import logging from typing import Any +import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status from app.config import settings @@ -45,7 +45,7 @@ from ..schemas.ban_system import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/ban-system', tags=['Cabinet Admin Ban System']) @@ -53,9 +53,11 @@ router = APIRouter(prefix='/admin/ban-system', tags=['Cabinet Admin Ban System'] def _get_ban_api() -> BanSystemAPI: """Get Ban System API instance.""" logger.debug( - f'Ban System check - enabled: {settings.is_ban_system_enabled()}, configured: {settings.is_ban_system_configured()}' + 'Ban System check enabled: configured', + is_ban_system_enabled=settings.is_ban_system_enabled(), + is_ban_system_configured=settings.is_ban_system_configured(), ) - logger.debug(f'Ban System URL: {settings.get_ban_system_api_url()}') + logger.debug('Ban System URL', get_ban_system_api_url=settings.get_ban_system_api_url()) if not settings.is_ban_system_enabled(): raise HTTPException( @@ -83,13 +85,13 @@ async def _api_request(api: BanSystemAPI, method: str, *args, **kwargs) -> Any: func = getattr(api, method) return await func(*args, **kwargs) except BanSystemAPIError as e: - logger.error(f'Ban System API error: {e}') + logger.error('Ban System API error', error=e) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f'Ban System API error: {e.message}', ) except Exception as e: - logger.error(f'Ban System unexpected error: {e}') + logger.error('Ban System unexpected error', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Internal error: {e!s}', @@ -133,7 +135,7 @@ async def get_stats( api = _get_ban_api() data = await _api_request(api, 'get_stats') - logger.debug(f'Ban System raw stats: {data}') + logger.debug('Ban System raw stats', data=data) # Extract punishment stats punishment_stats = data.get('punishment_stats') or {} @@ -364,7 +366,7 @@ async def unban_user( api = _get_ban_api() try: await _api_request(api, 'enable_user', user_id=user_id) - logger.info(f'Admin {admin.id} unbanned user {user_id} in Ban System') + logger.info('Admin unbanned user in Ban System', admin_id=admin.id, user_id=user_id) return UnbanResponse(success=True, message='User unbanned successfully') except HTTPException: raise @@ -387,7 +389,7 @@ async def ban_user( minutes=request.minutes, reason=request.reason, ) - logger.info(f'Admin {admin.id} banned user {request.username}: {request.reason}') + logger.info('Admin banned user', admin_id=admin.id, username=request.username, reason=request.reason) return UnbanResponse(success=True, message='User banned successfully') except HTTPException: raise @@ -819,7 +821,7 @@ async def set_setting( api = _get_ban_api() data = await _api_request(api, 'set_setting', key=key, value=value) - logger.info(f'Admin {admin.id} changed Ban System setting {key} to {value}') + logger.info('Admin changed Ban System setting to', admin_id=admin.id, key=key, value=value) return _parse_setting_response(key, data) @@ -833,7 +835,7 @@ async def toggle_setting( api = _get_ban_api() data = await _api_request(api, 'toggle_setting', key=key) - logger.info(f'Admin {admin.id} toggled Ban System setting {key}') + logger.info('Admin toggled Ban System setting', admin_id=admin.id, key=key) return _parse_setting_response(key, data, default_type='bool') @@ -850,7 +852,7 @@ async def whitelist_add( api = _get_ban_api() try: await _api_request(api, 'whitelist_add', username=request.username) - logger.info(f'Admin {admin.id} added {request.username} to Ban System whitelist') + logger.info('Admin added to Ban System whitelist', admin_id=admin.id, username=request.username) return UnbanResponse(success=True, message=f'User {request.username} added to whitelist') except HTTPException: raise @@ -867,7 +869,7 @@ async def whitelist_remove( api = _get_ban_api() try: await _api_request(api, 'whitelist_remove', username=request.username) - logger.info(f'Admin {admin.id} removed {request.username} from Ban System whitelist') + logger.info('Admin removed from Ban System whitelist', admin_id=admin.id, username=request.username) return UnbanResponse(success=True, message=f'User {request.username} removed from whitelist') except HTTPException: raise diff --git a/app/cabinet/routes/admin_broadcasts.py b/app/cabinet/routes/admin_broadcasts.py index 950c2690..099b1228 100644 --- a/app/cabinet/routes/admin_broadcasts.py +++ b/app/cabinet/routes/admin_broadcasts.py @@ -1,8 +1,8 @@ """Admin routes for broadcasts in cabinet.""" -import logging from datetime import datetime +import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import distinct, func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -40,7 +40,7 @@ from ..schemas.broadcasts import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/broadcasts', tags=['Cabinet Admin Broadcasts']) @@ -255,7 +255,7 @@ async def get_filters( try: count = await get_target_users_count(db, key) except Exception as e: - logger.warning(f'Failed to get count for filter {key}: {e}') + logger.warning('Failed to get count for filter', key=key, error=e) count = 0 filters.append( BroadcastFilter( @@ -272,7 +272,7 @@ async def get_filters( try: count = await get_target_users_count(db, key) except Exception as e: - logger.warning(f'Failed to get count for custom filter {key}: {e}') + logger.warning('Failed to get count for custom filter', key=key, error=e) count = 0 custom_filters.append( BroadcastFilter( @@ -367,7 +367,7 @@ async def preview_broadcast( try: count = await get_target_users_count(db, request.target) except Exception as e: - logger.error(f'Failed to get count for target {request.target}: {e}') + logger.error('Failed to get count for target', target=request.target, error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to count recipients', @@ -450,7 +450,9 @@ async def create_broadcast( await broadcast_service.start_broadcast(broadcast.id, config) await db.refresh(broadcast) - logger.info(f"Admin {admin.id} created broadcast {broadcast.id} for target '{request.target}'") + logger.info( + 'Admin created broadcast for target', admin_id=admin.id, broadcast_id=broadcast.id, target=request.target + ) return _serialize_broadcast(broadcast) @@ -494,7 +496,7 @@ async def get_email_filters( try: count = await _get_email_filter_count(db, key) except Exception as e: - logger.warning(f'Failed to get count for email filter {key}: {e}') + logger.warning('Failed to get count for email filter', key=key, error=e) count = 0 filters.append( @@ -532,7 +534,7 @@ async def preview_email_broadcast( try: count = await _get_email_filter_count(db, request.target) except Exception as e: - logger.error(f'Failed to get email count for target {request.target}: {e}') + logger.error('Failed to get email count for target', target=request.target, error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to count email recipients', @@ -661,7 +663,13 @@ async def create_combined_broadcast( await db.refresh(broadcast) - logger.info(f"Admin {admin.id} created {request.channel} broadcast {broadcast.id} for target '{request.target}'") + logger.info( + 'Admin created broadcast for target', + admin_id=admin.id, + channel=request.channel, + broadcast_id=broadcast.id, + target=request.target, + ) return _serialize_broadcast(broadcast) @@ -721,6 +729,6 @@ async def stop_broadcast( await db.commit() await db.refresh(broadcast) - logger.info(f'Admin {admin.id} stopped broadcast {broadcast_id}') + logger.info('Admin stopped broadcast', admin_id=admin.id, broadcast_id=broadcast_id) return _serialize_broadcast(broadcast) diff --git a/app/cabinet/routes/admin_button_styles.py b/app/cabinet/routes/admin_button_styles.py index 9b039431..d67fbd4a 100644 --- a/app/cabinet/routes/admin_button_styles.py +++ b/app/cabinet/routes/admin_button_styles.py @@ -1,8 +1,8 @@ """Admin routes for per-section cabinet button style configuration.""" import json -import logging +import structlog from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -20,7 +20,7 @@ from app.utils.button_styles_cache import ( from ..dependencies import get_cabinet_db, get_current_admin_user -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/button-styles', tags=['Admin Button Styles']) @@ -234,7 +234,9 @@ async def update_button_styles( # Refresh in-process cache await load_button_styles_cache() - logger.info('Admin %s updated button styles for sections: %s', admin.telegram_id, changed_sections) + logger.info( + 'Admin updated button styles for sections', telegram_id=admin.telegram_id, changed_sections=changed_sections + ) return _build_response(current) @@ -248,6 +250,6 @@ async def reset_button_styles( await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES)) await load_button_styles_cache() - logger.info('Admin %s reset button styles to defaults', admin.telegram_id) + logger.info('Admin reset button styles to defaults', telegram_id=admin.telegram_id) return _build_response(DEFAULT_BUTTON_STYLES) diff --git a/app/cabinet/routes/admin_campaigns.py b/app/cabinet/routes/admin_campaigns.py index 117a7606..5e922b1e 100644 --- a/app/cabinet/routes/admin_campaigns.py +++ b/app/cabinet/routes/admin_campaigns.py @@ -1,7 +1,6 @@ """Admin routes for managing advertising campaigns in cabinet.""" -import logging - +import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -45,7 +44,7 @@ from ..schemas.campaigns import ( from ..schemas.tariffs import TariffListItem -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/campaigns', tags=['Cabinet Admin Campaigns']) @@ -378,7 +377,7 @@ async def create_new_campaign( # Reload to get tariff relationship campaign = await get_campaign_by_id(db, campaign.id) - logger.info(f'Admin {admin.id} created campaign {campaign.id}: {campaign.name}') + logger.info('Admin created campaign', admin_id=admin.id, campaign_id=campaign.id, campaign_name=campaign.name) return await get_campaign(campaign.id, admin, db) @@ -447,7 +446,7 @@ async def update_existing_campaign( if updates: await update_campaign(db, campaign, **updates) - logger.info(f'Admin {admin.id} updated campaign {campaign_id}') + logger.info('Admin updated campaign', admin_id=admin.id, campaign_id=campaign_id) return await get_campaign(campaign_id, admin, db) @@ -475,7 +474,7 @@ async def delete_existing_campaign( ) await delete_campaign(db, campaign) - logger.info(f'Admin {admin.id} deleted campaign {campaign_id}: {campaign.name}') + logger.info('Admin deleted campaign', admin_id=admin.id, campaign_id=campaign_id, campaign_name=campaign.name) return {'message': 'Campaign deleted successfully'} @@ -498,7 +497,7 @@ async def toggle_campaign( await update_campaign(db, campaign, is_active=new_status) status_text = 'activated' if new_status else 'deactivated' - logger.info(f'Admin {admin.id} {status_text} campaign {campaign_id}') + logger.info('Admin campaign', admin_id=admin.id, status_text=status_text, campaign_id=campaign_id) return CampaignToggleResponse( id=campaign_id, diff --git a/app/cabinet/routes/admin_email_templates.py b/app/cabinet/routes/admin_email_templates.py index 4e4fdd02..01109403 100644 --- a/app/cabinet/routes/admin_email_templates.py +++ b/app/cabinet/routes/admin_email_templates.py @@ -1,9 +1,9 @@ """Admin routes for managing email notification templates.""" import asyncio -import logging from typing import Any +import structlog from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -20,7 +20,7 @@ from ..services.email_template_overrides import ( from ..services.email_templates import EmailNotificationTemplates -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/email-templates', tags=['Admin Email Templates']) @@ -505,10 +505,7 @@ async def update_template( ) logger.info( - 'Админ %s обновил email шаблон %s/%s', - admin.id, - notification_type, - language, + 'Админ обновил email шаблон /', admin_id=admin.id, notification_type=notification_type, language=language ) return {'status': 'ok', 'template': result} @@ -533,10 +530,10 @@ async def reset_template( if deleted: logger.info( - 'Админ %s сбросил email шаблон %s/%s к дефолту', - admin.id, - notification_type, - language, + 'Админ сбросил email шаблон / к дефолту', + admin_id=admin.id, + notification_type=notification_type, + language=language, ) return {'status': 'ok', 'was_custom': deleted} @@ -657,7 +654,7 @@ async def send_test_email( body_html=body_html, ) except Exception as e: - logger.error('Ошибка отправки тестового email: %s', e) + logger.error('Ошибка отправки тестового email', e=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Failed to send test email: {e!s}', @@ -670,11 +667,11 @@ async def send_test_email( ) logger.info( - 'Админ %s отправил тестовый email %s/%s на %s', - admin.id, - notification_type, - language, - to_email, + 'Админ отправил тестовый email / на', + admin_id=admin.id, + notification_type=notification_type, + language=language, + to_email=to_email, ) return {'status': 'ok', 'sent_to': to_email} diff --git a/app/cabinet/routes/admin_payment_methods.py b/app/cabinet/routes/admin_payment_methods.py index 4e6de175..7de07d48 100644 --- a/app/cabinet/routes/admin_payment_methods.py +++ b/app/cabinet/routes/admin_payment_methods.py @@ -1,8 +1,8 @@ """Admin routes for payment method configuration in cabinet.""" -import logging from datetime import datetime +import structlog from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -20,7 +20,7 @@ from app.services.payment_method_config_service import ( from ..dependencies import get_cabinet_db, get_current_admin_user -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/payment-methods', tags=['Cabinet Admin Payment Methods']) @@ -168,7 +168,7 @@ async def update_payment_methods_order( ): """Batch update sort order for payment methods.""" await update_sort_order(db, request.method_ids) - logger.info(f'Admin {admin.id} updated payment methods order: {request.method_ids}') + logger.info('Admin updated payment methods order', admin_id=admin.id, method_ids=request.method_ids) return {'success': True} @@ -222,7 +222,7 @@ async def update_payment_method( detail=f'Payment method not found: {method_id}', ) - logger.info(f'Admin {admin.id} updated payment method config: {method_id}') + logger.info('Admin updated payment method config', admin_id=admin.id, method_id=method_id) defaults = _get_method_defaults() return _enrich_config(config, defaults) diff --git a/app/cabinet/routes/admin_payments.py b/app/cabinet/routes/admin_payments.py index 3b7f7ab3..10dbf991 100644 --- a/app/cabinet/routes/admin_payments.py +++ b/app/cabinet/routes/admin_payments.py @@ -1,9 +1,9 @@ """Admin routes for payment verification in cabinet.""" -import logging import math from datetime import datetime +import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -22,7 +22,7 @@ from app.services.payment_verification_service import ( from ..dependencies import get_cabinet_db, get_current_admin_user -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/payments', tags=['Cabinet Admin Payments']) @@ -406,7 +406,14 @@ async def check_payment_status( if status_changed: _, new_status_text = _get_status_info(updated) message = f'Статус обновлён: {new_status_text}' - logger.info(f'Admin {admin.id} checked payment {method}/{payment_id}: {old_status} -> {updated.status}') + logger.info( + 'Admin checked payment /', + admin_id=admin.id, + method=method, + payment_id=payment_id, + old_status=old_status, + status=updated.status, + ) else: message = 'Статус не изменился' diff --git a/app/cabinet/routes/admin_pinned_messages.py b/app/cabinet/routes/admin_pinned_messages.py index 46776142..16f70839 100644 --- a/app/cabinet/routes/admin_pinned_messages.py +++ b/app/cabinet/routes/admin_pinned_messages.py @@ -1,9 +1,9 @@ """Admin routes for pinned messages in cabinet.""" -import logging import time from datetime import datetime +import structlog from aiogram import Bot from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode @@ -34,7 +34,7 @@ from ..schemas.pinned_messages import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/pinned-messages', tags=['Cabinet Admin Pinned Messages']) @@ -186,7 +186,9 @@ async def create_pinned_message( if payload.broadcast: sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg) - logger.info(f'Admin {admin.id} created pinned message #{msg.id} (broadcast={payload.broadcast})') + logger.info( + 'Admin created pinned message # (broadcast=)', admin_id=admin.id, message_id=msg.id, broadcast=payload.broadcast + ) return PinnedMessageBroadcastResponse( message=_serialize_pinned_message(msg), @@ -229,7 +231,7 @@ async def update_pinned_message( await db.commit() await db.refresh(msg) - logger.info(f'Admin {admin.id} updated pinned message #{message_id}') + logger.info('Admin updated pinned message #', admin_id=admin.id, message_id=message_id) return _serialize_pinned_message(msg) @@ -273,7 +275,7 @@ async def deactivate_active_message( if not msg: return None - logger.info(f'Admin {admin.id} deactivated pinned message #{msg.id}') + logger.info('Admin deactivated pinned message #', admin_id=admin.id, message_id=msg.id) return _serialize_pinned_message(msg) @@ -288,7 +290,12 @@ async def unpin_active_message( unpinned_count, failed_count, was_active = await unpin_active_pinned_message(_get_bot(), db) if was_active: - logger.info(f'Admin {admin.id} unpinned active message: unpinned={unpinned_count}, failed={failed_count}') + logger.info( + 'Admin unpinned active message: unpinned=, failed', + admin_id=admin.id, + unpinned_count=unpinned_count, + failed_count=failed_count, + ) return PinnedMessageUnpinResponse( unpinned_count=unpinned_count, @@ -339,7 +346,9 @@ async def activate_pinned_message( if broadcast: sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg) - logger.info(f'Admin {admin.id} activated pinned message #{message_id} (broadcast={broadcast})') + logger.info( + 'Admin activated pinned message # (broadcast=)', admin_id=admin.id, message_id=message_id, broadcast=broadcast + ) return PinnedMessageBroadcastResponse( message=_serialize_pinned_message(msg), @@ -364,7 +373,13 @@ async def broadcast_message( sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg) - logger.info(f'Admin {admin.id} broadcast pinned message #{message_id}: sent={sent_count}, failed={failed_count}') + logger.info( + 'Admin broadcast pinned message #: sent=, failed', + admin_id=admin.id, + message_id=message_id, + sent_count=sent_count, + failed_count=failed_count, + ) return PinnedMessageBroadcastResponse( message=_serialize_pinned_message(msg), @@ -394,4 +409,4 @@ async def delete_pinned_message( await db.delete(msg) await db.commit() - logger.info(f'Admin {admin.id} deleted pinned message #{message_id}') + logger.info('Admin deleted pinned message #', admin_id=admin.id, message_id=message_id) diff --git a/app/cabinet/routes/admin_promo_offers.py b/app/cabinet/routes/admin_promo_offers.py index ae47063c..43caf556 100644 --- a/app/cabinet/routes/admin_promo_offers.py +++ b/app/cabinet/routes/admin_promo_offers.py @@ -3,10 +3,10 @@ from __future__ import annotations import asyncio -import logging from datetime import datetime from typing import Any +import structlog from aiogram import Bot from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode @@ -37,7 +37,7 @@ from app.utils.miniapp_buttons import build_miniapp_or_callback_button from ..dependencies import get_cabinet_db, get_current_admin_user -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/promo-offers', tags=['Admin Promo Offers']) @@ -430,7 +430,7 @@ async def _send_promo_notifications( async def send_single(user: User, offer: DiscountOffer) -> bool: # Skip email-only users (no telegram_id) if not user.telegram_id: - logger.debug(f'Skipping promo notification for email-only user {user.id}') + logger.debug('Skipping promo notification for email-only user', user_id=user.id) return False async with semaphore: @@ -459,18 +459,10 @@ async def _send_promo_notifications( ) return True except (TelegramForbiddenError, TelegramBadRequest) as exc: - logger.warning( - 'Failed to send promo notification to user %s: %s', - user.telegram_id, - exc, - ) + logger.warning('Failed to send promo notification to user', telegram_id=user.telegram_id, exc=exc) return False except Exception as exc: - logger.error( - 'Error sending promo notification to user %s: %s', - user.telegram_id, - exc, - ) + logger.error('Error sending promo notification to user', telegram_id=user.telegram_id, exc=exc) return False # Send in batches diff --git a/app/cabinet/routes/admin_remnawave.py b/app/cabinet/routes/admin_remnawave.py index f773d3e9..8d7be615 100644 --- a/app/cabinet/routes/admin_remnawave.py +++ b/app/cabinet/routes/admin_remnawave.py @@ -1,9 +1,9 @@ """Admin routes for RemnaWave management in cabinet.""" -import logging from datetime import datetime, timedelta from typing import Any +import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession @@ -76,7 +76,7 @@ except Exception: remnawave_sync_service = None -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/remnawave', tags=['Cabinet Admin RemnaWave']) @@ -380,7 +380,9 @@ async def perform_node_action( } if success: - logger.info(f'Admin {admin.telegram_id} performed {payload.action} on node {node_uuid}') + logger.info( + 'Admin performed on node', telegram_id=admin.telegram_id, action=payload.action, node_uuid=node_uuid + ) return NodeActionResponse( success=True, message=messages.get(payload.action, 'Action completed'), @@ -403,7 +405,7 @@ async def restart_all_nodes( success = await service.restart_all_nodes() if success: - logger.info(f'Admin {admin.telegram_id} restarted all nodes') + logger.info('Admin restarted all nodes', telegram_id=admin.telegram_id) return NodeActionResponse(success=True, message='All nodes restart initiated') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -510,7 +512,9 @@ async def create_squad( squad_uuid = await service.create_squad(payload.name, payload.inbound_uuids) if squad_uuid: - logger.info(f'Admin {admin.telegram_id} created squad {payload.name} ({squad_uuid})') + logger.info( + 'Admin created squad', telegram_id=admin.telegram_id, payload_name=payload.name, squad_uuid=squad_uuid + ) return SquadOperationResponse( success=True, message='Squad created successfully', @@ -545,7 +549,7 @@ async def update_squad( ) if success: - logger.info(f'Admin {admin.telegram_id} updated squad {squad_uuid}') + logger.info('Admin updated squad', telegram_id=admin.telegram_id, squad_uuid=squad_uuid) return SquadOperationResponse(success=True, message='Squad updated') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -594,7 +598,7 @@ async def perform_squad_action( message = 'Inbounds updated' if success else 'Failed to update inbounds' if success: - logger.info(f'Admin {admin.telegram_id} performed {action} on squad {squad_uuid}') + logger.info('Admin performed on squad', telegram_id=admin.telegram_id, action=action, squad_uuid=squad_uuid) return SquadOperationResponse(success=success, message=message) @@ -611,7 +615,7 @@ async def delete_squad( success = await service.delete_squad(squad_uuid) if success: - logger.info(f'Admin {admin.telegram_id} deleted squad {squad_uuid}') + logger.info('Admin deleted squad', telegram_id=admin.telegram_id, squad_uuid=squad_uuid) return SquadOperationResponse(success=True, message='Squad deleted') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -699,7 +703,9 @@ async def migrate_squad_users( error=result.get('error'), ) - logger.info(f'Admin {admin.telegram_id} migrated users from {source_uuid} to {target_uuid}') + logger.info( + 'Admin migrated users from to', telegram_id=admin.telegram_id, source_uuid=source_uuid, target_uuid=target_uuid + ) return MigrationResponse( success=True, @@ -782,14 +788,14 @@ async def toggle_auto_sync( if payload.enabled and not current_status.enabled: # Enable - would need to update settings and refresh schedule remnawave_sync_service.schedule_refresh(run_immediately=True) - logger.info(f'Admin {admin.telegram_id} enabled auto sync') + logger.info('Admin enabled auto sync', telegram_id=admin.telegram_id) return SyncResponse( success=True, message='Auto sync enabled and scheduled', ) if not payload.enabled and current_status.enabled: # Disable - would need to update settings and stop scheduler - logger.info(f'Admin {admin.telegram_id} disabled auto sync') + logger.info('Admin disabled auto sync', telegram_id=admin.telegram_id) return SyncResponse( success=True, message='Auto sync setting change requested. Restart may be required.', @@ -811,7 +817,7 @@ async def run_auto_sync_now( detail='Auto sync service is not available', ) - logger.info(f'Admin {admin.telegram_id} triggered manual sync') + logger.info('Admin triggered manual sync', telegram_id=admin.telegram_id) result = await remnawave_sync_service.run_sync_now(reason='manual') return AutoSyncRunResponse( @@ -839,7 +845,7 @@ async def sync_from_panel( try: stats = await service.sync_users_from_panel(db, payload.mode) - logger.info(f'Admin {admin.telegram_id} synced from panel (mode: {payload.mode})') + logger.info('Admin synced from panel (mode: )', telegram_id=admin.telegram_id, mode=payload.mode) return SyncResponse( success=True, message='Sync from panel completed', @@ -862,7 +868,7 @@ async def sync_to_panel( _ensure_configured(service) stats = await service.sync_users_to_panel(db) - logger.info(f'Admin {admin.telegram_id} synced to panel') + logger.info('Admin synced to panel', telegram_id=admin.telegram_id) return SyncResponse( success=True, @@ -892,9 +898,15 @@ async def sync_servers( try: await cache.delete_pattern('available_countries*') except Exception as e: - logger.warning(f'Failed to clear countries cache: {e}') + logger.warning('Failed to clear countries cache', error=e) - logger.info(f'Admin {admin.telegram_id} synced servers: created={created}, updated={updated}, removed={removed}') + logger.info( + 'Admin synced servers: created=, updated=, removed', + telegram_id=admin.telegram_id, + created=created, + updated=updated, + removed=removed, + ) return SyncResponse( success=True, @@ -918,7 +930,7 @@ async def validate_subscriptions( _ensure_configured(service) stats = await service.validate_and_fix_subscriptions(db) - logger.info(f'Admin {admin.telegram_id} validated subscriptions') + logger.info('Admin validated subscriptions', telegram_id=admin.telegram_id) return SyncResponse( success=True, @@ -937,7 +949,7 @@ async def cleanup_subscriptions( _ensure_configured(service) stats = await service.cleanup_orphaned_subscriptions(db) - logger.info(f'Admin {admin.telegram_id} cleaned up subscriptions') + logger.info('Admin cleaned up subscriptions', telegram_id=admin.telegram_id) return SyncResponse( success=True, @@ -956,7 +968,7 @@ async def sync_subscription_statuses( _ensure_configured(service) stats = await service.sync_subscription_statuses(db) - logger.info(f'Admin {admin.telegram_id} synced subscription statuses') + logger.info('Admin synced subscription statuses', telegram_id=admin.telegram_id) return SyncResponse( success=True, diff --git a/app/cabinet/routes/admin_servers.py b/app/cabinet/routes/admin_servers.py index 48072276..898eb432 100644 --- a/app/cabinet/routes/admin_servers.py +++ b/app/cabinet/routes/admin_servers.py @@ -1,7 +1,6 @@ """Admin routes for managing servers in cabinet.""" -import logging - +import structlog from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import String, func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -31,7 +30,7 @@ from ..schemas.servers import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/servers', tags=['Cabinet Admin Servers']) @@ -184,7 +183,7 @@ async def update_existing_server( if request.promo_group_ids is not None: await update_server_squad_promo_groups(db, server_id, request.promo_group_ids) - logger.info(f'Admin {admin.id} updated server {server_id}') + logger.info('Admin updated server', admin_id=admin.id, server_id=server_id) return await get_server(server_id, admin, db) @@ -207,7 +206,7 @@ async def toggle_server( await update_server_squad(db, server_id, is_available=new_status) status_text = 'enabled' if new_status else 'disabled' - logger.info(f'Admin {admin.id} {status_text} server {server_id}') + logger.info('Admin server', admin_id=admin.id, status_text=status_text, server_id=server_id) return ServerToggleResponse( id=server_id, @@ -234,7 +233,7 @@ async def toggle_server_trial( await update_server_squad(db, server_id, is_trial_eligible=new_status) status_text = 'enabled for trial' if new_status else 'disabled for trial' - logger.info(f'Admin {admin.id} {status_text} server {server_id}') + logger.info('Admin server', admin_id=admin.id, status_text=status_text, server_id=server_id) return ServerTrialToggleResponse( id=server_id, @@ -311,7 +310,7 @@ async def sync_servers( # Sync with database created, updated, removed = await sync_with_remnawave(db, squads) - logger.info(f'Admin {admin.id} synced servers: +{created} ~{updated} -{removed}') + logger.info('Admin synced servers: + ~', admin_id=admin.id, created=created, updated=updated, removed=removed) return ServerSyncResponse( created=created, @@ -323,7 +322,7 @@ async def sync_servers( except HTTPException: raise except Exception as e: - logger.error(f'Failed to sync servers: {e}') + logger.error('Failed to sync servers', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Sync failed: {e!s}', diff --git a/app/cabinet/routes/admin_settings.py b/app/cabinet/routes/admin_settings.py index a2fa69fb..de672f57 100644 --- a/app/cabinet/routes/admin_settings.py +++ b/app/cabinet/routes/admin_settings.py @@ -1,8 +1,8 @@ """Admin settings routes for cabinet - system configuration management.""" -import logging from typing import Any +import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -16,7 +16,7 @@ from app.services.system_settings_service import ( from ..dependencies import get_cabinet_db, get_current_admin_user -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/settings', tags=['Admin Settings']) @@ -248,7 +248,7 @@ async def update_setting( raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error await db.commit() - logger.info(f'Admin {admin.telegram_id} updated setting {key} to {value}') + logger.info('Admin updated setting to', telegram_id=admin.telegram_id, key=key, value=value) return _serialize_definition(definition) @@ -270,5 +270,5 @@ async def reset_setting( raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error await db.commit() - logger.info(f'Admin {admin.telegram_id} reset setting {key}') + logger.info('Admin reset setting', telegram_id=admin.telegram_id, key=key) return _serialize_definition(definition) diff --git a/app/cabinet/routes/admin_stats.py b/app/cabinet/routes/admin_stats.py index 383bfd36..deda96c9 100644 --- a/app/cabinet/routes/admin_stats.py +++ b/app/cabinet/routes/admin_stats.py @@ -1,10 +1,10 @@ """Admin routes for statistics dashboard in cabinet.""" -import logging import sys import time from datetime import datetime, timedelta +import structlog from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy import and_, func, select @@ -29,7 +29,7 @@ from app.services.version_service import version_service from ..dependencies import get_cabinet_db, get_current_admin_user -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) _start_time = time.time() @@ -317,7 +317,7 @@ async def get_dashboard_stats( ) except Exception as e: - logger.error(f'Failed to get dashboard stats: {e}') + logger.error('Failed to get dashboard stats', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to load dashboard statistics', @@ -349,7 +349,7 @@ async def get_system_info( subscriptions_active=subscriptions_active, ) except Exception as e: - logger.error(f'Failed to get system info: {e}') + logger.error('Failed to get system info', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to load system information', @@ -364,7 +364,7 @@ async def get_nodes_status( try: return await _get_nodes_overview() except Exception as e: - logger.error(f'Failed to get nodes status: {e}') + logger.error('Failed to get nodes status', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to load nodes status', @@ -382,7 +382,7 @@ async def restart_node( success = await service.manage_node(node_uuid, 'restart') if success: - logger.info(f'Admin {admin.id} restarted node {node_uuid}') + logger.info('Admin restarted node', admin_id=admin.id, node_uuid=node_uuid) return {'success': True, 'message': 'Node restart initiated'} raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -391,7 +391,7 @@ async def restart_node( except HTTPException: raise except Exception as e: - logger.error(f'Failed to restart node {node_uuid}: {e}') + logger.error('Failed to restart node', node_uuid=node_uuid, error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to restart node', @@ -420,7 +420,7 @@ async def toggle_node( success = await service.manage_node(node_uuid, action) if success: - logger.info(f'Admin {admin.id} {action}d node {node_uuid}') + logger.info('Admin d node', admin_id=admin.id, action=action, node_uuid=node_uuid) return {'success': True, 'message': f'Node {action}d', 'is_disabled': not is_disabled} raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -429,7 +429,7 @@ async def toggle_node( except HTTPException: raise except Exception as e: - logger.error(f'Failed to toggle node {node_uuid}: {e}') + logger.error('Failed to toggle node', node_uuid=node_uuid, error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to toggle node', @@ -480,7 +480,7 @@ async def _get_nodes_overview() -> NodesOverview: nodes=node_statuses, ) except Exception as e: - logger.warning(f'Failed to get nodes from RemnaWave: {e}') + logger.warning('Failed to get nodes from RemnaWave', error=e) # Return empty data if RemnaWave is unavailable return NodesOverview( total=0, @@ -560,7 +560,9 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None: ) purchased_month = month_result.scalar() or 0 - logger.info(f"📊 Тариф '{tariff.name}': активных={active_count}, триал={trial_count}") + logger.info( + '📊 Тариф активных=, триал', tariff_name=tariff.name, active_count=active_count, trial_count=trial_count + ) tariff_items.append( TariffStatItem( @@ -576,7 +578,7 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None: total_tariff_subscriptions += active_count - logger.info(f'📊 Всего подписок по тарифам: {total_tariff_subscriptions}') + logger.info('📊 Всего подписок по тарифам', total_tariff_subscriptions=total_tariff_subscriptions) return TariffStats( tariffs=tariff_items, @@ -584,7 +586,7 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None: ) except Exception as e: - logger.error(f'Failed to get tariff stats: {e}', exc_info=True) + logger.error('Failed to get tariff stats', error=e, exc_info=True) return None @@ -800,7 +802,7 @@ async def get_top_referrers( ) except Exception as e: - logger.error(f'Failed to get top referrers: {e}', exc_info=True) + logger.error('Failed to get top referrers', error=e, exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to load referrers statistics', @@ -857,7 +859,7 @@ async def get_top_campaigns( ) except Exception as e: - logger.error(f'Failed to get top campaigns: {e}', exc_info=True) + logger.error('Failed to get top campaigns', error=e, exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to load campaigns statistics', @@ -996,7 +998,7 @@ async def get_recent_payments( ) except Exception as e: - logger.error(f'Failed to get recent payments: {e}', exc_info=True) + logger.error('Failed to get recent payments', error=e, exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to load recent payments', diff --git a/app/cabinet/routes/admin_tariffs.py b/app/cabinet/routes/admin_tariffs.py index 87fdd783..09d53987 100644 --- a/app/cabinet/routes/admin_tariffs.py +++ b/app/cabinet/routes/admin_tariffs.py @@ -1,7 +1,6 @@ """Admin routes for managing tariffs in cabinet.""" -import logging - +import structlog from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -38,7 +37,7 @@ from ..schemas.tariffs import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/tariffs', tags=['Cabinet Admin Tariffs']) @@ -169,7 +168,7 @@ async def update_tariff_order( await reorder_tariffs(db, request.tariff_ids) await db.commit() - logger.info(f'Admin {admin.id} updated tariff order: {request.tariff_ids}') + logger.info('Admin updated tariff order', admin_id=admin.id, tariff_ids=request.tariff_ids) return {'message': 'Tariff order updated successfully'} @@ -295,7 +294,7 @@ async def create_new_tariff( traffic_reset_mode=request.traffic_reset_mode, ) - logger.info(f'Admin {admin.id} created tariff {tariff.id}: {tariff.name}') + logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name) # Перезагружаем периоды из БД для синхронизации с ботом await load_period_prices_from_db(db) @@ -390,7 +389,7 @@ async def update_existing_tariff( if request.promo_group_ids is not None: await set_tariff_promo_groups(db, tariff, request.promo_group_ids) - logger.info(f'Admin {admin.id} updated tariff {tariff_id}') + logger.info('Admin updated tariff', admin_id=admin.id, tariff_id=tariff_id) # Перезагружаем периоды из БД для синхронизации с ботом await load_period_prices_from_db(db) @@ -414,7 +413,13 @@ async def delete_existing_tariff( subs_count = await get_tariff_subscriptions_count(db, tariff_id) await delete_tariff(db, tariff) - logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name} (affected subscriptions: {subs_count})') + logger.info( + 'Admin deleted tariff (affected subscriptions: )', + admin_id=admin.id, + tariff_id=tariff_id, + tariff_name=tariff.name, + subs_count=subs_count, + ) # Перезагружаем периоды из БД для синхронизации с ботом await load_period_prices_from_db(db) @@ -440,7 +445,7 @@ async def toggle_tariff( await update_tariff(db, tariff, is_active=new_status) status_text = 'activated' if new_status else 'deactivated' - logger.info(f'Admin {admin.id} {status_text} tariff {tariff_id}') + logger.info('Admin tariff', admin_id=admin.id, status_text=status_text, tariff_id=tariff_id) # Перезагружаем периоды из БД для синхронизации с ботом await load_period_prices_from_db(db) @@ -483,7 +488,7 @@ async def toggle_trial_tariff( await update_tariff(db, tariff, is_trial_available=new_status) status_text = 'set as trial' if new_status else 'removed from trial' - logger.info(f'Admin {admin.id} {status_text} tariff {tariff_id}') + logger.info('Admin tariff', admin_id=admin.id, status_text=status_text, tariff_id=tariff_id) return TariffTrialResponse( id=tariff_id, diff --git a/app/cabinet/routes/admin_tickets.py b/app/cabinet/routes/admin_tickets.py index 040ddeb3..72e21b81 100644 --- a/app/cabinet/routes/admin_tickets.py +++ b/app/cabinet/routes/admin_tickets.py @@ -1,9 +1,9 @@ """Admin tickets routes for cabinet.""" -import logging import math from datetime import datetime +import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from sqlalchemy import desc, func, select @@ -20,7 +20,7 @@ from ..dependencies import get_cabinet_db, get_current_admin_user from ..schemas.tickets import TicketMessageResponse -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/tickets', tags=['Cabinet Admin Tickets']) @@ -317,7 +317,7 @@ async def update_ticket_settings( env_file.write_text('\n'.join(new_lines) + '\n') logger.info('Updated ticket settings in .env file') except Exception as e: - logger.warning(f'Failed to update .env file: {e}') + logger.warning('Failed to update .env file', error=e) return TicketSettingsResponse( sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED, @@ -473,11 +473,11 @@ async def reply_to_ticket( await notify_user_about_ticket_reply(bot, ticket, request.message, db) except Exception as e: - logger.warning(f'Failed to notify user about ticket reply: {e}') + logger.warning('Failed to notify user about ticket reply', error=e) finally: await bot.session.close() except Exception as e: - logger.warning(f'Failed to send Telegram notification: {e}') + logger.warning('Failed to send Telegram notification', error=e) # Уведомить пользователя в кабинете try: @@ -488,7 +488,7 @@ async def reply_to_ticket( # Отправить WebSocket уведомление await notify_user_ticket_reply(ticket.user_id, ticket.id, (request.message or '')[:100]) except Exception as e: - logger.warning(f'Failed to create cabinet notification for admin reply: {e}') + logger.warning('Failed to create cabinet notification for admin reply', error=e) return _message_to_response(message) diff --git a/app/cabinet/routes/admin_traffic.py b/app/cabinet/routes/admin_traffic.py index ed67429e..9977b25b 100644 --- a/app/cabinet/routes/admin_traffic.py +++ b/app/cabinet/routes/admin_traffic.py @@ -3,10 +3,10 @@ import asyncio import csv import io -import logging import time from datetime import UTC, datetime, timedelta +import structlog from aiogram import Bot from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode @@ -32,7 +32,7 @@ from ..schemas.traffic import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/traffic', tags=['Admin Traffic']) @@ -110,7 +110,7 @@ async def _aggregate_traffic( stats = await api.get_bandwidth_stats_node_users_legacy(node.uuid, start_str, end_str) return node.uuid, stats except Exception: - logger.warning('Failed to get traffic for node %s', node.name, exc_info=True) + logger.warning('Failed to get traffic for node', node_name=node.name, exc_info=True) return node.uuid, None results = await asyncio.gather(*(fetch_node_users(n) for n in nodes)) @@ -685,7 +685,7 @@ async def export_traffic_csv( caption=f'Traffic usage report ({period_label})\nUsers: {len(rows)}', ) except Exception: - logger.error('Failed to send CSV to admin %s', admin.telegram_id, exc_info=True) + logger.error('Failed to send CSV to admin', telegram_id=admin.telegram_id, exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to send CSV report. Please try again later.', diff --git a/app/cabinet/routes/admin_updates.py b/app/cabinet/routes/admin_updates.py index faa7565b..1e5ce846 100644 --- a/app/cabinet/routes/admin_updates.py +++ b/app/cabinet/routes/admin_updates.py @@ -1,9 +1,9 @@ """Admin routes for version and release information.""" -import logging from datetime import datetime, timedelta import aiohttp +import structlog from fastapi import APIRouter, Depends from pydantic import BaseModel @@ -13,7 +13,7 @@ from app.services.version_service import version_service from ..dependencies import get_current_admin_user -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/updates', tags=['Cabinet Admin Updates']) @@ -76,15 +76,15 @@ async def _fetch_cabinet_releases(force: bool = False) -> list[dict]: ) _cabinet_cache['releases'] = releases _cabinet_last_check = datetime.now() - logger.info('Fetched %d cabinet releases from GitHub', len(releases)) + logger.info('Fetched cabinet releases from GitHub', releases_count=len(releases)) return releases - logger.warning('GitHub API returned status %d for cabinet releases', response.status) + logger.warning('GitHub API returned status for cabinet releases', response_status=response.status) return _cabinet_cache.get('releases', []) except TimeoutError: logger.warning('Timeout fetching cabinet releases from GitHub') return _cabinet_cache.get('releases', []) except Exception as e: - logger.error('Error fetching cabinet releases: %s', e) + logger.error('Error fetching cabinet releases', e=e) return _cabinet_cache.get('releases', []) diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index 064a7f24..6fc13f6c 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -1,8 +1,8 @@ """Admin routes for managing users in cabinet.""" -import logging from datetime import datetime, timedelta +import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import Integer, and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -91,7 +91,7 @@ from ..schemas.users import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/users', tags=['Cabinet Admin Users']) @@ -219,7 +219,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription service = RemnaWaveService() if not service.is_configured: - logger.warning(f'Remnawave not configured, skipping panel sync for user {user.id}') + logger.warning('Remnawave not configured, skipping panel sync for user', user_id=user.id) return {'skipped': True, 'reason': 'Remnawave not configured'} is_active = ( @@ -260,7 +260,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription if panel_uuid: existing_user = await api.get_user_by_uuid(panel_uuid) if not existing_user: - logger.warning(f'User {user.id} has stale remnawave_uuid {panel_uuid}, clearing') + logger.warning('User has stale remnawave_uuid clearing', user_id=user.id, panel_uuid=panel_uuid) panel_uuid = None user.remnawave_uuid = None @@ -299,7 +299,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription try: await api.update_user(**update_kwargs) changes['action'] = 'updated' - logger.info(f'Updated user {user.id} in Remnawave panel') + logger.info('Updated user in Remnawave panel', user_id=user.id) except Exception as update_error: if hasattr(update_error, 'status_code') and update_error.status_code == 404: panel_uuid = None # Will create new @@ -328,7 +328,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription subscription.subscription_url = new_panel_user.subscription_url changes['action'] = 'created' changes['panel_uuid'] = new_panel_user.uuid - logger.info(f'Created user {user.id} in Remnawave panel: {new_panel_user.uuid}') + logger.info('Created user in Remnawave panel', user_id=user.id, uuid=new_panel_user.uuid) user.last_remnawave_sync = datetime.utcnow() await db.commit() @@ -336,7 +336,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription return changes except Exception as e: - logger.error(f'Error syncing user {user.id} to panel: {e}') + logger.error('Error syncing user to panel', user_id=user.id, error=e) return {'error': str(e)} @@ -709,7 +709,7 @@ async def get_user_panel_info( last_node_name = node.node_name break except Exception: - logger.warning(f'Failed to resolve node name for user {user_id}') + logger.warning('Failed to resolve node name for user', user_id=user_id) return UserPanelInfoResponse( found=True, @@ -728,7 +728,7 @@ async def get_user_panel_info( ) except Exception as e: - logger.error(f'Error getting panel info for user {user_id}: {e}') + logger.error('Error getting panel info for user', user_id=user_id, error=e) return UserPanelInfoResponse(found=False) @@ -812,7 +812,7 @@ async def get_user_node_usage( return UserNodeUsageResponse(items=items, categories=categories) except Exception as e: - logger.error(f'Error getting node usage for user {user_id}: {e}') + logger.error('Error getting node usage for user', user_id=user_id, error=e) return UserNodeUsageResponse(items=[]) @@ -877,8 +877,12 @@ async def update_user_balance( await db.refresh(user) logger.info( - f'Admin {admin.id} updated balance for user {user_id}: ' - f'{old_balance} -> {user.balance_kopeks} ({request.amount_kopeks:+d})' + 'Admin updated balance for user', + admin_id=admin.id, + user_id=user_id, + old_balance=old_balance, + balance_kopeks=user.balance_kopeks, + amount_kopeks=format(request.amount_kopeks, '+d'), ) return UpdateBalanceResponse( @@ -962,7 +966,7 @@ async def update_user_subscription( # Sync to Remnawave panel await _sync_subscription_to_panel(db, user, new_sub) - logger.info(f'Admin {admin.id} created subscription for user {user_id}') + logger.info('Admin created subscription for user', admin_id=admin.id, user_id=user_id) return UpdateSubscriptionResponse( success=True, @@ -989,7 +993,9 @@ async def update_user_subscription( # Sync to Remnawave panel await _sync_subscription_to_panel(db, user, subscription) - logger.info(f'Admin {admin.id} extended subscription for user {user_id} by {request.days} days') + logger.info( + 'Admin extended subscription for user by days', admin_id=admin.id, user_id=user_id, days=request.days + ) return UpdateSubscriptionResponse( success=True, @@ -1016,7 +1022,7 @@ async def update_user_subscription( # Sync to Remnawave panel await _sync_subscription_to_panel(db, user, subscription) - logger.info(f'Admin {admin.id} set end_date for user {user_id} subscription') + logger.info('Admin set end_date for user subscription', admin_id=admin.id, user_id=user_id) return UpdateSubscriptionResponse( success=True, @@ -1050,7 +1056,7 @@ async def update_user_subscription( # Sync to Remnawave panel await _sync_subscription_to_panel(db, user, subscription) - logger.info(f'Admin {admin.id} changed tariff for user {user_id} to {tariff.name}') + logger.info('Admin changed tariff for user to', admin_id=admin.id, user_id=user_id, tariff_name=tariff.name) return UpdateSubscriptionResponse( success=True, @@ -1071,7 +1077,7 @@ async def update_user_subscription( # Sync to Remnawave panel await _sync_subscription_to_panel(db, user, subscription) - logger.info(f'Admin {admin.id} updated traffic for user {user_id}') + logger.info('Admin updated traffic for user', admin_id=admin.id, user_id=user_id) return UpdateSubscriptionResponse( success=True, @@ -1091,7 +1097,7 @@ async def update_user_subscription( await db.refresh(subscription) state = 'enabled' if request.autopay_enabled else 'disabled' - logger.info(f'Admin {admin.id} {state} autopay for user {user_id}') + logger.info('Admin autopay for user', admin_id=admin.id, state=state, user_id=user_id) return UpdateSubscriptionResponse( success=True, @@ -1108,7 +1114,7 @@ async def update_user_subscription( # Sync to Remnawave panel await _sync_subscription_to_panel(db, user, subscription) - logger.info(f'Admin {admin.id} cancelled subscription for user {user_id}') + logger.info('Admin cancelled subscription for user', admin_id=admin.id, user_id=user_id) return UpdateSubscriptionResponse( success=True, @@ -1127,7 +1133,7 @@ async def update_user_subscription( # Sync to Remnawave panel await _sync_subscription_to_panel(db, user, subscription) - logger.info(f'Admin {admin.id} activated subscription for user {user_id}') + logger.info('Admin activated subscription for user', admin_id=admin.id, user_id=user_id) return UpdateSubscriptionResponse( success=True, @@ -1151,7 +1157,7 @@ async def update_user_subscription( # Sync to Remnawave panel await _sync_subscription_to_panel(db, user, subscription) - logger.info(f'Admin {admin.id} added {request.traffic_gb} GB traffic for user {user_id}') + logger.info('Admin added traffic for user', admin_id=admin.id, traffic_gb=request.traffic_gb, user_id=user_id) return UpdateSubscriptionResponse( success=True, @@ -1211,7 +1217,11 @@ async def update_user_subscription( await _sync_subscription_to_panel(db, user, subscription) logger.info( - f'Admin {admin.id} removed traffic purchase {request.traffic_purchase_id} ({removed_gb} GB) for user {user_id}' + 'Admin removed traffic purchase ( GB) for user', + admin_id=admin.id, + traffic_purchase_id=request.traffic_purchase_id, + removed_gb=removed_gb, + user_id=user_id, ) return UpdateSubscriptionResponse( @@ -1234,7 +1244,9 @@ async def update_user_subscription( # Sync to Remnawave panel await _sync_subscription_to_panel(db, user, subscription) - logger.info(f'Admin {admin.id} set device limit to {request.device_limit} for user {user_id}') + logger.info( + 'Admin set device limit to for user', admin_id=admin.id, device_limit=request.device_limit, user_id=user_id + ) return UpdateSubscriptionResponse( success=True, @@ -1384,7 +1396,7 @@ async def update_user_status( if request.reason: action += f' (reason: {request.reason})' - logger.info(f'Admin {admin.id} changed status for user {user_id}: {action}') + logger.info('Admin changed status for user', admin_id=admin.id, user_id=user_id, action=action) return UpdateUserStatusResponse( success=True, @@ -1449,8 +1461,11 @@ async def update_user_restrictions( await db.refresh(user) logger.info( - f'Admin {admin.id} updated restrictions for user {user_id}: ' - f'topup={user.restriction_topup}, subscription={user.restriction_subscription}' + 'Admin updated restrictions for user topup=, subscription', + admin_id=admin.id, + user_id=user_id, + restriction_topup=user.restriction_topup, + restriction_subscription=user.restriction_subscription, ) return UpdateRestrictionsResponse( @@ -1501,7 +1516,11 @@ async def update_user_promo_group( await db.refresh(user) logger.info( - f'Admin {admin.id} changed promo group for user {user_id}: {old_promo_group_id} -> {new_promo_group_id}' + 'Admin changed promo group for user', + admin_id=admin.id, + user_id=user_id, + old_promo_group_id=old_promo_group_id, + new_promo_group_id=new_promo_group_id, ) return UpdatePromoGroupResponse( @@ -1537,7 +1556,11 @@ async def update_user_referral_commission( await db.commit() logger.info( - f'Admin {admin.id} changed referral commission for user {user_id}: {old_commission} -> {request.commission_percent}' + 'Admin changed referral commission for user', + admin_id=admin.id, + user_id=user_id, + old_commission=old_commission, + commission_percent=request.commission_percent, ) return UpdateReferralCommissionResponse( @@ -1600,7 +1623,7 @@ async def get_user_devices( ) except Exception as e: - logger.error(f'Error fetching devices for user {user_id}: {e}') + logger.error('Error fetching devices for user', user_id=user_id, error=e) return UserDevicesResponse() @@ -1627,12 +1650,12 @@ async def delete_user_device( success = await api.remove_device(user.remnawave_uuid, hwid) if success: - logger.info(f'Admin {admin.id} deleted device {hwid} for user {user_id}') + logger.info('Admin deleted device for user', admin_id=admin.id, hwid=hwid, user_id=user_id) return DeleteDeviceResponse(success=True, message='Device deleted', deleted_hwid=hwid) return DeleteDeviceResponse(success=False, message='Failed to delete device') except Exception as e: - logger.error(f'Error deleting device {hwid} for user {user_id}: {e}') + logger.error('Error deleting device for user', hwid=hwid, user_id=user_id, error=e) return DeleteDeviceResponse(success=False, message=str(e)) @@ -1672,11 +1695,11 @@ async def reset_user_devices( except Exception: pass - logger.info(f'Admin {admin.id} reset devices for user {user_id}: {deleted}/{total}') + logger.info('Admin reset devices for user /', admin_id=admin.id, user_id=user_id, deleted=deleted, total=total) return ResetDevicesResponse(success=True, message=f'Deleted {deleted}/{total} devices', deleted_count=deleted) except Exception as e: - logger.error(f'Error resetting devices for user {user_id}: {e}') + logger.error('Error resetting devices for user', user_id=user_id, error=e) return ResetDevicesResponse(success=False, message=str(e)) @@ -1713,7 +1736,7 @@ async def delete_user( action = 'permanently deleted' reason_text = f' (reason: {request.reason})' if request.reason else '' - logger.info(f'Admin {admin.id} {action} user {user_id}{reason_text}') + logger.info('Admin user', admin_id=admin.id, action=action, user_id=user_id, reason_text=reason_text) return DeleteUserResponse( success=True, @@ -1756,7 +1779,7 @@ async def full_delete_user( deleted_from_panel = request.delete_from_panel and user.remnawave_uuid is not None reason_text = f' (reason: {request.reason})' if request.reason else '' - logger.info(f'Admin {admin.id} fully deleted user {user_id}{reason_text}') + logger.info('Admin fully deleted user', admin_id=admin.id, user_id=user_id, reason_text=reason_text) return FullDeleteUserResponse( success=success, @@ -1800,9 +1823,9 @@ async def reset_user_trial( subscription_service = SubscriptionService() await subscription_service.disable_remnawave_user(user.remnawave_uuid) - logger.info(f'Disabled Remnawave user {user.remnawave_uuid} for trial reset') + logger.info('Disabled Remnawave user for trial reset', remnawave_uuid=user.remnawave_uuid) except Exception as e: - logger.warning(f'Failed to disable Remnawave user during trial reset: {e}') + logger.warning('Failed to disable Remnawave user during trial reset', error=e) # Delete subscription from database from sqlalchemy import delete @@ -1819,7 +1842,7 @@ async def reset_user_trial( await db.commit() reason_text = f' (reason: {request.reason})' if request.reason else '' - logger.info(f'Admin {admin.id} reset trial for user {user_id}{reason_text}') + logger.info('Admin reset trial for user', admin_id=admin.id, user_id=user_id, reason_text=reason_text) return ResetTrialResponse( success=True, @@ -1871,10 +1894,10 @@ async def reset_user_subscription( subscription_service = SubscriptionService() panel_deactivated = await subscription_service.disable_remnawave_user(user.remnawave_uuid) if panel_deactivated: - logger.info(f'Disabled Remnawave user {user.remnawave_uuid} for subscription reset') + logger.info('Disabled Remnawave user for subscription reset', remnawave_uuid=user.remnawave_uuid) except Exception as e: panel_error = str(e) - logger.warning(f'Failed to disable Remnawave user during subscription reset: {e}') + logger.warning('Failed to disable Remnawave user during subscription reset', error=e) # Delete subscription from database from sqlalchemy import delete @@ -1888,7 +1911,7 @@ async def reset_user_subscription( await db.commit() reason_text = f' (reason: {request.reason})' if request.reason else '' - logger.info(f'Admin {admin.id} reset subscription for user {user_id}{reason_text}') + logger.info('Admin reset subscription for user', admin_id=admin.id, user_id=user_id, reason_text=reason_text) return ResetSubscriptionResponse( success=True, @@ -1933,10 +1956,10 @@ async def disable_user( subscription_service = SubscriptionService() panel_deactivated = await subscription_service.disable_remnawave_user(user.remnawave_uuid) if panel_deactivated: - logger.info(f'Disabled Remnawave user {user.remnawave_uuid}') + logger.info('Disabled Remnawave user', remnawave_uuid=user.remnawave_uuid) except Exception as e: panel_error = str(e) - logger.warning(f'Failed to disable Remnawave user: {e}') + logger.warning('Failed to disable Remnawave user', error=e) # Deactivate subscription in bot database if user.subscription: @@ -1944,7 +1967,7 @@ async def disable_user( await deactivate_subscription(db, user.subscription) subscription_deactivated = True - logger.info(f'Deactivated subscription for user {user_id}') + logger.info('Deactivated subscription for user', user_id=user_id) # Block user account user.status = UserStatus.BLOCKED.value @@ -1952,7 +1975,7 @@ async def disable_user( await db.commit() reason_text = f' (reason: {request.reason})' if request.reason else '' - logger.info(f'Admin {admin.id} disabled user {user_id}{reason_text}') + logger.info('Admin disabled user', admin_id=admin.id, user_id=user_id, reason_text=reason_text) return DisableUserResponse( success=True, @@ -2199,7 +2222,7 @@ async def get_user_sync_status( differences.append(f'Squads mismatch ({", ".join(squad_diff_parts)})') except Exception as e: - logger.warning(f'Failed to get panel data for user {user_id}: {e}') + logger.warning('Failed to get panel data for user', user_id=user_id, error=e) differences.append(f'Error fetching panel data: {e!s}') return PanelSyncStatusResponse( @@ -2409,7 +2432,9 @@ async def sync_user_from_panel( await db.commit() - logger.info(f'Admin {admin.id} synced user {user_id} from panel. Changes: {list(changes.keys())}') + logger.info( + 'Admin synced user from panel. Changes', admin_id=admin.id, user_id=user_id, value=list(changes.keys()) + ) return SyncFromPanelResponse( success=True, @@ -2422,7 +2447,7 @@ async def sync_user_from_panel( except HTTPException: raise except Exception as e: - logger.error(f'Error syncing user {user_id} from panel: {e}') + logger.error('Error syncing user from panel', user_id=user_id, error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Sync error: {e!s}', @@ -2510,7 +2535,7 @@ async def sync_user_to_panel( if panel_uuid: existing_user = await api.get_user_by_uuid(panel_uuid) if not existing_user: - logger.warning(f'User {user.id} has stale remnawave_uuid {panel_uuid}, clearing') + logger.warning('User has stale remnawave_uuid clearing', user_id=user.id, panel_uuid=panel_uuid) panel_uuid = None user.remnawave_uuid = None @@ -2600,7 +2625,7 @@ async def sync_user_to_panel( await db.commit() - logger.info(f'Admin {admin.id} synced user {user_id} to panel. Action: {action}') + logger.info('Admin synced user to panel. Action', admin_id=admin.id, user_id=user_id, action=action) return SyncToPanelResponse( success=True, @@ -2614,7 +2639,7 @@ async def sync_user_to_panel( except HTTPException: raise except Exception as e: - logger.error(f'Error syncing user {user_id} to panel: {e}') + logger.error('Error syncing user to panel', user_id=user_id, error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Sync error: {e!s}', diff --git a/app/cabinet/routes/admin_wheel.py b/app/cabinet/routes/admin_wheel.py index e137a54f..bbef73ec 100644 --- a/app/cabinet/routes/admin_wheel.py +++ b/app/cabinet/routes/admin_wheel.py @@ -2,10 +2,10 @@ API роуты колеса удачи для администраторов. """ -import logging import math from datetime import datetime +import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession @@ -35,7 +35,7 @@ from app.database.models import User from app.services.wheel_service import wheel_service -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/wheel', tags=['Admin Fortune Wheel']) @@ -107,7 +107,7 @@ async def update_admin_wheel_config( config = await update_wheel_config(db, **update_data) - logger.info(f'🎡 Admin {admin.telegram_id} updated wheel config: {update_data}') + logger.info('🎡 Admin updated wheel config', telegram_id=admin.telegram_id, update_data=update_data) # Возвращаем полную конфигурацию prizes = await get_wheel_prizes(db, config.id, active_only=False) @@ -211,7 +211,7 @@ async def create_prize( promo_traffic_gb=request.promo_traffic_gb, ) - logger.info(f'🎁 Admin {admin.telegram_id} created prize: {prize.display_name}') + logger.info('🎁 Admin created prize', telegram_id=admin.telegram_id, display_name=prize.display_name) return WheelPrizeAdminResponse( id=prize.id, @@ -261,7 +261,7 @@ async def update_prize( detail='Prize not found', ) - logger.info(f'🎁 Admin {admin.telegram_id} updated prize {prize_id}: {update_data}') + logger.info('🎁 Admin updated prize', telegram_id=admin.telegram_id, prize_id=prize_id, update_data=update_data) return WheelPrizeAdminResponse( id=prize.id, @@ -298,7 +298,7 @@ async def delete_prize_endpoint( detail='Prize not found', ) - logger.info(f'🗑️ Admin {admin.telegram_id} deleted prize {prize_id}') + logger.info('🗑️ Admin deleted prize', telegram_id=admin.telegram_id, prize_id=prize_id) @router.post('/prizes/reorder', status_code=status.HTTP_200_OK) @@ -309,7 +309,7 @@ async def reorder_prizes( ): """Переупорядочить призы.""" await reorder_wheel_prizes(db, request.prize_ids) - logger.info(f'🔄 Admin {admin.telegram_id} reordered prizes: {request.prize_ids}') + logger.info('🔄 Admin reordered prizes', telegram_id=admin.telegram_id, prize_ids=request.prize_ids) return {'success': True} diff --git a/app/cabinet/routes/auth.py b/app/cabinet/routes/auth.py index db84eb1d..e09169fb 100644 --- a/app/cabinet/routes/auth.py +++ b/app/cabinet/routes/auth.py @@ -2,9 +2,9 @@ import asyncio import hashlib -import logging from datetime import UTC, datetime +import structlog from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.exc import IntegrityError @@ -69,7 +69,7 @@ from ..services.email_service import email_service from ..services.email_template_overrides import get_rendered_override -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/auth', tags=['Cabinet Auth']) @@ -158,12 +158,12 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) - panel_users = await api.get_user_by_email(user.email) if not panel_users: - logger.debug(f'No subscription found in panel for email: {user.email}') + logger.debug('No subscription found in panel for email', email=user.email) return # Take first user if multiple found panel_user = panel_users[0] - logger.info(f'Found subscription in panel for email {user.email}: {panel_user.uuid}') + logger.info('Found subscription in panel for email', email=user.email, uuid=panel_user.uuid) # Link user to panel user.remnawave_uuid = panel_user.uuid @@ -208,7 +208,10 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) - existing_sub.device_limit = device_limit existing_sub.is_trial = False # Panel subscription is not trial logger.info( - f'Updated subscription for email user {user.email}, squads: {connected_squads}, devices: {device_limit}' + 'Updated subscription for email user squads: devices', + email=user.email, + connected_squads=connected_squads, + device_limit=device_limit, ) else: # Create new subscription (expire_at and current_time already naive UTC) @@ -228,13 +231,16 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) - ) db.add(new_sub) logger.info( - f'Created subscription for email user {user.email}, squads: {connected_squads}, devices: {device_limit}' + 'Created subscription for email user squads: devices', + email=user.email, + connected_squads=connected_squads, + device_limit=device_limit, ) await db.commit() except Exception as e: - logger.warning(f'Failed to sync subscription from panel for {user.email}: {e}') + logger.warning('Failed to sync subscription from panel for', email=user.email, error=e) # Don't rollback - it detaches user object and breaks subsequent operations # The sync is non-critical, main verification already succeeded @@ -275,7 +281,7 @@ async def auth_telegram( if not user: # Create new user from Telegram initData - logger.info(f'Creating new user from cabinet (initData): telegram_id={telegram_id}') + logger.info('Creating new user from cabinet (initData): telegram_id', telegram_id=telegram_id) user = await create_user( db=db, telegram_id=telegram_id, @@ -284,7 +290,7 @@ async def auth_telegram( last_name=tg_last_name, language=tg_language, ) - logger.info(f'User created successfully: id={user.id}, telegram_id={user.telegram_id}') + logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id) else: # Update user info from initData (like bot middleware does) updated = False @@ -298,7 +304,7 @@ async def auth_telegram( user.last_name = tg_last_name updated = True if updated: - logger.info(f'User {user.id} profile updated from initData') + logger.info('User profile updated from initData', user_id=user.id) if user.status != 'active': raise HTTPException( @@ -341,7 +347,9 @@ async def auth_telegram_widget( if not user: # Create new user from Telegram data - logger.info(f'Creating new user from cabinet: telegram_id={request.id}, username={request.username}') + logger.info( + 'Creating new user from cabinet: telegram_id=, username', request_id=request.id, username=request.username + ) user = await create_user( db=db, telegram_id=request.id, @@ -350,7 +358,7 @@ async def auth_telegram_widget( last_name=request.last_name, language='ru', ) - logger.info(f'User created successfully: id={user.id}, telegram_id={user.telegram_id}') + logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id) if user.status != 'active': raise HTTPException( @@ -485,7 +493,7 @@ async def register_email_standalone( status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid test email password', ) - logger.info(f'Test email registration: {request.email}') + logger.info('Test email registration', email=request.email) # Check for disposable email if disposable_email_service.is_disposable(request.email): @@ -512,11 +520,17 @@ async def register_email_standalone( if referrer: # Защита от самореферала - нельзя регистрироваться по своему же коду if referrer.email and referrer.email.lower() == request.email.lower(): - logger.warning(f'Self-referral attempt blocked: email={request.email}, code={request.referral_code}') + logger.warning( + 'Self-referral attempt blocked: email=, code', + email=request.email, + referral_code=request.referral_code, + ) referrer = None else: logger.info( - f'Found referrer for email registration: referrer_id={referrer.id}, code={request.referral_code}' + 'Found referrer for email registration: referrer_id=, code', + referrer_id=referrer.id, + referral_code=request.referral_code, ) # Создать пользователя @@ -534,7 +548,7 @@ async def register_email_standalone( user.email_verified = True user.email_verified_at = datetime.utcnow() await db.commit() - logger.info(f'Test email auto-verified: {request.email}, user_id={user.id}') + logger.info('Test email auto-verified: user_id', email=request.email, user_id=user.id) else: # Сгенерировать токен верификации verification_token = generate_verification_token() @@ -579,9 +593,11 @@ async def register_email_standalone( if referrer: try: await process_referral_registration(db, user.id, referrer.id, bot=None) - logger.info(f'Processed referral registration: user_id={user.id}, referrer_id={referrer.id}') + logger.info( + 'Processed referral registration: user_id=, referrer_id', user_id=user.id, referrer_id=referrer.id + ) except Exception as e: - logger.error(f'Failed to process referral registration: {e}') + logger.error('Failed to process referral registration', error=e) # Не прерываем регистрацию из-за ошибки реферальной системы # Для тестового email - сразу можно логиниться (уже verified) @@ -724,7 +740,7 @@ async def login_email( if not user: # For test email - auto-create user if not exists if is_test_email and settings.validate_test_email_password(request.email, request.password): - logger.info(f'Test email login - creating new user: {request.email}') + logger.info('Test email login creating new user', email=request.email) password_hash = hash_password(request.password) user = await create_user_by_email( db=db, @@ -1051,9 +1067,16 @@ async def request_email_change( custom_body_html=custom_body, ) except Exception as e: - logger.error(f'Failed to send verification email to {request.new_email} for user {user.id}: {e}') + logger.error( + 'Failed to send verification email to for user', + new_email=request.new_email, + user_id=user.id, + error=e, + ) - logger.info(f'Unverified email replaced for user {user.id}: {old_email} -> {request.new_email}') + logger.info( + 'Unverified email replaced for user', user_id=user.id, old_email=old_email, new_email=request.new_email + ) return EmailChangeResponse( message='Email replaced, verification sent to new address', @@ -1104,7 +1127,7 @@ async def request_email_change( detail='Email service is not configured', ) - logger.info(f'Email change requested for user {user.id}: {user.email} -> {request.new_email}') + logger.info('Email change requested for user', user_id=user.id, email=user.email, new_email=request.new_email) return EmailChangeResponse( message='Verification code sent to new email', diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index 4c109ceb..19d248b8 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -1,11 +1,11 @@ """Balance and payment routes for cabinet.""" -import logging import math import time from decimal import ROUND_HALF_UP, Decimal, InvalidOperation import httpx +import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import desc, func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -41,7 +41,7 @@ from ..schemas.balance import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/balance', tags=['Cabinet Balance']) @@ -249,7 +249,7 @@ async def create_stars_invoice( if stars_amount <= 0: stars_amount = 1 except Exception as e: - logger.error(f'Error calculating Stars amount: {e}') + logger.error('Error calculating Stars amount', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to calculate Stars amount', @@ -279,7 +279,7 @@ async def create_stars_invoice( result = response.json() if not result.get('ok'): - logger.error(f'Telegram API error: {result}') + logger.error('Telegram API error', result=result) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to create Stars invoice', @@ -287,8 +287,10 @@ async def create_stars_invoice( invoice_url = result['result'] logger.info( - f'Created Stars invoice for balance top-up: user={user.id}, ' - f'amount={request.amount_kopeks} kopeks, stars={stars_amount}' + 'Created Stars invoice for balance top-up: user=, amount= kopeks, stars', + user_id=user.id, + amount_kopeks=request.amount_kopeks, + stars_amount=stars_amount, ) return StarsInvoiceResponse( @@ -298,7 +300,7 @@ async def create_stars_invoice( ) except httpx.HTTPError as e: - logger.error(f'HTTP error creating Stars invoice: {e}') + logger.error('HTTP error creating Stars invoice', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to connect to Telegram API', @@ -710,7 +712,7 @@ async def create_topup( except HTTPException: raise except Exception as e: - logger.error(f'Payment creation error: {e}') + logger.error('Payment creation error', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to create payment. Please try again later.', diff --git a/app/cabinet/routes/branding.py b/app/cabinet/routes/branding.py index 21fd9333..c36875a4 100644 --- a/app/cabinet/routes/branding.py +++ b/app/cabinet/routes/branding.py @@ -1,10 +1,10 @@ """Branding routes for cabinet - logo, project name, and theme colors management.""" import json -import logging import os from pathlib import Path +import structlog from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi.responses import FileResponse from pydantic import BaseModel @@ -17,7 +17,7 @@ from app.database.models import SystemSetting, User from ..dependencies import get_cabinet_db, get_current_admin_user -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/branding', tags=['Branding']) @@ -307,7 +307,7 @@ async def update_branding_name( await set_setting_value(db, BRANDING_NAME_KEY, name) - logger.info(f'Admin {admin.telegram_id} updated branding name to: {name}') + logger.info('Admin updated branding name to', telegram_id=admin.telegram_id, name=name) # Return updated branding custom_logo = has_custom_logo() @@ -368,7 +368,7 @@ async def upload_logo( # Mark that we have a custom logo await set_setting_value(db, BRANDING_LOGO_KEY, 'custom') - logger.info(f'Admin {admin.telegram_id} uploaded new logo: {logo_path}') + logger.info('Admin uploaded new logo', telegram_id=admin.telegram_id, logo_path=logo_path) # Get current name for response name = await get_setting_value(db, BRANDING_NAME_KEY) @@ -398,7 +398,7 @@ async def delete_logo( # Update setting await set_setting_value(db, BRANDING_LOGO_KEY, 'default') - logger.info(f'Admin {admin.telegram_id} deleted custom logo') + logger.info('Admin deleted custom logo', telegram_id=admin.telegram_id) # Get current name for response name = await get_setting_value(db, BRANDING_NAME_KEY) @@ -486,7 +486,7 @@ async def update_theme_colors( # Save to database await set_setting_value(db, THEME_COLORS_KEY, json.dumps(current_colors)) - logger.info(f'Admin {admin.telegram_id} updated theme colors: {list(update_data.keys())}') + logger.info('Admin updated theme colors', telegram_id=admin.telegram_id, value=list(update_data.keys())) return ThemeColorsResponse(**current_colors) @@ -500,7 +500,7 @@ async def reset_theme_colors( # Save default colors await set_setting_value(db, THEME_COLORS_KEY, json.dumps(DEFAULT_THEME_COLORS)) - logger.info(f'Admin {admin.telegram_id} reset theme colors to defaults') + logger.info('Admin reset theme colors to defaults', telegram_id=admin.telegram_id) return ThemeColorsResponse(**DEFAULT_THEME_COLORS) @@ -558,7 +558,7 @@ async def update_enabled_themes( # Save to database await set_setting_value(db, ENABLED_THEMES_KEY, json.dumps(current_themes)) - logger.info(f'Admin {admin.telegram_id} updated enabled themes: {current_themes}') + logger.info('Admin updated enabled themes', telegram_id=admin.telegram_id, current_themes=current_themes) return EnabledThemesResponse(**current_themes) @@ -593,7 +593,7 @@ async def update_animation_enabled( """Update animation enabled setting. Admin only.""" await set_setting_value(db, ANIMATION_ENABLED_KEY, str(payload.enabled).lower()) - logger.info(f'Admin {admin.telegram_id} set animation enabled: {payload.enabled}') + logger.info('Admin set animation enabled', telegram_id=admin.telegram_id, enabled=payload.enabled) return AnimationEnabledResponse(enabled=payload.enabled) @@ -628,7 +628,7 @@ async def update_fullscreen_enabled( """Update fullscreen enabled setting. Admin only.""" await set_setting_value(db, FULLSCREEN_ENABLED_KEY, str(payload.enabled).lower()) - logger.info(f'Admin {admin.telegram_id} set fullscreen enabled: {payload.enabled}') + logger.info('Admin set fullscreen enabled', telegram_id=admin.telegram_id, enabled=payload.enabled) return FullscreenEnabledResponse(enabled=payload.enabled) @@ -664,7 +664,7 @@ async def update_email_auth_enabled( """Update email auth enabled setting. Admin only.""" await set_setting_value(db, EMAIL_AUTH_ENABLED_KEY, str(payload.enabled).lower()) - logger.info(f'Admin {admin.telegram_id} set email auth enabled: {payload.enabled}') + logger.info('Admin set email auth enabled', telegram_id=admin.telegram_id, enabled=payload.enabled) return EmailAuthEnabledResponse(enabled=payload.enabled) @@ -719,7 +719,7 @@ async def update_analytics_counters( if payload.google_ads_label is not None: await set_setting_value(db, GOOGLE_ADS_LABEL_KEY, payload.google_ads_label.strip()) - logger.info(f'Admin {admin.telegram_id} updated analytics counters') + logger.info('Admin updated analytics counters', telegram_id=admin.telegram_id) # Return current state yandex_id = await get_setting_value(db, YANDEX_METRIKA_ID_KEY) or '' @@ -764,6 +764,6 @@ async def update_lite_mode_enabled( """Update lite mode enabled setting. Admin only.""" await set_setting_value(db, LITE_MODE_ENABLED_KEY, str(payload.enabled).lower()) - logger.info(f'Admin {admin.telegram_id} set lite mode enabled: {payload.enabled}') + logger.info('Admin set lite mode enabled', telegram_id=admin.telegram_id, enabled=payload.enabled) return LiteModeEnabledResponse(enabled=payload.enabled) diff --git a/app/cabinet/routes/contests.py b/app/cabinet/routes/contests.py index f7f865ef..1600e555 100644 --- a/app/cabinet/routes/contests.py +++ b/app/cabinet/routes/contests.py @@ -1,10 +1,10 @@ """Contests routes for cabinet - user participation in games/contests.""" -import logging import random from datetime import datetime, timedelta from typing import Any +import structlog from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -30,7 +30,7 @@ from app.services.contest_rotation_service import ( from ..dependencies import get_cabinet_db, get_current_cabinet_user -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/contests', tags=['Cabinet Contests']) @@ -106,7 +106,7 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va await db.commit() await db.refresh(subscription) - logger.info(f'🎁 Extended subscription for user {user_id} by {days} days (contest prize)') + logger.info('🎁 Extended subscription for user by days (contest prize)', user_id=user_id, days=days) return f'Subscription extended by {days} days' if prize_type == 'balance': @@ -125,10 +125,10 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va await db.commit() await db.refresh(user) - logger.info(f'🎁 Added {amount} to balance for user {user_id} (contest prize)') + logger.info('🎁 Added to balance for user (contest prize)', amount=amount, user_id=user_id) return f'Balance increased by {amount}' - logger.warning(f'Unknown prize type: {prize_type}') + logger.warning('Unknown prize type', prize_type=prize_type) return f"Prize type '{prize_type}' not supported" diff --git a/app/cabinet/routes/info.py b/app/cabinet/routes/info.py index 16b9b037..68b89cac 100644 --- a/app/cabinet/routes/info.py +++ b/app/cabinet/routes/info.py @@ -1,7 +1,6 @@ """Info pages routes for cabinet - FAQ, rules, privacy policy, etc.""" -import logging - +import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -16,7 +15,7 @@ from app.services.public_offer_service import PublicOfferService from ..dependencies import get_cabinet_db, get_current_cabinet_user -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/info', tags=['Cabinet Info']) diff --git a/app/cabinet/routes/media.py b/app/cabinet/routes/media.py index ed137992..4cc99427 100644 --- a/app/cabinet/routes/media.py +++ b/app/cabinet/routes/media.py @@ -1,8 +1,8 @@ """Media upload/download routes for cabinet tickets.""" -import logging import mimetypes +import structlog from aiogram import Bot from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode @@ -16,7 +16,7 @@ from app.database.models import User from ..dependencies import get_current_cabinet_user -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/media', tags=['Cabinet Media']) @@ -125,7 +125,12 @@ async def upload_media( media_url = _build_media_url(request, media.file_id) - logger.info(f'User {user.telegram_id} uploaded {media_type_normalized}: {media.file_id}') + logger.info( + 'User uploaded', + telegram_id=user.telegram_id, + media_type_normalized=media_type_normalized, + file_id=media.file_id, + ) return MediaUploadResponse( media_type=media_type_normalized, @@ -136,7 +141,7 @@ async def upload_media( except HTTPException: raise except Exception as error: - logger.error(f'Failed to upload media for user {user.telegram_id}: {error}') + logger.error('Failed to upload media for user', telegram_id=user.telegram_id, error=error) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to upload media', @@ -187,7 +192,7 @@ async def download_media( except HTTPException: raise except Exception as error: - logger.error(f'Failed to download media {file_id}: {error}') + logger.error('Failed to download media', file_id=file_id, error=error) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to download media', diff --git a/app/cabinet/routes/notifications.py b/app/cabinet/routes/notifications.py index c2d4bdd6..6da075a0 100644 --- a/app/cabinet/routes/notifications.py +++ b/app/cabinet/routes/notifications.py @@ -1,9 +1,9 @@ """Notification settings routes for cabinet.""" -import logging from datetime import datetime from typing import Any +import structlog from fastapi import APIRouter, Depends from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -13,7 +13,7 @@ from app.database.models import User from ..dependencies import get_cabinet_db, get_current_cabinet_user -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/notifications', tags=['Cabinet Notifications']) diff --git a/app/cabinet/routes/oauth.py b/app/cabinet/routes/oauth.py index 049dc8d7..46f3e1c0 100644 --- a/app/cabinet/routes/oauth.py +++ b/app/cabinet/routes/oauth.py @@ -1,8 +1,8 @@ """OAuth 2.0 authentication routes for cabinet.""" -import logging from datetime import UTC, datetime +import structlog from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -27,7 +27,7 @@ from ..schemas.auth import AuthResponse from .auth import _create_auth_response, _store_refresh_token -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth']) @@ -120,7 +120,7 @@ async def oauth_callback( try: token_data = await oauth_provider.exchange_code(request.code) except Exception as exc: - logger.error('OAuth code exchange failed for %s: %s', provider, exc) + logger.error('OAuth code exchange failed for', provider=provider, exc=exc) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='Failed to exchange authorization code', @@ -130,7 +130,7 @@ async def oauth_callback( try: user_info: OAuthUserInfo = await oauth_provider.get_user_info(token_data) except Exception as exc: - logger.error('OAuth user info fetch failed for %s: %s', provider, exc) + logger.error('OAuth user info fetch failed for', provider=provider, exc=exc) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='Failed to fetch user information from provider', @@ -139,7 +139,7 @@ async def oauth_callback( # 5. Find user by provider ID user = await get_user_by_oauth_provider(db, provider, user_info.provider_id) if user: - logger.info('OAuth login via %s for existing user %s', provider, user.id) + logger.info('OAuth login via for existing user', provider=provider, user_id=user.id) return await _finalize_oauth_login(db, user, provider) # 6. Find user by email (if verified) and link provider @@ -147,7 +147,7 @@ async def oauth_callback( user = await get_user_by_email(db, user_info.email) if user: await set_user_oauth_provider_id(db, user, provider, user_info.provider_id) - logger.info('OAuth login via %s linked to existing email user %s', provider, user.id) + logger.info('OAuth login via linked to existing email user', provider=provider, user_id=user.id) return await _finalize_oauth_login(db, user, provider) # 7. Create new user @@ -161,5 +161,5 @@ async def oauth_callback( last_name=user_info.last_name, username=user_info.username, ) - logger.info('OAuth new user created via %s with id=%s', provider, user.id) + logger.info('OAuth new user created via with id', provider=provider, user_id=user.id) return await _finalize_oauth_login(db, user, provider) diff --git a/app/cabinet/routes/polls.py b/app/cabinet/routes/polls.py index 1585ef61..c66e9ae3 100644 --- a/app/cabinet/routes/polls.py +++ b/app/cabinet/routes/polls.py @@ -1,8 +1,8 @@ """Polls routes for cabinet - user participation in polls/surveys.""" -import logging from datetime import datetime +import structlog from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy import select @@ -20,7 +20,7 @@ from app.services.poll_service import get_next_question, get_question_option, re from ..dependencies import get_cabinet_db, get_current_cabinet_user -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/polls', tags=['Cabinet Polls']) diff --git a/app/cabinet/routes/promo.py b/app/cabinet/routes/promo.py index df213b53..d3ca3994 100644 --- a/app/cabinet/routes/promo.py +++ b/app/cabinet/routes/promo.py @@ -1,9 +1,9 @@ """Promo offers routes for cabinet - personal discounts and offers.""" -import logging from datetime import datetime, timedelta from typing import Any +import structlog from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy import and_, select @@ -22,7 +22,7 @@ from app.services.promo_offer_service import promo_offer_service from ..dependencies import get_cabinet_db, get_current_cabinet_user -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/promo', tags=['Cabinet Promo']) diff --git a/app/cabinet/routes/promocode.py b/app/cabinet/routes/promocode.py index 5b6a5ab0..3d83730e 100644 --- a/app/cabinet/routes/promocode.py +++ b/app/cabinet/routes/promocode.py @@ -1,7 +1,6 @@ """Promo code routes for cabinet.""" -import logging - +import structlog from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -12,7 +11,7 @@ from app.services.promocode_service import PromoCodeService from ..dependencies import get_cabinet_db, get_current_cabinet_user -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/promocode', tags=['Cabinet Promocode']) diff --git a/app/cabinet/routes/referral.py b/app/cabinet/routes/referral.py index 43ef4c4e..277cc675 100644 --- a/app/cabinet/routes/referral.py +++ b/app/cabinet/routes/referral.py @@ -1,8 +1,8 @@ """Referral program routes for cabinet.""" -import logging import math +import structlog from fastapi import APIRouter, Depends, Query from sqlalchemy import desc, func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -22,7 +22,7 @@ from ..schemas.referral import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/referral', tags=['Cabinet Referral']) diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index 8f879b10..7655fa79 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -2,11 +2,11 @@ import base64 import json -import logging import re from datetime import UTC, datetime, timedelta from typing import Any +import structlog from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -58,7 +58,7 @@ from ..schemas.subscription import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/subscription', tags=['Cabinet Subscription']) @@ -490,9 +490,9 @@ async def renew_subscription( try: await user_cart_service.save_user_cart(user.id, cart_data) - logger.info(f'Cart saved for auto-renewal (cabinet) user {user.id}') + logger.info('Cart saved for auto-renewal (cabinet) user', user_id=user.id) except Exception as e: - logger.error(f'Error saving cart for auto-renewal (cabinet): {e}') + logger.error('Error saving cart for auto-renewal (cabinet)', error=e) raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, @@ -550,7 +550,7 @@ async def renew_subscription( finally: await bot.session.close() except Exception as e: - logger.error(f'Failed to send admin notification for subscription renewal: {e}') + logger.error('Failed to send admin notification for subscription renewal', error=e) response = { 'message': 'Subscription renewed successfully', @@ -789,11 +789,13 @@ async def purchase_traffic( try: await user_cart_service.save_user_cart(user.id, cart_data) logger.info( - f'Cart saved for traffic purchase (cabinet) user {user.id}: ' - f'+{request.gb} GB, discount {traffic_discount_percent}%' + 'Cart saved for traffic purchase (cabinet) user + discount', + user_id=user.id, + gb=request.gb, + traffic_discount_percent=traffic_discount_percent, ) except Exception as e: - logger.error(f'Error saving cart for traffic purchase (cabinet): {e}') + logger.error('Error saving cart for traffic purchase (cabinet)', error=e) raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, @@ -833,7 +835,11 @@ async def purchase_traffic( from datetime import timedelta subscription.traffic_reset_at = datetime.utcnow() + timedelta(days=30) - logger.info(f'Set traffic_reset_at for subscription {subscription.id}: {subscription.traffic_reset_at}') + logger.info( + 'Set traffic_reset_at for subscription', + subscription_id=subscription.id, + traffic_reset_at=subscription.traffic_reset_at, + ) await db.commit() @@ -845,7 +851,7 @@ async def purchase_traffic( else: await subscription_service.create_remnawave_user(db, subscription) except Exception as e: - logger.error(f'Failed to sync traffic with RemnaWave: {e}') + logger.error('Failed to sync traffic with RemnaWave', error=e) # Создаём транзакцию await create_transaction( @@ -882,7 +888,7 @@ async def purchase_traffic( finally: await bot.session.close() except Exception as e: - logger.error(f'Failed to send admin notification for traffic purchase: {e}') + logger.error('Failed to send admin notification for traffic purchase', error=e) response = { 'success': True, @@ -946,9 +952,13 @@ async def purchase_devices_legacy( 'source': 'cabinet', } await user_cart_service.save_user_cart(user.id, cart_data) - logger.info(f'Cart saved for device purchase (cabinet /devices) user {user.id}: +{request.devices} devices') + logger.info( + 'Cart saved for device purchase (cabinet /devices) user + devices', + user_id=user.id, + devices=request.devices, + ) except Exception as e: - logger.error(f'Error saving cart for device purchase (cabinet /devices): {e}') + logger.error('Error saving cart for device purchase (cabinet /devices)', error=e) raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, @@ -1020,7 +1030,7 @@ async def purchase_devices_legacy( finally: await bot.session.close() except Exception as e: - logger.error(f'Failed to send admin notification for device purchase: {e}') + logger.error('Failed to send admin notification for device purchase', error=e) response = { 'message': 'Devices added successfully', @@ -1123,7 +1133,7 @@ async def get_trial_info( if tariff_trial_days: duration_days = tariff_trial_days except Exception as e: - logger.error(f'Error getting trial tariff for info: {e}') + logger.error('Error getting trial tariff for info', error=e) # Check if user already has an active subscription if user.subscription: @@ -1211,7 +1221,7 @@ async def activate_trial( detail=f'Insufficient balance. Need {price_kopeks / 100:.2f} RUB', ) user.balance_kopeks -= price_kopeks - logger.info(f'User {user.id} paid {price_kopeks} kopeks for trial activation') + logger.info('User paid kopeks for trial activation', user_id=user.id, price_kopeks=price_kopeks) # Get trial parameters from tariff if configured (same logic as bot handler) trial_duration = settings.TRIAL_DURATION_DAYS @@ -1243,9 +1253,14 @@ async def activate_trial( tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None) if tariff_trial_days: trial_duration = tariff_trial_days - logger.info(f'Using trial tariff {trial_tariff.name} (ID: {trial_tariff.id}) with squads: {trial_squads}') + logger.info( + 'Using trial tariff (ID: ) with squads', + trial_tariff_name=trial_tariff.name, + trial_tariff_id=trial_tariff.id, + trial_squads=trial_squads, + ) except Exception as e: - logger.error(f'Error getting trial tariff: {e}') + logger.error('Error getting trial tariff', error=e) # Create trial subscription subscription = await create_trial_subscription( @@ -1258,7 +1273,7 @@ async def activate_trial( tariff_id=tariff_id_for_trial, ) - logger.info(f'Trial subscription activated for user {user.id}') + logger.info('Trial subscription activated for user', user_id=user.id) # Create RemnaWave user try: @@ -1267,7 +1282,7 @@ async def activate_trial( await subscription_service.create_remnawave_user(db, subscription) await db.refresh(subscription) except Exception as e: - logger.error(f'Failed to create RemnaWave user for trial: {e}') + logger.error('Failed to create RemnaWave user for trial', error=e) # Send admin notification about trial activation try: @@ -1286,7 +1301,7 @@ async def activate_trial( finally: await bot.session.close() except Exception as e: - logger.error(f'Failed to send trial activation notification: {e}') + logger.error('Failed to send trial activation notification', error=e) return _subscription_to_response(subscription) @@ -1551,7 +1566,7 @@ async def get_purchase_options( detail=str(e), ) except Exception as e: - logger.error(f'Failed to build purchase options for user {user.id}: {e}') + logger.error('Failed to build purchase options for user', user_id=user.id, error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to load purchase options', @@ -1596,7 +1611,7 @@ async def preview_purchase( detail=str(e), ) except Exception as e: - logger.error(f'Failed to calculate purchase preview for user {user.id}: {e}') + logger.error('Failed to calculate purchase preview for user', user_id=user.id, error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to calculate price', @@ -1658,7 +1673,7 @@ async def submit_purchase( bot=None, ) except Exception as notif_error: - logger.warning(f'Failed to send subscription notification to {user.email}: {notif_error}') + logger.warning('Failed to send subscription notification to', email=user.email, notif_error=notif_error) # Отправляем уведомление админам о покупке подписки try: @@ -1684,7 +1699,7 @@ async def submit_purchase( finally: await bot.session.close() except Exception as e: - logger.error(f'Failed to send admin notification for subscription purchase: {e}') + logger.error('Failed to send admin notification for subscription purchase', error=e) return { 'success': True, @@ -1716,9 +1731,9 @@ async def submit_purchase( 'source': 'cabinet', } await user_cart_service.save_user_cart(user.id, cart_data) - logger.info(f'Cart saved for auto-purchase (cabinet /purchase) user {user.id}') + logger.info('Cart saved for auto-purchase (cabinet /purchase) user', user_id=user.id) except Exception as cart_error: - logger.error(f'Error saving cart for auto-purchase (cabinet /purchase): {cart_error}') + logger.error('Error saving cart for auto-purchase (cabinet /purchase)', cart_error=cart_error) raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, @@ -1730,7 +1745,7 @@ async def submit_purchase( }, ) except Exception as e: - logger.error(f'Failed to submit purchase for user {user.id}: {e}') + logger.error('Failed to submit purchase for user', user_id=user.id, error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to process purchase', @@ -1912,9 +1927,9 @@ async def purchase_tariff( try: await user_cart_service.save_user_cart(user.id, cart_data) - logger.info(f'Cart saved for auto-purchase (cabinet) user {user.id}, tariff {tariff.id}') + logger.info('Cart saved for auto-purchase (cabinet) user tariff', user_id=user.id, tariff_id=tariff.id) except Exception as e: - logger.error(f'Error saving cart for auto-purchase (cabinet): {e}') + logger.error('Error saving cart for auto-purchase (cabinet)', error=e) raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, @@ -2019,7 +2034,7 @@ async def purchase_tariff( reset_reason='покупка тарифа (cabinet)', ) except Exception as remnawave_error: - logger.error(f'Failed to sync subscription with RemnaWave: {remnawave_error}') + logger.error('Failed to sync subscription with RemnaWave', remnawave_error=remnawave_error) # Save cart for auto-renewal (not for daily tariffs - they have their own charging) if not is_daily_tariff: @@ -2033,9 +2048,9 @@ async def purchase_tariff( 'description': f'Продление тарифа {tariff.name} на {period_days} дней', } await user_cart_service.save_user_cart(user.id, cart_data) - logger.info(f'Tariff cart saved for auto-renewal (cabinet) user {user.id}') + logger.info('Tariff cart saved for auto-renewal (cabinet) user', user_id=user.id) except Exception as e: - logger.error(f'Error saving tariff cart (cabinet): {e}') + logger.error('Error saving tariff cart (cabinet)', error=e) await db.refresh(user) @@ -2095,7 +2110,7 @@ async def purchase_tariff( bot=None, ) except Exception as notif_error: - logger.warning(f'Failed to send subscription notification to {user.email}: {notif_error}') + logger.warning('Failed to send subscription notification to', email=user.email, notif_error=notif_error) # Отправляем уведомление админам о покупке/продлении тарифа try: @@ -2124,14 +2139,14 @@ async def purchase_tariff( finally: await bot.session.close() except Exception as e: - logger.error(f'Failed to send admin notification for tariff purchase: {e}') + logger.error('Failed to send admin notification for tariff purchase', error=e) return response except HTTPException: raise except Exception as e: - logger.error(f'Failed to purchase tariff for user {user.id}: {e}') + logger.error('Failed to purchase tariff for user', user_id=user.id, error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to process tariff purchase', @@ -2238,11 +2253,13 @@ async def purchase_devices( } await user_cart_service.save_user_cart(user.id, cart_data) logger.info( - f'Cart saved for device purchase (cabinet) user {user.id}: ' - f'+{request.devices} devices, discount {devices_discount_percent}%' + 'Cart saved for device purchase (cabinet) user + devices, discount', + user_id=user.id, + devices=request.devices, + devices_discount_percent=devices_discount_percent, ) except Exception as e: - logger.error(f'Error saving cart for device purchase (cabinet): {e}') + logger.error('Error saving cart for device purchase (cabinet)', error=e) raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, @@ -2288,17 +2305,23 @@ async def purchase_devices( else: await service.create_remnawave_user(db, subscription) except Exception as e: - logger.error(f'Failed to sync devices with RemnaWave: {e}') + logger.error('Failed to sync devices with RemnaWave', error=e) await db.refresh(user) if devices_discount_percent > 0: logger.info( - f'User {user.id} purchased {request.devices} devices for {price_kopeks} kopeks ' - f'(discount {devices_discount_percent}%, saved {discount_value} kopeks)' + 'User purchased devices for kopeks (discount saved kopeks)', + user_id=user.id, + devices=request.devices, + price_kopeks=price_kopeks, + devices_discount_percent=devices_discount_percent, + discount_value=discount_value, ) else: - logger.info(f'User {user.id} purchased {request.devices} devices for {price_kopeks} kopeks') + logger.info( + 'User purchased devices for kopeks', user_id=user.id, devices=request.devices, price_kopeks=price_kopeks + ) # Отправляем уведомление админам try: @@ -2322,7 +2345,7 @@ async def purchase_devices( finally: await bot.session.close() except Exception as e: - logger.error(f'Failed to send admin notification for device purchase: {e}') + logger.error('Failed to send admin notification for device purchase', error=e) response = { 'success': True, @@ -2345,7 +2368,7 @@ async def purchase_devices( except HTTPException: raise except Exception as e: - logger.error(f'Failed to purchase devices for user {user.id}: {e}') + logger.error('Failed to purchase devices for user', user_id=user.id, error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Не удалось обработать покупку устройств', @@ -2463,7 +2486,7 @@ async def save_traffic_cart( 'description': f'Докупка {request.gb} ГБ трафика', } await user_cart_service.save_user_cart(user.id, cart_data) - logger.info(f'Cart saved for traffic purchase (cabinet save-cart) user {user.id}: +{request.gb} GB') + logger.info('Cart saved for traffic purchase (cabinet save-cart) user +', user_id=user.id, gb=request.gb) return {'success': True, 'cart_saved': True} @@ -2538,7 +2561,9 @@ async def save_devices_cart( 'source': 'cabinet', } await user_cart_service.save_user_cart(user.id, cart_data) - logger.info(f'Cart saved for device purchase (cabinet save-cart) user {user.id}: +{request.devices} devices') + logger.info( + 'Cart saved for device purchase (cabinet save-cart) user + devices', user_id=user.id, devices=request.devices + ) return {'success': True, 'cart_saved': True} @@ -2664,7 +2689,7 @@ def _load_app_config_from_file() -> dict[str, Any]: if isinstance(data, dict): return data except Exception as e: - logger.error(f'Failed to load app-config.json: {e}') + logger.error('Failed to load app-config.json', error=e) return {} @@ -2807,10 +2832,11 @@ def _get_url_scheme_for_app(app: dict[str, Any]) -> tuple[str, bool]: # No scheme found logger.debug( - f"_get_url_scheme_for_app: No scheme found for app '{app.get('name')}', " - f'has blocks: {bool(app.get("blocks"))}, ' - f'has buttons: {bool(app.get("buttons"))}, ' - f'has urlScheme: {bool(app.get("urlScheme"))}' + '_get_url_scheme_for_app: No scheme found for app has blocks: has buttons: has urlScheme', + get=app.get('name'), + get_2=bool(app.get('blocks')), + get_3=bool(app.get('buttons')), + get_4=bool(app.get('urlScheme')), ) return '', False @@ -2856,7 +2882,7 @@ def _convert_remnawave_app_to_cabinet(app: dict[str, Any]) -> dict[str, Any]: # Debug log for conversion (не логируем отсутствие urlScheme - для Happ это нормально) app_name = app.get('name', 'unknown') if url_scheme: - logger.debug(f"_convert_remnawave_app_to_cabinet: app '{app_name}' -> urlScheme='{url_scheme}'") + logger.debug('_convert_remnawave_app_to_cabinet: app urlScheme', app_name=app_name, url_scheme=url_scheme) # Smart block mapping: find blocks by their content, not just position # 1. First block is usually installation @@ -2965,12 +2991,12 @@ async def _load_app_config_async() -> dict[str, Any]: async with service.get_api_client() as api: config = await api.get_subscription_page_config(remnawave_uuid) if config and config.config: - logger.debug(f'Loaded app config from RemnaWave: {remnawave_uuid}') + logger.debug('Loaded app config from RemnaWave', remnawave_uuid=remnawave_uuid) raw = dict(config.config) raw['_isRemnawave'] = True return raw except Exception as e: - logger.warning(f'Failed to load RemnaWave config, falling back to file: {e}') + logger.warning('Failed to load RemnaWave config, falling back to file', error=e) # Fallback to local file return _load_app_config_from_file() @@ -3001,21 +3027,21 @@ def _create_deep_link( scheme, uses_crypto = _get_url_scheme_for_app(app) if not scheme: - logger.debug(f"_create_deep_link: no urlScheme for app '{app.get('name', 'unknown')}'") + logger.debug('_create_deep_link: no urlScheme for app', get=app.get('name', 'unknown')) return None # Pick the correct payload based on which template the app uses if uses_crypto: if not subscription_crypto_link: logger.debug( - f"_create_deep_link: app '{app.get('name', 'unknown')}' requires crypto link but none available" + '_create_deep_link: app requires crypto link but none available', get=app.get('name', 'unknown') ) return None payload = subscription_crypto_link else: if not subscription_url: logger.debug( - f"_create_deep_link: app '{app.get('name', 'unknown')}' requires subscription_url but none available" + '_create_deep_link: app requires subscription_url but none available', get=app.get('name', 'unknown') ) return None payload = subscription_url @@ -3024,7 +3050,7 @@ def _create_deep_link( try: payload = base64.b64encode(payload.encode('utf-8')).decode('utf-8') except Exception as e: - logger.warning(f'Failed to encode payload to base64: {e}') + logger.warning('Failed to encode payload to base64', error=e) return f'{scheme}{payload}' @@ -3233,7 +3259,7 @@ async def update_countries( try: await add_user_to_servers(db, added_server_ids) except Exception as e: - logger.error(f'Ошибка обновления счётчика серверов: {e}') + logger.error('Ошибка обновления счётчика серверов', error=e) # Update connected squads user.subscription.connected_squads = selected_countries @@ -3248,7 +3274,7 @@ async def update_countries( else: await subscription_service.create_remnawave_user(db, user.subscription) except Exception as e: - logger.error(f'Failed to sync countries with RemnaWave: {e}') + logger.error('Failed to sync countries with RemnaWave', error=e) await db.refresh(user.subscription) @@ -3583,7 +3609,7 @@ async def get_devices( } except Exception as e: - logger.error(f'Error fetching devices: {e}') + logger.error('Error fetching devices', error=e) return { 'devices': [], 'total': 0, @@ -3627,7 +3653,7 @@ async def delete_device( } except Exception as e: - logger.error(f'Error deleting device: {e}') + logger.error('Error deleting device', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to delete device', @@ -3686,7 +3712,7 @@ async def delete_all_devices( await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) deleted_count += 1 except Exception as device_error: - logger.error(f'Error deleting device {device_hwid}: {device_error}') + logger.error('Error deleting device', device_hwid=device_hwid, device_error=device_error) return { 'success': True, @@ -3695,7 +3721,7 @@ async def delete_all_devices( } except Exception as e: - logger.error(f'Error deleting all devices: {e}') + logger.error('Error deleting all devices', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to delete devices', @@ -3769,7 +3795,7 @@ async def get_device_reduction_info( if response and 'response' in response: connected_devices_count = response['response'].get('total', 0) except Exception as e: - logger.error(f'Error getting connected devices count: {e}') + logger.error('Error getting connected devices count', error=e) can_reduce = current_device_limit - min_device_limit @@ -3853,8 +3879,11 @@ async def reduce_devices( if connected_devices_count > new_device_limit: devices_to_remove = connected_devices_count - new_device_limit logger.info( - f'Removing {devices_to_remove} excess devices for user {user.id}: ' - f'had {connected_devices_count}, new limit {new_device_limit}' + 'Removing excess devices for user had new limit', + devices_to_remove=devices_to_remove, + user_id=user.id, + connected_devices_count=connected_devices_count, + new_device_limit=new_device_limit, ) # Sort by date (oldest first) and remove the last ones @@ -3871,11 +3900,11 @@ async def reduce_devices( delete_data = {'userUuid': user.remnawave_uuid, 'hwid': device_hwid} await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) devices_removed_count += 1 - logger.info(f'Removed device {device_hwid} for user {user.id}') + logger.info('Removed device for user', device_hwid=device_hwid, user_id=user.id) except Exception as del_error: - logger.error(f'Error removing device {device_hwid}: {del_error}') + logger.error('Error removing device', device_hwid=device_hwid, del_error=del_error) except Exception as e: - logger.error(f'Error checking/removing devices: {e}') + logger.error('Error checking/removing devices', error=e) old_device_limit = current_device_limit @@ -3889,7 +3918,7 @@ async def reduce_devices( subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) except Exception as e: - logger.error(f'Error updating RemnaWave user: {e}') + logger.error('Error updating RemnaWave user', error=e) logger.info( f'User {user.id} reduced device limit from {old_device_limit} to {new_device_limit}' @@ -4323,7 +4352,7 @@ async def switch_tariff( else: await subscription_service.create_remnawave_user(db, user.subscription) except Exception as e: - logger.error(f'Failed to sync tariff switch with RemnaWave: {e}') + logger.error('Failed to sync tariff switch with RemnaWave', error=e) # Reset all devices on tariff switch devices_reset = False @@ -4333,9 +4362,9 @@ async def switch_tariff( async with service.get_api_client() as api: await api.reset_user_devices(user.remnawave_uuid) devices_reset = True - logger.info(f'Reset all devices for user {user.id} on tariff switch') + logger.info('Reset all devices for user on tariff switch', user_id=user.id) except Exception as e: - logger.error(f'Failed to reset devices on tariff switch: {e}') + logger.error('Failed to reset devices on tariff switch', error=e) await db.refresh(user) await db.refresh(user.subscription) @@ -4363,7 +4392,7 @@ async def switch_tariff( finally: await bot.session.close() except Exception as e: - logger.error(f'Failed to send admin notification for tariff switch: {e}') + logger.error('Failed to send admin notification for tariff switch', error=e) response = { 'success': True, @@ -4462,7 +4491,7 @@ async def toggle_subscription_pause( subscription_service = SubscriptionService() await subscription_service.enable_remnawave_user(user.remnawave_uuid) except Exception as e: - logger.error(f'Error enabling RemnaWave user on resume: {e}') + logger.error('Error enabling RemnaWave user on resume', error=e) if new_paused_state: message = 'Daily subscription paused' @@ -4596,7 +4625,7 @@ async def switch_traffic_package( else: await subscription_service.create_remnawave_user(db, user.subscription) except Exception as e: - logger.error(f'Failed to sync traffic switch with RemnaWave: {e}') + logger.error('Failed to sync traffic switch with RemnaWave', error=e) await db.refresh(user) await db.refresh(user.subscription) @@ -4737,7 +4766,7 @@ async def refresh_traffic( } except Exception as e: - logger.error(f'Error refreshing traffic for user {user.id}: {e}') + logger.error('Error refreshing traffic for user', user_id=user.id, error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to refresh traffic data', diff --git a/app/cabinet/routes/ticket_notifications.py b/app/cabinet/routes/ticket_notifications.py index 61e43e37..c636cf64 100644 --- a/app/cabinet/routes/ticket_notifications.py +++ b/app/cabinet/routes/ticket_notifications.py @@ -1,8 +1,8 @@ """Ticket notifications routes for cabinet.""" -import logging from datetime import datetime +import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -13,7 +13,7 @@ from app.database.models import User from ..dependencies import get_cabinet_db, get_current_admin_user, get_current_cabinet_user -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/tickets/notifications', tags=['Cabinet Ticket Notifications']) admin_router = APIRouter(prefix='/admin/tickets/notifications', tags=['Cabinet Admin Ticket Notifications']) diff --git a/app/cabinet/routes/tickets.py b/app/cabinet/routes/tickets.py index b28e6f9b..9f96650e 100644 --- a/app/cabinet/routes/tickets.py +++ b/app/cabinet/routes/tickets.py @@ -1,9 +1,9 @@ """Support tickets routes for cabinet.""" -import logging import math from datetime import datetime +import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import desc, func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -26,7 +26,7 @@ from ..schemas.tickets import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/tickets', tags=['Cabinet Tickets']) @@ -164,7 +164,7 @@ async def create_ticket( try: await notify_admins_about_new_ticket(ticket, db) except Exception as e: - logger.error(f'Error notifying admins about new ticket from cabinet: {e}') + logger.error('Error notifying admins about new ticket from cabinet', error=e) # Уведомить админов в кабинете try: @@ -173,7 +173,7 @@ async def create_ticket( # Отправить WebSocket уведомление await notify_admins_new_ticket(ticket.id, ticket.title, user.id) except Exception as e: - logger.error(f'Error creating cabinet notification for new ticket: {e}') + logger.error('Error creating cabinet notification for new ticket', error=e) messages = [_message_to_response(m) for m in ticket.messages] @@ -284,7 +284,7 @@ async def add_ticket_message( try: await notify_admins_about_ticket_reply(ticket, request.message, db) except Exception as e: - logger.error(f'Error notifying admins about ticket reply from cabinet: {e}') + logger.error('Error notifying admins about ticket reply from cabinet', error=e) # Уведомить админов в кабинете try: @@ -295,6 +295,6 @@ async def add_ticket_message( # Отправить WebSocket уведомление await notify_admins_ticket_reply(ticket.id, (request.message or '')[:100], user.id) except Exception as e: - logger.error(f'Error creating cabinet notification for user reply: {e}') + logger.error('Error creating cabinet notification for user reply', error=e) return _message_to_response(message) diff --git a/app/cabinet/routes/websocket.py b/app/cabinet/routes/websocket.py index 60665552..5066b2b3 100644 --- a/app/cabinet/routes/websocket.py +++ b/app/cabinet/routes/websocket.py @@ -4,8 +4,8 @@ from __future__ import annotations import asyncio import json -import logging +import structlog from fastapi import APIRouter, WebSocket, WebSocketDisconnect from app.cabinet.auth.jwt_handler import get_token_payload @@ -14,7 +14,7 @@ from app.database.crud.user import get_user_by_id from app.database.database import AsyncSessionLocal -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter() @@ -42,10 +42,10 @@ class CabinetConnectionManager: self._admin_connections[user_id].add(websocket) logger.debug( - 'Cabinet WS connected: user_id=%d, is_admin=%s, total_users=%d', - user_id, - is_admin, - len(self._user_connections), + 'Cabinet WS connected: user_id is_admin total_users', + user_id=user_id, + is_admin=is_admin, + user_connections_count=len(self._user_connections), ) async def disconnect(self, websocket: WebSocket, user_id: int) -> None: @@ -61,7 +61,7 @@ class CabinetConnectionManager: if not self._admin_connections[user_id]: del self._admin_connections[user_id] - logger.debug('Cabinet WS disconnected: user_id=%d', user_id) + logger.debug('Cabinet WS disconnected: user_id', user_id=user_id) async def send_to_user(self, user_id: int, message: dict) -> None: """Отправить сообщение конкретному пользователю.""" @@ -79,7 +79,7 @@ class CabinetConnectionManager: try: await ws.send_text(data) except Exception as e: - logger.warning('Failed to send to user %d: %s', user_id, e) + logger.warning('Failed to send to user', user_id=user_id, e=e) disconnected.add(ws) # Cleanup disconnected @@ -105,7 +105,7 @@ class CabinetConnectionManager: try: await ws.send_text(data) except Exception as e: - logger.warning('Failed to send to admin %d: %s', user_id, e) + logger.warning('Failed to send to admin', user_id=user_id, e=e) if user_id not in disconnected_by_user: disconnected_by_user[user_id] = set() disconnected_by_user[user_id].add(ws) @@ -152,7 +152,7 @@ async def verify_cabinet_ws_token(token: str) -> tuple[int | None, bool]: ) return user_id, is_admin except (TimeoutError, OSError, ConnectionRefusedError) as e: - logger.error('Database connection error in WS token verification: %s', str(e)[:200]) + logger.error('Database connection error in WS token verification', e=str(e)[:200]) return None, False @@ -165,7 +165,7 @@ async def cabinet_websocket_endpoint(websocket: WebSocket): token = websocket.query_params.get('token') if not token: - logger.debug('Cabinet WS: No token from %s', client_host) + logger.debug('Cabinet WS: No token from', client_host=client_host) # Принимаем и сразу закрываем с кодом ошибки await websocket.accept() await websocket.close(code=1008, reason='Unauthorized: No token') @@ -175,7 +175,7 @@ async def cabinet_websocket_endpoint(websocket: WebSocket): user_id, is_admin = await verify_cabinet_ws_token(token) if not user_id: - logger.debug('Cabinet WS: Invalid token from %s', client_host) + logger.debug('Cabinet WS: Invalid token from', client_host=client_host) # Принимаем и сразу закрываем с кодом ошибки await websocket.accept() await websocket.close(code=1008, reason='Unauthorized: Invalid token') @@ -184,9 +184,9 @@ async def cabinet_websocket_endpoint(websocket: WebSocket): # Принимаем соединение try: await websocket.accept() - logger.debug('Cabinet WS accepted: user_id=%d, is_admin=%s', user_id, is_admin) + logger.debug('Cabinet WS accepted: user_id is_admin', user_id=user_id, is_admin=is_admin) except Exception as e: - logger.error('Cabinet WS: Failed to accept from %s: %s', client_host, e) + logger.error('Cabinet WS: Failed to accept from', client_host=client_host, e=e) return # Регистрируем подключение @@ -213,17 +213,17 @@ async def cabinet_websocket_endpoint(websocket: WebSocket): await websocket.send_json({'type': 'pong'}) except json.JSONDecodeError: - logger.warning('Cabinet WS: Invalid JSON from user %d', user_id) + logger.warning('Cabinet WS: Invalid JSON from user', user_id=user_id) except WebSocketDisconnect: break except Exception as e: - logger.exception('Cabinet WS error for user %d: %s', user_id, e) + logger.exception('Cabinet WS error for user', user_id=user_id, e=e) break except WebSocketDisconnect: - logger.debug('Cabinet WS disconnected: user_id=%d', user_id) + logger.debug('Cabinet WS disconnected: user_id', user_id=user_id) except Exception as e: - logger.exception('Cabinet WS error: %s', e) + logger.exception('Cabinet WS error', e=e) finally: await cabinet_ws_manager.disconnect(websocket, user_id) diff --git a/app/cabinet/routes/wheel.py b/app/cabinet/routes/wheel.py index 4aa31450..b509478f 100644 --- a/app/cabinet/routes/wheel.py +++ b/app/cabinet/routes/wheel.py @@ -2,11 +2,11 @@ API роуты колеса удачи для пользователей. """ -import logging import math import time import httpx +import structlog from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -32,7 +32,7 @@ from app.database.models import User from app.services.wheel_service import wheel_service -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix='/wheel', tags=['Fortune Wheel']) @@ -253,14 +253,16 @@ async def create_stars_invoice( result = response.json() if not result.get('ok'): - logger.error(f'Telegram API error: {result}') + logger.error('Telegram API error', result=result) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Ошибка создания инвойса', ) invoice_url = result['result'] - logger.info(f'Created Stars invoice for wheel spin: user={user.id}, stars={stars_amount}') + logger.info( + 'Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount + ) return StarsInvoiceResponse( invoice_url=invoice_url, @@ -268,7 +270,7 @@ async def create_stars_invoice( ) except httpx.HTTPError as e: - logger.error(f'HTTP error creating invoice: {e}') + logger.error('HTTP error creating invoice', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Ошибка соединения с Telegram', diff --git a/app/cabinet/services/email_service.py b/app/cabinet/services/email_service.py index 9818892d..bc833a8f 100644 --- a/app/cabinet/services/email_service.py +++ b/app/cabinet/services/email_service.py @@ -1,14 +1,15 @@ """Email service for sending verification and password reset emails.""" -import logging import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +import structlog + from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class EmailService: @@ -41,7 +42,7 @@ class EmailService: if smtp.has_extn('auth'): smtp.login(self.user, self.password) else: - logger.debug(f'SMTP server {self.host} does not support AUTH, skipping authentication') + logger.debug('SMTP server does not support AUTH, skipping authentication', host=self.host) return smtp @@ -94,11 +95,11 @@ class EmailService: with self._get_smtp_connection() as smtp: smtp.sendmail(self.from_email, to_email, msg.as_string()) - logger.info(f'Email sent successfully to {to_email}') + logger.info('Email sent successfully to', to_email=to_email) return True except Exception as e: - logger.error(f'Failed to send email to {to_email}: {e}') + logger.error('Failed to send email to', to_email=to_email, error=e) return False def send_verification_email( diff --git a/app/cabinet/services/email_template_overrides.py b/app/cabinet/services/email_template_overrides.py index 1e3ca6e9..f8bfae09 100644 --- a/app/cabinet/services/email_template_overrides.py +++ b/app/cabinet/services/email_template_overrides.py @@ -4,17 +4,17 @@ Service for managing email template overrides stored in the database. Custom templates override the hardcoded defaults from email_templates.py. """ -import logging from datetime import datetime from typing import Any +import structlog from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from app.database.database import AsyncSessionLocal -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def get_template_override( @@ -56,7 +56,9 @@ async def get_template_override( return None except Exception as e: - logger.debug('Не удалось получить override шаблона %s/%s: %s', notification_type, language, e) + logger.debug( + 'Не удалось получить override шаблона /', notification_type=notification_type, language=language, e=e + ) return None diff --git a/app/config.py b/app/config.py index 04efa9b4..31dc929c 100644 --- a/app/config.py +++ b/app/config.py @@ -1,7 +1,6 @@ import hashlib import hmac import html -import logging import math import os import re @@ -11,6 +10,7 @@ from pathlib import Path from urllib.parse import urlparse from zoneinfo import ZoneInfo +import structlog from pydantic import Field, field_validator from pydantic_settings import BaseSettings @@ -23,7 +23,7 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS: list[str] = [ USER_TAG_PATTERN = re.compile(r'^[A-Z0-9_]{1,16}$') -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class Settings(BaseSettings): @@ -1297,7 +1297,7 @@ class Settings(BaseSettings): raise ValueError return time(hour=hours, minute=minutes) except (ValueError, AttributeError): - logging.getLogger(__name__).warning('Некорректное значение ADMIN_REPORTS_SEND_TIME: %s', value) + logger.warning('Некорректное значение ADMIN_REPORTS_SEND_TIME', send_time_value=value) return None def kopeks_to_rubles(self, kopeks: int) -> float: @@ -1317,17 +1317,14 @@ class Settings(BaseSettings): if len(cleaned) > 16: logger.warning( - 'Некорректная длина %s: максимум 16 символов, получено %s', - setting_name, - len(cleaned), + 'Некорректная длина : максимум 16 символов, получено', + setting_name=setting_name, + cleaned_count=len(cleaned), ) return None if not USER_TAG_PATTERN.fullmatch(cleaned): - logger.warning( - 'Некорректный формат %s: допустимы только A-Z, 0-9 и подчёркивание', - setting_name, - ) + logger.warning('Некорректный формат : допустимы только A-Z, 0-9 и подчёркивание', setting_name=setting_name) return None return cleaned @@ -1448,9 +1445,9 @@ class Settings(BaseSettings): try: return int(self.EXTERNAL_ADMIN_TOKEN_BOT_ID) if self.EXTERNAL_ADMIN_TOKEN_BOT_ID else None except (TypeError, ValueError): # pragma: no cover - защитная ветка для некорректных значений - logging.getLogger(__name__).warning( - 'Некорректный идентификатор бота для внешней админки: %s', - self.EXTERNAL_ADMIN_TOKEN_BOT_ID, + logger.warning( + 'Некорректный идентификатор бота для внешней админки', + EXTERNAL_ADMIN_TOKEN_BOT_ID=self.EXTERNAL_ADMIN_TOKEN_BOT_ID, ) return None @@ -1531,10 +1528,7 @@ class Settings(BaseSettings): try: value = int(raw_value) except (TypeError, ValueError): - logger.warning( - 'Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT: %s', - raw_value, - ) + logger.warning('Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT', raw_value=raw_value) return None if value < 0: @@ -1574,8 +1568,7 @@ class Settings(BaseSettings): value = int(self.TRIAL_ACTIVATION_PRICE) except (TypeError, ValueError): logger.warning( - 'Некорректное значение TRIAL_ACTIVATION_PRICE: %s', - self.TRIAL_ACTIVATION_PRICE, + 'Некорректное значение TRIAL_ACTIVATION_PRICE', TRIAL_ACTIVATION_PRICE=self.TRIAL_ACTIVATION_PRICE ) return 0 @@ -1694,7 +1687,7 @@ class Settings(BaseSettings): try: method_code = int(part) except ValueError: - logger.warning('Некорректный код метода Platega: %s', part) + logger.warning('Некорректный код метода Platega', part=part) continue if method_code in {2, 10, 11, 12, 13} and method_code not in seen: methods.append(method_code) @@ -1789,8 +1782,8 @@ class Settings(BaseSettings): if minutes <= 0: logger.warning( - 'Некорректный интервал автопроверки платежей: %s. Используется значение по умолчанию 10 минут.', - self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES, + 'Некорректный интервал автопроверки платежей: . Используется значение по умолчанию 10 минут.', + PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES, ) return 10 @@ -2144,22 +2137,13 @@ class Settings(BaseSettings): return self.REFERRAL_NOTIFICATIONS_ENABLED def get_traffic_packages(self) -> list[dict]: - import logging - - logger = logging.getLogger(__name__) - try: packages = [] config_str = self.TRAFFIC_PACKAGES_CONFIG.strip() - logger.debug(f"CONFIG STRING: '{config_str}'") - if not config_str: - logger.debug('CONFIG EMPTY, USING FALLBACK') return self._get_fallback_traffic_packages() - logger.debug('PARSING CONFIG...') - for package_config in config_str.split(','): package_config = package_config.strip() if not package_config: @@ -2178,11 +2162,10 @@ class Settings(BaseSettings): except ValueError: continue - logger.debug(f'PARSED {len(packages)} packages from config') return packages if packages else self._get_fallback_traffic_packages() except Exception as e: - logger.info(f'ERROR PARSING CONFIG: {e}') + logger.warning('ERROR PARSING CONFIG', error=e) return self._get_fallback_traffic_packages() def is_version_check_enabled(self) -> bool: diff --git a/app/database/crud/campaign.py b/app/database/crud/campaign.py index 9ada95ae..d36df4b4 100644 --- a/app/database/crud/campaign.py +++ b/app/database/crud/campaign.py @@ -1,6 +1,6 @@ -import logging from datetime import datetime +import structlog from sqlalchemy import and_, delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -17,7 +17,7 @@ from app.database.models import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def create_campaign( @@ -57,10 +57,10 @@ async def create_campaign( await db.refresh(campaign) logger.info( - '📣 Создана рекламная кампания %s (start=%s, bonus=%s)', - campaign.name, - campaign.start_parameter, - campaign.bonus_type, + '📣 Создана рекламная кампания (start bonus=)', + campaign_name=campaign.name, + start_parameter=campaign.start_parameter, + bonus_type=campaign.bonus_type, ) return campaign @@ -157,14 +157,14 @@ async def update_campaign( await db.commit() await db.refresh(campaign) - logger.info('✏️ Обновлена рекламная кампания %s (%s)', campaign.name, update_data) + logger.info('✏️ Обновлена рекламная кампания', campaign_name=campaign.name, update_data=update_data) return campaign async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool: await db.execute(delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id)) await db.commit() - logger.info('🗑️ Удалена рекламная кампания %s', campaign.name) + logger.info('🗑️ Удалена рекламная кампания', campaign_name=campaign.name) return True @@ -217,7 +217,7 @@ async def record_campaign_registration( await db.commit() await db.refresh(registration) - logger.info('📈 Регистрируем пользователя %s в кампании %s', user_id, campaign_id) + logger.info('📈 Регистрируем пользователя в кампании', user_id=user_id, campaign_id=campaign_id) return registration diff --git a/app/database/crud/cloudpayments.py b/app/database/crud/cloudpayments.py index 7f40d29f..0ca32e90 100644 --- a/app/database/crud/cloudpayments.py +++ b/app/database/crud/cloudpayments.py @@ -2,17 +2,17 @@ from __future__ import annotations -import logging from datetime import datetime from typing import Any +import structlog from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import CloudPaymentsPayment -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def create_cloudpayments_payment( @@ -65,10 +65,10 @@ async def create_cloudpayments_payment( await db.refresh(payment) logger.debug( - 'Created CloudPayments payment: id=%s, invoice=%s, amount=%s', - payment.id, - invoice_id, - amount_kopeks, + 'Created CloudPayments payment: id invoice amount', + payment_id=payment.id, + invoice_id=invoice_id, + amount_kopeks=amount_kopeks, ) return payment @@ -195,9 +195,7 @@ async def mark_cloudpayments_payment_as_paid( await db.refresh(payment) logger.info( - 'Marked CloudPayments payment as paid: id=%s, invoice=%s', - payment.id, - payment.invoice_id, + 'Marked CloudPayments payment as paid: id invoice', payment_id=payment.id, invoice_id=payment.invoice_id ) return payment diff --git a/app/database/crud/contest.py b/app/database/crud/contest.py index 511bf6ee..5c100b2d 100644 --- a/app/database/crud/contest.py +++ b/app/database/crud/contest.py @@ -1,7 +1,7 @@ -import logging from collections.abc import Sequence from datetime import datetime +import structlog from sqlalchemy import and_, delete, desc, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload from app.database.models import ContestAttempt, ContestRound, ContestTemplate, User -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # Templates diff --git a/app/database/crud/cryptobot.py b/app/database/crud/cryptobot.py index 869d77ed..063891b6 100644 --- a/app/database/crud/cryptobot.py +++ b/app/database/crud/cryptobot.py @@ -1,6 +1,6 @@ -import logging from datetime import datetime +import structlog from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload from app.database.models import CryptoBotPayment -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def create_cryptobot_payment( @@ -41,7 +41,13 @@ async def create_cryptobot_payment( await db.commit() await db.refresh(payment) - logger.info(f'Создан CryptoBot платеж: {invoice_id} на {amount} {asset} для пользователя {user_id}') + logger.info( + 'Создан CryptoBot платеж: на для пользователя', + invoice_id=invoice_id, + amount=amount, + asset=asset, + user_id=user_id, + ) return payment @@ -78,7 +84,7 @@ async def update_cryptobot_payment_status( await db.commit() await db.refresh(payment) - logger.info(f'Обновлен статус CryptoBot платежа {invoice_id}: {status}') + logger.info('Обновлен статус CryptoBot платежа', invoice_id=invoice_id, status=status) return payment @@ -96,7 +102,7 @@ async def link_cryptobot_payment_to_transaction( await db.commit() await db.refresh(payment) - logger.info(f'Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}') + logger.info('Связан CryptoBot платеж с транзакцией', invoice_id=invoice_id, transaction_id=transaction_id) return payment diff --git a/app/database/crud/discount_offer.py b/app/database/crud/discount_offer.py index 6d841666..91de782e 100644 --- a/app/database/crud/discount_offer.py +++ b/app/database/crud/discount_offer.py @@ -1,8 +1,8 @@ from __future__ import annotations -import logging from datetime import datetime, timedelta +import structlog from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -11,7 +11,7 @@ from app.database.crud.promo_offer_log import log_promo_offer_action from app.database.models import DiscountOffer -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def upsert_discount_offer( @@ -178,17 +178,12 @@ async def mark_offer_claimed( details=details, ) except Exception as exc: # pragma: no cover - defensive logging - logger.warning( - 'Failed to record promo offer claim log for offer %s: %s', - offer.id, - exc, - ) + logger.warning('Failed to record promo offer claim log for offer', offer_id=offer.id, exc=exc) try: await db.rollback() except Exception as rollback_error: # pragma: no cover - defensive logging logger.warning( - 'Failed to rollback session after promo offer claim log failure: %s', - rollback_error, + 'Failed to rollback session after promo offer claim log failure', rollback_error=rollback_error ) return offer @@ -239,16 +234,13 @@ async def deactivate_expired_offers(db: AsyncSession) -> int: ) except Exception as exc: # pragma: no cover - defensive logging logger.warning( - 'Failed to record promo offer disable log for offer %s: %s', - payload.get('offer_id'), - exc, + 'Failed to record promo offer disable log for offer', payload=payload.get('offer_id'), exc=exc ) try: await db.rollback() except Exception as rollback_error: # pragma: no cover - defensive logging logger.warning( - 'Failed to rollback session after promo offer disable log failure: %s', - rollback_error, + 'Failed to rollback session after promo offer disable log failure', rollback_error=rollback_error ) return count diff --git a/app/database/crud/faq.py b/app/database/crud/faq.py index a42d2520..84ccb371 100644 --- a/app/database/crud/faq.py +++ b/app/database/crud/faq.py @@ -1,14 +1,14 @@ -import logging from collections.abc import Iterable from datetime import datetime +import structlog from sqlalchemy import delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import FaqPage, FaqSetting -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def get_faq_setting(db: AsyncSession, language: str) -> FaqSetting | None: @@ -94,7 +94,7 @@ async def create_faq_page( await db.commit() await db.refresh(page) - logger.info('✅ Создана страница FAQ %s для языка %s', page.id, language) + logger.info('✅ Создана страница FAQ для языка', page_id=page.id, language=language) return page @@ -122,7 +122,7 @@ async def update_faq_page( await db.commit() await db.refresh(page) - logger.info('✅ Страница FAQ %s обновлена', page.id) + logger.info('✅ Страница FAQ обновлена', page_id=page.id) return page @@ -130,7 +130,7 @@ async def update_faq_page( async def delete_faq_page(db: AsyncSession, page_id: int) -> None: await db.execute(delete(FaqPage).where(FaqPage.id == page_id)) await db.commit() - logger.info('🗑️ Страница FAQ %s удалена', page_id) + logger.info('🗑️ Страница FAQ удалена', page_id=page_id) async def bulk_update_order( diff --git a/app/database/crud/freekassa.py b/app/database/crud/freekassa.py index aad9889c..31ba2b73 100644 --- a/app/database/crud/freekassa.py +++ b/app/database/crud/freekassa.py @@ -1,16 +1,16 @@ """CRUD операции для платежей Freekassa.""" import json -import logging from datetime import datetime +import structlog from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import FreekassaPayment -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def create_freekassa_payment( @@ -41,7 +41,7 @@ async def create_freekassa_payment( db.add(payment) await db.commit() await db.refresh(payment) - logger.info(f'Создан платеж Freekassa: order_id={order_id}, user_id={user_id}') + logger.info('Создан платеж Freekassa: order_id=, user_id', order_id=order_id, user_id=user_id) return payment @@ -92,7 +92,12 @@ async def update_freekassa_payment_status( await db.commit() await db.refresh(payment) - logger.info(f'Обновлен статус платежа Freekassa: order_id={payment.order_id}, status={status}, is_paid={is_paid}') + logger.info( + 'Обновлен статус платежа Freekassa: order_id=, status=, is_paid', + order_id=payment.order_id, + status=status, + is_paid=is_paid, + ) return payment diff --git a/app/database/crud/heleket.py b/app/database/crud/heleket.py index b0ceae99..19e7f34a 100644 --- a/app/database/crud/heleket.py +++ b/app/database/crud/heleket.py @@ -1,7 +1,7 @@ -import logging from datetime import datetime from typing import Any +import structlog from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload from app.database.models import HeleketPayment -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def create_heleket_payment( @@ -50,12 +50,12 @@ async def create_heleket_payment( await db.refresh(payment) logger.info( - 'Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s', - uuid, - order_id, - amount, - currency, - user_id, + 'Создан Heleket платеж: uuid= order_id= amount= для пользователя', + uuid=uuid, + order_id=order_id, + amount=amount, + currency=currency, + user_id=user_id, ) return payment @@ -107,7 +107,7 @@ async def update_heleket_payment( payment = await get_heleket_payment_by_uuid(db, uuid) if not payment: - logger.error('Heleket платеж с uuid=%s не найден', uuid) + logger.error('Heleket платеж с uuid= не найден', uuid=uuid) return None if status is not None: @@ -135,11 +135,11 @@ async def update_heleket_payment( await db.refresh(payment) logger.info( - 'Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s', - uuid, - payment.status, - payment.payer_amount, - payment.payer_currency, + 'Обновлен Heleket платеж : статус= payer_amount', + uuid=uuid, + payment_status=payment.status, + payer_amount=payment.payer_amount, + payer_currency=payment.payer_currency, ) return payment @@ -153,7 +153,7 @@ async def link_heleket_payment_to_transaction( payment = await get_heleket_payment_by_uuid(db, uuid) if not payment: - logger.error('Не найден Heleket платеж для связи с транзакцией: %s', uuid) + logger.error('Не найден Heleket платеж для связи с транзакцией', uuid=uuid) return None payment.transaction_id = transaction_id @@ -162,10 +162,6 @@ async def link_heleket_payment_to_transaction( await db.commit() await db.refresh(payment) - logger.info( - 'Heleket платеж %s связан с транзакцией %s', - uuid, - transaction_id, - ) + logger.info('Heleket платеж связан с транзакцией', uuid=uuid, transaction_id=transaction_id) return payment diff --git a/app/database/crud/kassa_ai.py b/app/database/crud/kassa_ai.py index 34aefb71..9b8cae01 100644 --- a/app/database/crud/kassa_ai.py +++ b/app/database/crud/kassa_ai.py @@ -1,16 +1,16 @@ """CRUD операции для платежей KassaAI.""" import json -import logging from datetime import datetime +import structlog from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import KassaAiPayment -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def create_kassa_ai_payment( @@ -43,7 +43,7 @@ async def create_kassa_ai_payment( db.add(payment) await db.commit() await db.refresh(payment) - logger.info(f'Создан платеж KassaAI: order_id={order_id}, user_id={user_id}') + logger.info('Создан платеж KassaAI: order_id=, user_id', order_id=order_id, user_id=user_id) return payment @@ -94,7 +94,12 @@ async def update_kassa_ai_payment_status( await db.commit() await db.refresh(payment) - logger.info(f'Обновлен статус платежа KassaAI: order_id={payment.order_id}, status={status}, is_paid={is_paid}') + logger.info( + 'Обновлен статус платежа KassaAI: order_id=, status=, is_paid', + order_id=payment.order_id, + status=status, + is_paid=is_paid, + ) return payment diff --git a/app/database/crud/mulenpay.py b/app/database/crud/mulenpay.py index b6cb1ecb..44fe1e46 100644 --- a/app/database/crud/mulenpay.py +++ b/app/database/crud/mulenpay.py @@ -1,6 +1,6 @@ -import logging from datetime import datetime +import structlog from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -8,7 +8,7 @@ from app.config import settings from app.database.models import MulenPayPayment -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def create_mulenpay_payment( @@ -41,12 +41,12 @@ async def create_mulenpay_payment( await db.refresh(payment) logger.info( - 'Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s', - settings.get_mulenpay_display_name(), - payment.mulen_payment_id, - uuid, - amount_kopeks, - user_id, + 'Создан платеж # (uuid=) на сумму копеек для пользователя', + get_mulenpay_display_name=settings.get_mulenpay_display_name(), + mulen_payment_id=payment.mulen_payment_id, + uuid=uuid, + amount_kopeks=amount_kopeks, + user_id=user_id, ) return payment diff --git a/app/database/crud/notification.py b/app/database/crud/notification.py index eb26ebdc..bc369b21 100644 --- a/app/database/crud/notification.py +++ b/app/database/crud/notification.py @@ -1,12 +1,11 @@ -import logging - +import structlog from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import SentNotification -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def notification_sent( diff --git a/app/database/crud/pal24.py b/app/database/crud/pal24.py index 0eb0087a..50c042bd 100644 --- a/app/database/crud/pal24.py +++ b/app/database/crud/pal24.py @@ -2,17 +2,17 @@ from __future__ import annotations -import logging from datetime import datetime from typing import Any +import structlog from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import Pal24Payment -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def create_pal24_payment( @@ -51,11 +51,11 @@ async def create_pal24_payment( await db.refresh(payment) logger.info( - 'Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)', - payment.id, - user_id, - amount_kopeks, - status, + 'Создан Pal24 платеж # для пользователя : копеек (статус)', + payment_id=payment.id, + user_id=user_id, + amount_kopeks=amount_kopeks, + status=status, ) return payment @@ -128,10 +128,10 @@ async def update_pal24_payment_status( await db.refresh(payment) logger.info( - 'Обновлен Pal24 платеж %s: статус=%s, is_paid=%s', - payment.bill_id, - payment.status, - payment.is_paid, + 'Обновлен Pal24 платеж : статус is_paid', + bill_id=payment.bill_id, + payment_status=payment.status, + is_paid=payment.is_paid, ) return payment @@ -145,9 +145,5 @@ async def link_pal24_payment_to_transaction( await db.execute(update(Pal24Payment).where(Pal24Payment.id == payment.id).values(transaction_id=transaction_id)) await db.commit() await db.refresh(payment) - logger.info( - 'Pal24 платеж %s привязан к транзакции %s', - payment.bill_id, - transaction_id, - ) + logger.info('Pal24 платеж привязан к транзакции', bill_id=payment.bill_id, transaction_id=transaction_id) return payment diff --git a/app/database/crud/platega.py b/app/database/crud/platega.py index 830b2536..70172e83 100644 --- a/app/database/crud/platega.py +++ b/app/database/crud/platega.py @@ -2,17 +2,17 @@ from __future__ import annotations -import logging from datetime import datetime from typing import Any +import structlog from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import PlategaPayment -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def create_platega_payment( @@ -55,11 +55,11 @@ async def create_platega_payment( await db.refresh(payment) logger.info( - 'Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s', - payment.id, - platega_transaction_id, - amount_kopeks, - user_id, + 'Создан Platega платеж # (tx=) на сумму копеек для пользователя', + payment_id=payment.id, + platega_transaction_id=platega_transaction_id, + amount_kopeks=amount_kopeks, + user_id=user_id, ) return payment diff --git a/app/database/crud/poll.py b/app/database/crud/poll.py index a286f6ce..339890b2 100644 --- a/app/database/crud/poll.py +++ b/app/database/crud/poll.py @@ -1,6 +1,6 @@ -import logging from collections.abc import Iterable, Sequence +import structlog from sqlalchemy import and_, delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -14,7 +14,7 @@ from app.database.models import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def create_poll( @@ -97,7 +97,7 @@ async def delete_poll(db: AsyncSession, poll_id: int) -> bool: await db.delete(poll) await db.commit() - logger.info('🗑️ Удалён опрос %s', poll_id) + logger.info('🗑️ Удалён опрос', poll_id=poll_id) return True diff --git a/app/database/crud/privacy_policy.py b/app/database/crud/privacy_policy.py index fa7f883a..b9e68abd 100644 --- a/app/database/crud/privacy_policy.py +++ b/app/database/crud/privacy_policy.py @@ -1,13 +1,13 @@ -import logging from datetime import datetime +import structlog from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import PrivacyPolicy -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def get_privacy_policy(db: AsyncSession, language: str) -> PrivacyPolicy | None: @@ -38,11 +38,7 @@ async def upsert_privacy_policy( await db.commit() await db.refresh(policy) - logger.info( - '✅ Политика конфиденциальности для языка %s обновлена (ID: %s)', - language, - policy.id, - ) + logger.info('✅ Политика конфиденциальности для языка обновлена (ID:)', language=language, policy_id=policy.id) return policy diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 2b4c96ee..14b55604 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -1,5 +1,4 @@ -import logging - +import structlog from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -25,7 +24,7 @@ def _normalize_period_discounts(period_discounts: dict[int, int] | None) -> dict return normalized -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def get_promo_groups_with_counts( @@ -194,11 +193,7 @@ async def update_promo_group( await db.commit() await db.refresh(group) - logger.info( - "Обновлена промогруппа '%s' (id=%s)", - group.name, - group.id, - ) + logger.info("Обновлена промогруппа '' (id=)", group_name=group.name, group_id=group.id) return group @@ -246,10 +241,10 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool: await db.commit() logger.info( - "Промогруппа '%s' (id=%s) удалена, пользователи переведены в '%s'", - group.name, - group.id, - default_group.name, + "Промогруппа '' (id=) удалена, пользователи переведены в ''", + group_name=group.name, + group_id=group.id, + default_group_name=default_group.name, ) return True diff --git a/app/database/crud/promo_offer_log.py b/app/database/crud/promo_offer_log.py index d96588d5..dba33e45 100644 --- a/app/database/crud/promo_offer_log.py +++ b/app/database/crud/promo_offer_log.py @@ -1,7 +1,6 @@ from __future__ import annotations -import logging - +import structlog from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -9,7 +8,7 @@ from sqlalchemy.orm import selectinload from app.database.models import PromoOfferLog -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def log_promo_offer_action( diff --git a/app/database/crud/promocode.py b/app/database/crud/promocode.py index 6e00c41c..ca51b1fd 100644 --- a/app/database/crud/promocode.py +++ b/app/database/crud/promocode.py @@ -1,6 +1,6 @@ -import logging from datetime import datetime +import structlog from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload from app.database.models import PromoCode, PromoCodeType, PromoCodeUse, User -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def get_promocode_by_code(db: AsyncSession, code: str) -> PromoCode | None: @@ -78,9 +78,9 @@ async def create_promocode( await db.refresh(promocode) if promo_group_id: - logger.info(f'✅ Создан промокод: {code} с промогруппой ID {promo_group_id}') + logger.info('✅ Создан промокод: с промогруппой ID', code=code, promo_group_id=promo_group_id) else: - logger.info(f'✅ Создан промокод: {code}') + logger.info('✅ Создан промокод', code=code) return promocode @@ -97,11 +97,11 @@ async def use_promocode(db: AsyncSession, promocode_id: int, user_id: int) -> bo await db.commit() - logger.info(f'✅ Промокод {promocode.code} использован пользователем {user_id}') + logger.info('✅ Промокод использован пользователем', code=promocode.code, user_id=user_id) return True except Exception as e: - logger.error(f'Ошибка использования промокода: {e}') + logger.error('Ошибка использования промокода', error=e) await db.rollback() return False @@ -120,7 +120,7 @@ async def create_promocode_use(db: AsyncSession, promocode_id: int, user_id: int await db.commit() await db.refresh(promocode_use) - logger.info(f'📝 Записано использование промокода {promocode_id} пользователем {user_id}') + logger.info('📝 Записано использование промокода пользователем', promocode_id=promocode_id, user_id=user_id) return promocode_use @@ -194,11 +194,11 @@ async def delete_promocode(db: AsyncSession, promocode: PromoCode) -> bool: await db.delete(promocode) await db.commit() - logger.info(f'🗑️ Удален промокод: {promocode.code}') + logger.info('🗑️ Удален промокод', code=promocode.code) return True except Exception as e: - logger.error(f'Ошибка удаления промокода: {e}') + logger.error('Ошибка удаления промокода', error=e) await db.rollback() return False diff --git a/app/database/crud/public_offer.py b/app/database/crud/public_offer.py index 83a85ab4..8ae9f201 100644 --- a/app/database/crud/public_offer.py +++ b/app/database/crud/public_offer.py @@ -1,13 +1,13 @@ -import logging from datetime import datetime +import structlog from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import PublicOffer -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def get_public_offer(db: AsyncSession, language: str) -> PublicOffer | None: @@ -38,11 +38,7 @@ async def upsert_public_offer( await db.commit() await db.refresh(offer) - logger.info( - '✅ Публичная оферта для языка %s обновлена (ID: %s)', - language, - offer.id, - ) + logger.info('✅ Публичная оферта для языка обновлена (ID:)', language=language, offer_id=offer.id) return offer diff --git a/app/database/crud/referral.py b/app/database/crud/referral.py index 12cca9ae..61aa0d33 100644 --- a/app/database/crud/referral.py +++ b/app/database/crud/referral.py @@ -1,6 +1,6 @@ -import logging from datetime import datetime, timedelta +import structlog from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload from app.database.models import ReferralEarning, User -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def create_referral_earning( @@ -31,7 +31,9 @@ async def create_referral_earning( await db.commit() await db.refresh(earning) - logger.info(f'💰 Создан реферальный заработок: {amount_kopeks / 100}₽ для пользователя {user_id}') + logger.info( + '💰 Создан реферальный заработок: ₽ для пользователя', amount_kopeks=amount_kopeks / 100, user_id=user_id + ) return earning @@ -211,7 +213,10 @@ async def get_referral_statistics(db: AsyncSession) -> dict: month_earnings = month_referral_earnings_result.scalar() + month_transaction_earnings_result.scalar() logger.info( - f'Реферальная статистика: {users_with_referrals} рефералов, {active_referrers} рефереров, выплачено {total_paid} копеек' + 'Реферальная статистика: рефералов, рефереров, выплачено копеек', + users_with_referrals=users_with_referrals, + active_referrers=active_referrers, + total_paid=total_paid, ) return { diff --git a/app/database/crud/referral_contest.py b/app/database/crud/referral_contest.py index df35d676..8b10122b 100644 --- a/app/database/crud/referral_contest.py +++ b/app/database/crud/referral_contest.py @@ -1,7 +1,7 @@ -import logging from collections.abc import Sequence from datetime import date, datetime, time +import structlog from sqlalchemy import and_, desc, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -16,7 +16,7 @@ from app.database.models import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def create_referral_contest( @@ -725,7 +725,12 @@ async def sync_contest_events( # Конец дня: 23:59:59.999999 contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999) - logger.info('Синхронизация конкурса %s: период с %s по %s', contest_id, contest_start, contest_end) + logger.info( + 'Синхронизация конкурса : период с по', + contest_id=contest_id, + contest_start=contest_start, + contest_end=contest_end, + ) stats = { 'updated': 0, @@ -815,7 +820,11 @@ async def sync_contest_events( # Логируем значительные изменения if abs(old_amount - total_paid) > 10000: # больше 100 руб разницы logger.debug( - 'Событие %s (реферал %s): %s -> %s коп.', event.id, event.referral_id, old_amount, total_paid + 'Событие (реферал): -> коп.', + event_id=event.id, + referral_id=event.referral_id, + old_amount=old_amount, + total_paid=total_paid, ) else: stats['skipped'] += 1 @@ -824,11 +833,11 @@ async def sync_contest_events( await db.commit() logger.info( - 'Синхронизация конкурса %s завершена: обновлено %s, пропущено %s, сумма %s коп.', - contest_id, - stats['updated'], - stats['skipped'], - stats['total_amount'], + 'Синхронизация конкурса завершена: обновлено , пропущено , сумма коп.', + contest_id=contest_id, + stats=stats['updated'], + stats_2=stats['skipped'], + stats_3=stats['total_amount'], ) return stats @@ -861,7 +870,9 @@ async def cleanup_invalid_contest_events( if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0: contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999) - logger.info('Очистка конкурса %s: период с %s по %s', contest_id, contest_start, contest_end) + logger.info( + 'Очистка конкурса : период с по', contest_id=contest_id, contest_start=contest_start, contest_end=contest_end + ) # Считаем сколько было событий до очистки total_before_result = await db.execute( @@ -905,11 +916,11 @@ async def cleanup_invalid_contest_events( remaining = int(remaining_result.scalar_one() or 0) logger.info( - 'Очистка конкурса %s завершена: удалено %s невалидных событий, осталось %s валидных (было %s)', - contest_id, - deleted, - remaining, - total_before, + 'Очистка конкурса завершена: удалено невалидных событий, осталось валидных (было)', + contest_id=contest_id, + deleted=deleted, + remaining=remaining, + total_before=total_before, ) return { diff --git a/app/database/crud/rules.py b/app/database/crud/rules.py index a98a1b13..729f70a8 100644 --- a/app/database/crud/rules.py +++ b/app/database/crud/rules.py @@ -1,13 +1,13 @@ -import logging from datetime import datetime +import structlog from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import ServiceRule -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def get_rules_by_language(db: AsyncSession, language: str = 'ru') -> ServiceRule | None: @@ -38,7 +38,7 @@ async def create_or_update_rules( await db.commit() await db.refresh(new_rules) - logger.info(f'✅ Правила для языка {language} обновлены (ID: {new_rules.id})') + logger.info('✅ Правила для языка обновлены (ID: )', language=language, new_rules_id=new_rules.id) return new_rules @@ -53,12 +53,14 @@ async def clear_all_rules(db: AsyncSession, language: str = 'ru') -> bool: await db.commit() rows_affected = result.rowcount - logger.info(f'✅ Очищены правила для языка {language}. Деактивировано записей: {rows_affected}') + logger.info( + '✅ Очищены правила для языка . Деактивировано записей', language=language, rows_affected=rows_affected + ) return rows_affected > 0 except Exception as e: - logger.error(f'❌ Ошибка при очистке правил для языка {language}: {e}') + logger.error('❌ Ошибка при очистке правил для языка', language=language, error=e) await db.rollback() raise @@ -102,7 +104,7 @@ async def restore_rules_version(db: AsyncSession, rule_id: int, language: str = rule_to_restore = result.scalar_one_or_none() if not rule_to_restore: - logger.warning(f'Правило с ID {rule_id} не найдено для языка {language}') + logger.warning('Правило с ID не найдено для языка', rule_id=rule_id, language=language) return None await db.execute( @@ -119,11 +121,13 @@ async def restore_rules_version(db: AsyncSession, rule_id: int, language: str = await db.commit() await db.refresh(restored_rule) - logger.info(f'✅ Восстановлена версия правил ID {rule_id} как новое правило ID {restored_rule.id}') + logger.info( + '✅ Восстановлена версия правил ID как новое правило ID', rule_id=rule_id, restored_rule_id=restored_rule.id + ) return restored_rule except Exception as e: - logger.error(f'❌ Ошибка при восстановлении правил ID {rule_id}: {e}') + logger.error('❌ Ошибка при восстановлении правил ID', rule_id=rule_id, error=e) await db.rollback() raise @@ -156,5 +160,5 @@ async def get_rules_statistics(db: AsyncSession) -> dict: } except Exception as e: - logger.error(f'❌ Ошибка при получении статистики правил: {e}') + logger.error('❌ Ошибка при получении статистики правил', error=e) return {'total_active': 0, 'total_all_time': 0, 'languages': {}, 'total_languages': 0, 'error': str(e)} diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py index 181e02d9..ec3daff5 100644 --- a/app/database/crud/server_squad.py +++ b/app/database/crud/server_squad.py @@ -1,8 +1,8 @@ -import logging import random from collections.abc import Iterable, Sequence from datetime import datetime +import structlog from sqlalchemy import ( String, and_, @@ -28,7 +28,7 @@ from app.database.models import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def _get_default_promo_group_id(db: AsyncSession) -> int | None: @@ -64,7 +64,7 @@ async def create_server_squad( promo_groups = promo_groups_result.scalars().all() if len(promo_groups) != len(normalized_group_ids): - logger.warning('Не все промогруппы найдены при создании сервера %s', display_name) + logger.warning('Не все промогруппы найдены при создании сервера', display_name=display_name) server_squad = ServerSquad( squad_uuid=squad_uuid, @@ -84,7 +84,7 @@ async def create_server_squad( await db.commit() await db.refresh(server_squad) - logger.info(f'✅ Создан сервер {display_name} (UUID: {squad_uuid})') + logger.info('✅ Создан сервер (UUID: )', display_name=display_name, squad_uuid=squad_uuid) return server_squad @@ -267,13 +267,17 @@ async def delete_server_squad(db: AsyncSession, server_id: int) -> bool: connections_count = connections_result.scalar() if connections_count > 0: - logger.warning(f'⚠ Нельзя удалить сервер {server_id}: есть активные подключения ({connections_count})') + logger.warning( + '⚠ Нельзя удалить сервер есть активные подключения', + server_id=server_id, + connections_count=connections_count, + ) return False await db.execute(delete(ServerSquad).where(ServerSquad.id == server_id)) await db.commit() - logger.info(f'🗑️ Удален сервер (ID: {server_id})') + logger.info('🗑️ Удален сервер (ID: )', server_id=server_id) return True @@ -322,11 +326,7 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) -> subscription_ids = {row[0] for row in subscription_ids_result.fetchall()} for server in removed_servers: - logger.info( - '🗑️ Удаляется сервер %s (UUID: %s)', - server.display_name, - server.squad_uuid, - ) + logger.info('🗑️ Удаляется сервер (UUID:)', display_name=server.display_name, squad_uuid=server.squad_uuid) await db.execute(delete(SubscriptionServer).where(SubscriptionServer.server_squad_id.in_(removed_ids))) @@ -386,20 +386,14 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) -> removed = len(removed_servers) if cleaned_subscriptions: - logger.info( - '🧹 Обновлены подписки после удаления серверов: %s', - cleaned_subscriptions, - ) + logger.info('🧹 Обновлены подписки после удаления серверов', cleaned_subscriptions=cleaned_subscriptions) if cleaned_tariffs: - logger.info( - '🧹 Обновлены тарифы после удаления серверов: %s', - cleaned_tariffs, - ) + logger.info('🧹 Обновлены тарифы после удаления серверов', cleaned_tariffs=cleaned_tariffs) await db.commit() - logger.info(f'🔄 Синхронизация завершена: +{created} ~{updated} -{removed}') + logger.info('🔄 Синхронизация завершена: + ~', created=created, updated=updated, removed=removed) return created, updated, removed @@ -767,11 +761,11 @@ async def add_user_to_servers(db: AsyncSession, server_squad_ids: list[int]) -> ) await db.flush() - logger.info(f'✅ Увеличен счетчик пользователей для серверов: {server_squad_ids}') + logger.info('✅ Увеличен счетчик пользователей для серверов', server_squad_ids=server_squad_ids) return True except Exception as e: - logger.error(f'Ошибка увеличения счетчика пользователей: {e}') + logger.error('Ошибка увеличения счетчика пользователей', error=e) raise @@ -785,11 +779,11 @@ async def remove_user_from_servers(db: AsyncSession, server_squad_ids: list[int] ) await db.flush() - logger.info(f'✅ Уменьшен счетчик пользователей для серверов: {server_squad_ids}') + logger.info('✅ Уменьшен счетчик пользователей для серверов', server_squad_ids=server_squad_ids) return True except Exception as e: - logger.error(f'Ошибка уменьшения счетчика пользователей: {e}') + logger.error('Ошибка уменьшения счетчика пользователей', error=e) raise @@ -836,12 +830,12 @@ async def update_server_user_counts( await db.flush() if add_set: - logger.info('✅ Увеличен счетчик пользователей для серверов: %s', sorted(add_set)) + logger.info('✅ Увеличен счетчик пользователей для серверов', sorted=sorted(add_set)) if remove_set: - logger.info('✅ Уменьшен счетчик пользователей для серверов: %s', sorted(remove_set)) + logger.info('✅ Уменьшен счетчик пользователей для серверов', sorted=sorted(remove_set)) except Exception as e: - logger.error('Ошибка обновления счетчиков серверов: %s', e) + logger.error('Ошибка обновления счетчиков серверов', e=e) raise @@ -875,7 +869,7 @@ async def ensure_servers_synced(db: AsyncSession) -> None: server_count = result.scalar() or 0 if server_count > 0: - logger.info(f'✅ В базе уже есть {server_count} серверов, пропускаем синхронизацию') + logger.info('✅ В базе уже есть серверов, пропускаем синхронизацию', server_count=server_count) return logger.info('🔄 Серверов в БД нет, начинаем синхронизацию с RemnaWave...') @@ -900,10 +894,10 @@ async def ensure_servers_synced(db: AsyncSession) -> None: # Синхронизируем created, updated, removed = await sync_with_remnawave(db, squads) - logger.info(f'✅ Серверы синхронизированы: +{created} ~{updated} -{removed}') + logger.info('✅ Серверы синхронизированы: + ~', created=created, updated=updated, removed=removed) except Exception as e: - logger.error(f'❌ Ошибка синхронизации серверов: {e}') + logger.error('❌ Ошибка синхронизации серверов', error=e) async def sync_server_user_counts(db: AsyncSession) -> int: @@ -911,7 +905,7 @@ async def sync_server_user_counts(db: AsyncSession) -> int: all_servers_result = await db.execute(select(ServerSquad.id, ServerSquad.squad_uuid)) all_servers = all_servers_result.fetchall() - logger.info(f'🔍 Найдено серверов для синхронизации: {len(all_servers)}') + logger.info('🔍 Найдено серверов для синхронизации', all_servers_count=len(all_servers)) updated_count = 0 for server_id, squad_uuid in all_servers: @@ -926,16 +920,18 @@ async def sync_server_user_counts(db: AsyncSession) -> int: ) actual_users = count_result.scalar() or 0 - logger.info(f'📊 Сервер {server_id} ({squad_uuid[:8]}): {actual_users} пользователей') + logger.info( + '📊 Сервер пользователей', server_id=server_id, squad_uuid=squad_uuid[:8], actual_users=actual_users + ) await db.execute(update(ServerSquad).where(ServerSquad.id == server_id).values(current_users=actual_users)) updated_count += 1 await db.commit() - logger.info(f'✅ Синхронизированы счетчики для {updated_count} серверов') + logger.info('✅ Синхронизированы счетчики для серверов', updated_count=updated_count) return updated_count except Exception as e: - logger.error(f'Ошибка синхронизации счетчиков пользователей: {e}') + logger.error('Ошибка синхронизации счетчиков пользователей', error=e) await db.rollback() return 0 diff --git a/app/database/crud/squad.py b/app/database/crud/squad.py index 4ac97e75..3e518d8e 100644 --- a/app/database/crud/squad.py +++ b/app/database/crud/squad.py @@ -1,12 +1,11 @@ -import logging - +import structlog from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import Squad -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def get_squad_by_uuid(db: AsyncSession, uuid: str) -> Squad | None: @@ -28,7 +27,7 @@ async def create_squad( await db.commit() await db.refresh(squad) - logger.info(f'✅ Создан сквад: {name}') + logger.info('✅ Создан сквад', name=name) return squad diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index d47f2085..20078348 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -1,8 +1,8 @@ -import logging from collections.abc import Iterable from datetime import UTC, datetime, timedelta from typing import Optional +import structlog from sqlalchemy import and_, delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -22,7 +22,7 @@ from app.utils.pricing_utils import calculate_months_from_days, get_remaining_mo from app.utils.timezone import format_local_datetime -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) _WEBHOOK_GUARD_SECONDS = 60 @@ -50,7 +50,10 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscri if subscription: logger.info( - f'🔍 Загружена подписка {subscription.id} для пользователя {user_id}, статус: {subscription.status}' + '🔍 Загружена подписка для пользователя статус', + subscription_id=subscription.id, + user_id=user_id, + status=subscription.status, ) subscription = await check_and_update_subscription_status(db, subscription) @@ -93,16 +96,10 @@ async def create_trial_subscription( if random_squad: final_squads = [random_squad] logger.debug( - 'Выбран сквад %s для триальной подписки пользователя %s', - random_squad, - user_id, + 'Выбран сквад для триальной подписки пользователя', random_squad=random_squad, user_id=user_id ) except Exception as error: - logger.error( - 'Не удалось получить сквад для триальной подписки пользователя %s: %s', - user_id, - error, - ) + logger.error('Не удалось получить сквад для триальной подписки пользователя', user_id=user_id, error=error) end_date = datetime.utcnow() + timedelta(days=duration_days) @@ -119,9 +116,7 @@ async def create_trial_subscription( await db.commit() await db.refresh(existing) logger.info( - '🎁 Обновлена PENDING триальная подписка %s для пользователя %s', - existing.id, - user_id, + '🎁 Обновлена PENDING триальная подписка для пользователя', existing_id=existing.id, user_id=user_id ) return existing @@ -157,20 +152,14 @@ async def create_trial_subscription( server_ids = await get_server_ids_by_uuids(db, final_squads) if server_ids: await add_user_to_servers(db, server_ids) - logger.info( - '📈 Обновлен счетчик пользователей для триальных сквадов %s', - final_squads, - ) + logger.info('📈 Обновлен счетчик пользователей для триальных сквадов', final_squads=final_squads) else: - logger.warning( - '⚠️ Не удалось найти серверы для обновления счетчика (сквады %s)', - final_squads, - ) + logger.warning('⚠️ Не удалось найти серверы для обновления счетчика (сквады)', final_squads=final_squads) except Exception as error: logger.error( - '⚠️ Ошибка обновления счетчика пользователей для триальных сквадов %s: %s', - final_squads, - error, + '⚠️ Ошибка обновления счетчика пользователей для триальных сквадов', + final_squads=final_squads, + error=error, ) return subscription @@ -211,7 +200,10 @@ async def create_paid_subscription( await db.refresh(subscription) logger.info( - f'💎 Создана платная подписка для пользователя {user_id}, ID: {subscription.id}, статус: {subscription.status}' + '💎 Создана платная подписка для пользователя ID: статус', + user_id=user_id, + subscription_id=subscription.id, + status=subscription.status, ) squad_uuids = list(connected_squads or []) @@ -226,21 +218,21 @@ async def create_paid_subscription( if server_ids: await add_user_to_servers(db, server_ids) logger.info( - '📈 Обновлен счетчик пользователей для платной подписки пользователя %s (сквады: %s)', - user_id, - squad_uuids, + '📈 Обновлен счетчик пользователей для платной подписки пользователя (сквады:)', + user_id=user_id, + squad_uuids=squad_uuids, ) else: logger.warning( - '⚠️ Не удалось найти серверы для обновления счетчика платной подписки пользователя %s (сквады: %s)', - user_id, - squad_uuids, + '⚠️ Не удалось найти серверы для обновления счетчика платной подписки пользователя (сквады:)', + user_id=user_id, + squad_uuids=squad_uuids, ) except Exception as error: logger.error( - '⚠️ Ошибка обновления счетчика пользователей серверов для платной подписки пользователя %s: %s', - user_id, - error, + '⚠️ Ошибка обновления счетчика пользователей серверов для платной подписки пользователя', + user_id=user_id, + error=error, ) return subscription @@ -313,16 +305,16 @@ async def replace_subscription( ) logger.info( - '♻️ Обновлены параметры подписки %s: удалено сквадов %s, добавлено %s', - subscription.id, - len(squads_to_remove), - len(squads_to_add), + '♻️ Обновлены параметры подписки : удалено сквадов , добавлено', + subscription_id=subscription.id, + squads_to_remove_count=len(squads_to_remove), + squads_to_add_count=len(squads_to_add), ) except Exception as error: logger.error( - '⚠️ Ошибка обновления счетчиков серверов при замене подписки %s: %s', - subscription.id, - error, + '⚠️ Ошибка обновления счетчиков серверов при замене подписки', + subscription_id=subscription.id, + error=error, ) return subscription @@ -351,9 +343,12 @@ async def extend_subscription( """ current_time = datetime.utcnow() - logger.info(f'🔄 Продление подписки {subscription.id} на {days} дней') + logger.info('🔄 Продление подписки на дней', subscription_id=subscription.id, days=days) logger.info( - f'📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}, тариф={subscription.tariff_id}' + '📊 Текущие параметры: статус=, окончание=, тариф', + status=subscription.status, + end_date=subscription.end_date, + tariff_id=subscription.tariff_id, ) # Определяем, происходит ли СМЕНА тарифа (а не продление того же) @@ -361,7 +356,7 @@ async def extend_subscription( is_tariff_change = tariff_id is not None and (subscription.tariff_id is None or tariff_id != subscription.tariff_id) if is_tariff_change: - logger.info(f'🔄 Обнаружена СМЕНА тарифа: {subscription.tariff_id} → {tariff_id}') + logger.info('🔄 Обнаружена СМЕНА тарифа: →', tariff_id=subscription.tariff_id, tariff_id_2=tariff_id) # Бонусные дни от триала - добавляются ТОЛЬКО когда подписка истекла # и мы начинаем отсчёт с текущей даты. НЕ начисляются при смене тарифа. @@ -371,9 +366,7 @@ async def extend_subscription( if days < 0: subscription.end_date = subscription.end_date + timedelta(days=days) logger.info( - '📅 Срок подписки уменьшен на %s дней, новая дата окончания: %s', - abs(days), - subscription.end_date, + '📅 Срок подписки уменьшен на дней, новая дата окончания', abs=abs(days), end_date=subscription.end_date ) elif is_tariff_change: # При СМЕНЕ тарифа срок начинается с текущей даты + бонус от триала @@ -383,19 +376,19 @@ async def extend_subscription( if remaining.total_seconds() > 0: bonus_days = max(0, remaining.days) logger.info( - '🎁 Обнаружен остаток триала: %s дней для подписки %s', - bonus_days, - subscription.id, + '🎁 Обнаружен остаток триала: дней для подписки', + bonus_days=bonus_days, + subscription_id=subscription.id, ) total_days = days + bonus_days subscription.end_date = current_time + timedelta(days=total_days) subscription.start_date = current_time - logger.info(f'📅 СМЕНА тарифа: срок начинается с текущей даты + {total_days} дней') + logger.info('📅 СМЕНА тарифа: срок начинается с текущей даты + дней', total_days=total_days) elif subscription.end_date > current_time: # Подписка активна - просто добавляем дни к текущей дате окончания # БЕЗ бонусных дней (они уже учтены в end_date) subscription.end_date = subscription.end_date + timedelta(days=days) - logger.info(f'📅 Подписка активна, добавляем {days} дней к текущей дате окончания') + logger.info('📅 Подписка активна, добавляем дней к текущей дате окончания', days=days) else: # Подписка истекла - начинаем с текущей даты + бонус от триала if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: @@ -403,14 +396,19 @@ async def extend_subscription( pass total_days = days + bonus_days subscription.end_date = current_time + timedelta(days=total_days) - logger.info(f'📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней') + logger.info('📅 Подписка истекла, устанавливаем новую дату окончания на дней', total_days=total_days) # УДАЛЕНО: Автоматическая конвертация триала по длительности # Теперь триал конвертируется ТОЛЬКО после успешного коммита продления # и ТОЛЬКО вызывающей функцией (например, _auto_extend_subscription) # Логируем статус подписки перед проверкой - logger.info(f'🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}') + logger.info( + '🔄 Продление подписки текущий статус: дни', + subscription_id=subscription.id, + status=subscription.status, + days=days, + ) if days > 0 and subscription.status in ( SubscriptionStatus.EXPIRED.value, @@ -419,23 +417,21 @@ async def extend_subscription( previous_status = subscription.status subscription.status = SubscriptionStatus.ACTIVE.value logger.info( - '🔄 Статус подписки %s изменён с %s на ACTIVE', - subscription.id, - previous_status, + '🔄 Статус подписки изменён с на ACTIVE', subscription_id=subscription.id, previous_status=previous_status ) elif days > 0 and subscription.status == SubscriptionStatus.PENDING.value: - logger.warning('⚠️ Попытка продлить PENDING подписку %s, дни: %s', subscription.id, days) + logger.warning('⚠️ Попытка продлить PENDING подписку , дни', subscription_id=subscription.id, days=days) # Обновляем параметры тарифа, если переданы if tariff_id is not None: old_tariff_id = subscription.tariff_id subscription.tariff_id = tariff_id - logger.info(f'📦 Обновлен тариф подписки: {old_tariff_id} → {tariff_id}') + logger.info('📦 Обновлен тариф подписки: →', old_tariff_id=old_tariff_id, tariff_id=tariff_id) # При покупке тарифа сбрасываем триальный статус if subscription.is_trial: subscription.is_trial = False - logger.info(f'🎓 Подписка {subscription.id} конвертирована из триала в платную') + logger.info('🎓 Подписка конвертирована из триала в платную', subscription_id=subscription.id) if traffic_limit_gb is not None: old_traffic = subscription.traffic_limit_gb @@ -452,14 +448,19 @@ async def extend_subscription( subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None logger.info( - f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (смена тарифа, докупки сброшены)' + '📊 Обновлен лимит трафика: ГБ → ГБ (смена тарифа, докупки сброшены)', + old_traffic=old_traffic, + traffic_limit_gb=traffic_limit_gb, ) else: # При ПРОДЛЕНИИ того же тарифа — сохраняем докупленный трафик purchased = subscription.purchased_traffic_gb or 0 subscription.traffic_limit_gb = traffic_limit_gb + purchased logger.info( - f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb + purchased} ГБ (докупки сохранены: {purchased} ГБ)' + '📊 Обновлен лимит трафика: ГБ → ГБ (докупки сохранены: ГБ)', + old_traffic=old_traffic, + traffic_limit_gb=traffic_limit_gb + purchased, + purchased=purchased, ) elif settings.RESET_TRAFFIC_ON_PAYMENT: subscription.traffic_used_gb = 0.0 @@ -475,12 +476,12 @@ async def extend_subscription( if device_limit is not None: old_devices = subscription.device_limit subscription.device_limit = device_limit - logger.info(f'📱 Обновлен лимит устройств: {old_devices} → {device_limit}') + logger.info('📱 Обновлен лимит устройств: →', old_devices=old_devices, device_limit=device_limit) if connected_squads is not None: old_squads = subscription.connected_squads subscription.connected_squads = connected_squads - logger.info(f'🌍 Обновлены сквады: {old_squads} → {connected_squads}') + logger.info('🌍 Обновлены сквады: →', old_squads=old_squads, connected_squads=connected_squads) # Обработка daily полей при смене тарифа if is_tariff_change and tariff_id is not None: @@ -514,7 +515,11 @@ async def extend_subscription( subscription.traffic_limit_gb = fixed_limit subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика - logger.info(f'🔄 Сброс трафика при продлении (fixed_with_topup): {old_limit} ГБ → {fixed_limit} ГБ') + logger.info( + '🔄 Сброс трафика при продлении (fixed_with_topup): ГБ → ГБ', + old_limit=old_limit, + fixed_limit=fixed_limit, + ) subscription.updated_at = current_time @@ -522,8 +527,8 @@ async def extend_subscription( await db.refresh(subscription) await clear_notifications(db, subscription.id) - logger.info(f'✅ Подписка продлена до: {subscription.end_date}') - logger.info(f'📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}') + logger.info('✅ Подписка продлена до', end_date=subscription.end_date) + logger.info('📊 Новые параметры: статус=, окончание', status=subscription.status, end_date=subscription.end_date) return subscription @@ -570,7 +575,10 @@ async def add_subscription_traffic(db: AsyncSession, subscription: Subscription, await db.refresh(subscription) logger.info( - f'📈 К подписке пользователя {subscription.user_id} добавлено {gb} ГБ трафика (истекает {new_expires_at.strftime("%d.%m.%Y")})' + '📈 К подписке пользователя добавлено ГБ трафика (истекает )', + user_id=subscription.user_id, + gb=gb, + new_expires_at=new_expires_at.strftime('%d.%m.%Y'), ) return subscription @@ -582,7 +590,7 @@ async def add_subscription_devices(db: AsyncSession, subscription: Subscription, await db.commit() await db.refresh(subscription) - logger.info(f'📱 К подписке пользователя {subscription.user_id} добавлено {devices} устройств') + logger.info('📱 К подписке пользователя добавлено устройств', user_id=subscription.user_id, devices=devices) return subscription @@ -594,7 +602,7 @@ async def add_subscription_squad(db: AsyncSession, subscription: Subscription, s await db.commit() await db.refresh(subscription) - logger.info(f'🌍 К подписке пользователя {subscription.user_id} добавлен сквад {squad_uuid}') + logger.info('🌍 К подписке пользователя добавлен сквад', user_id=subscription.user_id, squad_uuid=squad_uuid) return subscription @@ -609,7 +617,7 @@ async def remove_subscription_squad(db: AsyncSession, subscription: Subscription await db.commit() await db.refresh(subscription) - logger.info(f'🚫 Из подписки пользователя {subscription.user_id} удален сквад {squad_uuid}') + logger.info('🚫 Из подписки пользователя удален сквад', user_id=subscription.user_id, squad_uuid=squad_uuid) return subscription @@ -639,11 +647,7 @@ async def decrement_subscription_server_counts( ids_from_links = await get_subscription_server_ids(db, sub_id) server_ids.update(ids_from_links) except Exception as error: - logger.error( - '⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s', - sub_id, - error, - ) + logger.error('⚠️ Не удалось получить серверы подписки для уменьшения счетчика', sub_id=sub_id, error=error) connected_squads = list(subscription.connected_squads or []) if connected_squads: @@ -653,11 +657,7 @@ async def decrement_subscription_server_counts( squad_server_ids = await get_server_ids_by_uuids(db, connected_squads) server_ids.update(squad_server_ids) except Exception as error: - logger.error( - '⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s', - sub_id, - error, - ) + logger.error('⚠️ Не удалось сопоставить сквады подписки с серверами', sub_id=sub_id, error=error) if not server_ids: return @@ -670,16 +670,16 @@ async def decrement_subscription_server_counts( await remove_user_from_servers(db, list(server_ids)) except StaleDataError: logger.warning( - '⚠️ Подписка %s уже удалена (StaleDataError), пропускаем декремент серверов %s', - sub_id, - list(server_ids), + '⚠️ Подписка уже удалена (StaleDataError), пропускаем декремент серверов', + sub_id=sub_id, + list=list(server_ids), ) except Exception as error: logger.error( - '⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s', - list(server_ids), - sub_id, - error, + '⚠️ Ошибка уменьшения счетчика пользователей серверов для подписки', + list=list(server_ids), + sub_id=sub_id, + error=error, ) @@ -694,7 +694,7 @@ async def update_subscription_autopay( await db.refresh(subscription) status = 'включен' if enabled else 'выключен' - logger.info(f'💳 Автоплатеж для подписки пользователя {subscription.user_id} {status}') + logger.info('💳 Автоплатеж для подписки пользователя', user_id=subscription.user_id, status=status) return subscription @@ -705,7 +705,7 @@ async def deactivate_subscription(db: AsyncSession, subscription: Subscription) await db.commit() await db.refresh(subscription) - logger.info(f'❌ Подписка пользователя {subscription.user_id} деактивирована') + logger.info('❌ Подписка пользователя деактивирована', user_id=subscription.user_id) return subscription @@ -844,9 +844,9 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict: renewals_count = conversion_stats.get('month_conversions', 0) logger.info('📊 Статистика конверсии из таблицы conversions:') - logger.info(f' Общее количество конверсий: {conversion_stats.get("total_conversions", 0)}') - logger.info(f' Процент конверсии: {trial_to_paid_conversion}%') - logger.info(f' Конверсий за месяц: {renewals_count}') + logger.info('Общее количество конверсий', get=conversion_stats.get('total_conversions', 0)) + logger.info('Процент конверсии', trial_to_paid_conversion=trial_to_paid_conversion) + logger.info('Конверсий за месяц', renewals_count=renewals_count) except ImportError: logger.warning('⚠️ Таблица subscription_conversions не найдена, используем старую логику') @@ -943,9 +943,7 @@ async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> ) except Exception as error: # pragma: no cover - defensive logging logger.error( - 'Не удалось обновить счётчики серверов при сбросе триала %s: %s', - subscription.id, - error, + 'Не удалось обновить счётчики серверов при сбросе триала', subscription_id=subscription.id, error=error ) subscription_ids = [subscription.id for subscription in subscriptions] @@ -954,11 +952,7 @@ async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> try: await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id.in_(subscription_ids))) except Exception as error: # pragma: no cover - defensive logging - logger.error( - 'Ошибка удаления серверных связей триалов %s: %s', - subscription_ids, - error, - ) + logger.error('Ошибка удаления серверных связей триалов', subscription_ids=subscription_ids, error=error) raise await db.execute(delete(Subscription).where(Subscription.id.in_(subscription_ids))) @@ -967,10 +961,10 @@ async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> await db.commit() except Exception as error: # pragma: no cover - defensive logging await db.rollback() - logger.error('Ошибка сохранения сброса триалов: %s', error) + logger.error('Ошибка сохранения сброса триалов', error=error) raise - logger.info('♻️ Сброшено триальных подписок: %s', reset_count) + logger.info('♻️ Сброшено триальных подписок', reset_count=reset_count) return reset_count @@ -1047,7 +1041,12 @@ async def add_subscription_servers( await db.commit() await db.refresh(subscription) - logger.info(f'🌐 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}') + logger.info( + '🌐 К подписке добавлено серверов с ценами', + subscription_id=subscription.id, + server_squad_ids_count=len(server_squad_ids), + paid_prices=paid_prices, + ) return subscription @@ -1084,7 +1083,7 @@ async def get_servers_monthly_prices( user_promo_group = user.get_primary_promo_group() user_promo_group_id = user_promo_group.id if user_promo_group else None except Exception as e: - logger.warning(f'Не удалось получить промогруппу пользователя: {e}') + logger.warning('Не удалось получить промогруппу пользователя', error=e) for server_id in server_squad_ids: # Загружаем сервер с промогруппами @@ -1110,9 +1109,11 @@ async def get_servers_monthly_prices( else: # Сервер недоступен для промогруппы пользователя logger.warning( - f'⚠️ Сервер {server.display_name} (id={server_id}) недоступен для ' - f'промогруппы пользователя (promo_group_id={user_promo_group_id}), ' - f'allowed_promo_groups={[pg.id for pg in server.allowed_promo_groups] if server.allowed_promo_groups else []}' + '⚠️ Сервер (id=) недоступен для промогруппы пользователя (promo_group_id=), allowed_promo_groups', + display_name=server.display_name, + server_id=server_id, + user_promo_group_id=user_promo_group_id, + value=[pg.id for pg in server.allowed_promo_groups] if server.allowed_promo_groups else [], ) prices.append(server.price_kopeks) # Всё равно берём реальную цену @@ -1227,8 +1228,10 @@ async def calculate_subscription_total_cost( ], } - logger.debug(f'📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):') - logger.debug(f' Базовый период: {base_price / 100}₽') + logger.debug( + '📊 Расчет стоимости подписки на дней ( мес)', period_days=period_days, months_in_period=months_in_period + ) + logger.debug('Базовый период: ₽', base_price=base_price / 100) if total_traffic_price > 0: message = f' Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period} = {total_traffic_price / 100}₽' if total_traffic_discount > 0: @@ -1248,7 +1251,7 @@ async def calculate_subscription_total_cost( if total_devices_discount > 0: message += f' (скидка {devices_discount_percent}%: -{total_devices_discount / 100}₽)' logger.debug(message) - logger.debug(f' ИТОГО: {total_cost / 100}₽') + logger.debug('ИТОГО: ₽', total_cost=total_cost / 100) return total_cost, details @@ -1300,11 +1303,11 @@ async def remove_subscription_servers(db: AsyncSession, subscription_id: int, se ) await db.commit() - logger.info(f'🗑️ Удалены серверы {server_squad_ids} из подписки {subscription_id}') + logger.info('🗑️ Удалены серверы из подписки', server_squad_ids=server_squad_ids, subscription_id=subscription_id) return True except Exception as e: - logger.error(f'Ошибка удаления серверов из подписки: {e}') + logger.error('Ошибка удаления серверов из подписки', error=e) await db.rollback() return False @@ -1395,8 +1398,13 @@ async def get_subscription_renewal_cost( total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost - logger.info(f'💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):') - logger.info(f' 📅 Период: {base_price / 100}₽') + logger.info( + '💰 Расчет продления подписки на дней ( мес)', + subscription_id=subscription_id, + period_days=period_days, + months_in_period=months_in_period, + ) + logger.info('📅 Период: ₽', base_price=base_price / 100) if total_servers_cost > 0: message = f' 🌍 Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period} = {total_servers_cost / 100}₽' if total_servers_discount > 0: @@ -1414,12 +1422,12 @@ async def get_subscription_renewal_cost( if total_devices_discount > 0: message += f' (скидка {devices_discount_percent}%: -{total_devices_discount / 100}₽)' logger.info(message) - logger.info(f' 💎 ИТОГО: {total_cost / 100}₽') + logger.info('💎 ИТОГО: ₽', total_cost=total_cost / 100) return total_cost except Exception as e: - logger.error(f'Ошибка расчета стоимости продления: {e}') + logger.error('Ошибка расчета стоимости продления', error=e) from app.config import PERIOD_PRICES return PERIOD_PRICES.get(period_days, 0) @@ -1508,7 +1516,7 @@ async def calculate_addon_cost_for_remaining_period( ) logger.info(message) - logger.info(f'💰 Итого доплата за {months_to_pay} мес: {total_cost / 100}₽') + logger.info('💰 Итого доплата за мес: ₽', months_to_pay=months_to_pay, total_cost=total_cost / 100) return total_cost @@ -1519,7 +1527,7 @@ async def expire_subscription(db: AsyncSession, subscription: Subscription) -> S await db.commit() await db.refresh(subscription) - logger.info(f'⏰ Подписка пользователя {subscription.user_id} помечена как истёкшая') + logger.info('⏰ Подписка пользователя помечена как истёкшая', user_id=subscription.user_id) return subscription @@ -1527,28 +1535,30 @@ async def check_and_update_subscription_status(db: AsyncSession, subscription: S current_time = datetime.utcnow() logger.info( - '🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s', - subscription.id, - subscription.status, - format_local_datetime(subscription.end_date), - format_local_datetime(current_time), + '🔍 Проверка статуса подписки , текущий статус дата окончания текущее время', + subscription_id=subscription.id, + subscription_status=subscription.status, + format_local_datetime=format_local_datetime(subscription.end_date), + format_local_datetime_2=format_local_datetime(current_time), ) # Для суточных тарифов с паузой не меняем статус на expired # (время "заморожено" пока пользователь на паузе) is_daily_paused = getattr(subscription, 'is_daily_paused', False) if is_daily_paused: - logger.info(f'⏸️ Суточная подписка {subscription.id} на паузе, пропускаем проверку истечения') + logger.info('⏸️ Суточная подписка на паузе, пропускаем проверку истечения', subscription_id=subscription.id) return subscription if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time: # Детальное логирование для отладки проблемы с деактивацией time_diff = current_time - subscription.end_date logger.warning( - f'⏰ DEACTIVATION: подписка {subscription.id} (user_id={subscription.user_id}) ' - f'деактивируется в check_and_update_subscription_status. ' - f'end_date={subscription.end_date}, current_time={current_time}, ' - f'просрочена на {time_diff}' + '⏰ DEACTIVATION: подписка (user_id=) деактивируется в check_and_update_subscription_status. end_date=, current_time=, просрочена на', + subscription_id=subscription.id, + user_id=subscription.user_id, + end_date=subscription.end_date, + current_time=current_time, + time_diff=time_diff, ) subscription.status = SubscriptionStatus.EXPIRED.value @@ -1557,9 +1567,9 @@ async def check_and_update_subscription_status(db: AsyncSession, subscription: S await db.commit() await db.refresh(subscription) - logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'") + logger.info("⏰ Статус подписки пользователя изменен на 'expired'", user_id=subscription.user_id) elif subscription.status == SubscriptionStatus.PENDING.value: - logger.info(f'ℹ️ Проверка PENDING подписки {subscription.id}, статус остается без изменений') + logger.info('ℹ️ Проверка PENDING подписки статус остается без изменений', subscription_id=subscription.id) return subscription @@ -1614,7 +1624,7 @@ async def create_subscription_no_commit( await db.flush() # Не коммитим сразу, оставляем для пакетной обработки - logger.info(f'✅ Подготовлена подписка для пользователя {user_id} (ожидает коммита)') + logger.info('✅ Подготовлена подписка для пользователя (ожидает коммита)', user_id=user_id) return subscription @@ -1662,7 +1672,7 @@ async def create_subscription( await db.commit() await db.refresh(subscription) - logger.info(f'✅ Создана подписка для пользователя {user_id}') + logger.info('✅ Создана подписка для пользователя', user_id=user_id) return subscription @@ -1694,9 +1704,9 @@ async def create_pending_subscription( and existing_subscription.end_date > current_time ): logger.warning( - '⚠️ Попытка создать pending %sподписку для активного пользователя %s. Возвращаем существующую запись.', - trial_label, - user_id, + '⚠️ Попытка создать pending подписку для активного пользователя . Возвращаем существующую запись.', + trial_label=trial_label, + user_id=user_id, ) return existing_subscription @@ -1714,11 +1724,11 @@ async def create_pending_subscription( await db.refresh(existing_subscription) logger.info( - '♻️ Обновлена ожидающая %sподписка пользователя %s, ID: %s, метод оплаты: %s', - trial_label, - user_id, - existing_subscription.id, - payment_method, + '♻️ Обновлена ожидающая подписка пользователя , ID метод оплаты', + trial_label=trial_label, + user_id=user_id, + existing_subscription_id=existing_subscription.id, + payment_method=payment_method, ) return existing_subscription @@ -1740,11 +1750,11 @@ async def create_pending_subscription( await db.refresh(subscription) logger.info( - '💳 Создана ожидающая %sподписка для пользователя %s, ID: %s, метод оплаты: %s', - trial_label, - user_id, - subscription.id, - payment_method, + '💳 Создана ожидающая подписка для пользователя , ID метод оплаты', + trial_label=trial_label, + user_id=user_id, + subscription_id=subscription.id, + payment_method=payment_method, ) return subscription @@ -1777,7 +1787,7 @@ async def create_pending_trial_subscription( async def activate_pending_subscription(db: AsyncSession, user_id: int, period_days: int = None) -> Subscription | None: """Активирует pending подписку пользователя, меняя её статус на ACTIVE.""" - logger.info(f'Активация pending подписки: пользователь {user_id}, период {period_days} дней') + logger.info('Активация pending подписки: пользователь период дней', user_id=user_id, period_days=period_days) # Находим pending подписку пользователя result = await db.execute( @@ -1788,11 +1798,14 @@ async def activate_pending_subscription(db: AsyncSession, user_id: int, period_d pending_subscription = result.scalar_one_or_none() if not pending_subscription: - logger.warning(f'Не найдена pending подписка для пользователя {user_id}') + logger.warning('Не найдена pending подписка для пользователя', user_id=user_id) return None logger.info( - f'Найдена pending подписка {pending_subscription.id} для пользователя {user_id}, статус: {pending_subscription.status}' + 'Найдена pending подписка для пользователя статус', + pending_subscription_id=pending_subscription.id, + user_id=user_id, + status=pending_subscription.status, ) # Обновляем статус подписки на ACTIVE @@ -1812,7 +1825,9 @@ async def activate_pending_subscription(db: AsyncSession, user_id: int, period_d await db.commit() await db.refresh(pending_subscription) - logger.info(f'Подписка пользователя {user_id} активирована, ID: {pending_subscription.id}') + logger.info( + 'Подписка пользователя активирована, ID', user_id=user_id, pending_subscription_id=pending_subscription.id + ) return pending_subscription @@ -1823,7 +1838,11 @@ async def activate_pending_trial_subscription( user_id: int, ) -> Subscription | None: """Активирует pending триальную подписку по её ID после оплаты.""" - logger.info(f'Активация pending триальной подписки: subscription_id={subscription_id}, user_id={user_id}') + logger.info( + 'Активация pending триальной подписки: subscription_id=, user_id', + subscription_id=subscription_id, + user_id=user_id, + ) # Находим pending подписку по ID result = await db.execute( @@ -1839,10 +1858,16 @@ async def activate_pending_trial_subscription( pending_subscription = result.scalar_one_or_none() if not pending_subscription: - logger.warning(f'Не найдена pending триальная подписка {subscription_id} для пользователя {user_id}') + logger.warning( + 'Не найдена pending триальная подписка для пользователя', subscription_id=subscription_id, user_id=user_id + ) return None - logger.info(f'Найдена pending триальная подписка {pending_subscription.id}, статус: {pending_subscription.status}') + logger.info( + 'Найдена pending триальная подписка статус', + pending_subscription_id=pending_subscription.id, + status=pending_subscription.status, + ) # Обновляем статус подписки на ACTIVE current_time = datetime.utcnow() @@ -1865,7 +1890,11 @@ async def activate_pending_trial_subscription( await db.commit() await db.refresh(pending_subscription) - logger.info(f'Триальная подписка {pending_subscription.id} активирована для пользователя {user_id}') + logger.info( + 'Триальная подписка активирована для пользователя', + pending_subscription_id=pending_subscription.id, + user_id=user_id, + ) return pending_subscription @@ -1911,7 +1940,7 @@ async def get_daily_subscriptions_for_charge(db: AsyncSession) -> list[Subscript result = await db.execute(query) subscriptions = result.scalars().all() - logger.info(f'🔍 Найдено {len(subscriptions)} суточных подписок для списания') + logger.info('🔍 Найдено суточных подписок для списания', subscriptions_count=len(subscriptions)) return list(subscriptions) @@ -1948,7 +1977,7 @@ async def get_disabled_daily_subscriptions_for_resume( result = await db.execute(query) subscriptions = result.scalars().all() - logger.info(f'🔍 Найдено {len(subscriptions)} DISABLED суточных подписок для возобновления') + logger.info('🔍 Найдено DISABLED суточных подписок для возобновления', subscriptions_count=len(subscriptions)) return list(subscriptions) @@ -1959,14 +1988,18 @@ async def pause_daily_subscription( ) -> Subscription: """Приостанавливает суточную подписку (списание не будет происходить).""" if not subscription.is_daily_tariff: - logger.warning(f'Попытка приостановить не-суточную подписку {subscription.id}') + logger.warning('Попытка приостановить не-суточную подписку', subscription_id=subscription.id) return subscription subscription.is_daily_paused = True await db.commit() await db.refresh(subscription) - logger.info(f'⏸️ Суточная подписка {subscription.id} приостановлена пользователем {subscription.user_id}') + logger.info( + '⏸️ Суточная подписка приостановлена пользователем', + subscription_id=subscription.id, + user_id=subscription.user_id, + ) return subscription @@ -1977,7 +2010,7 @@ async def resume_daily_subscription( ) -> Subscription: """Возобновляет суточную подписку (списание продолжится).""" if not subscription.is_daily_tariff: - logger.warning(f'Попытка возобновить не-суточную подписку {subscription.id}') + logger.warning('Попытка возобновить не-суточную подписку', subscription_id=subscription.id) return subscription subscription.is_daily_paused = False @@ -1989,12 +2022,18 @@ async def resume_daily_subscription( # Обновляем время последнего списания для корректного расчёта следующего subscription.last_daily_charge_at = datetime.utcnow() subscription.end_date = datetime.utcnow() + timedelta(days=1) - logger.info(f'✅ Суточная подписка {subscription.id} восстановлена из {previous_status} в ACTIVE') + logger.info( + '✅ Суточная подписка восстановлена из в ACTIVE', + subscription_id=subscription.id, + previous_status=previous_status, + ) await db.commit() await db.refresh(subscription) - logger.info(f'▶️ Суточная подписка {subscription.id} возобновлена пользователем {subscription.user_id}') + logger.info( + '▶️ Суточная подписка возобновлена пользователем', subscription_id=subscription.id, user_id=subscription.user_id + ) return subscription @@ -2012,7 +2051,7 @@ async def update_daily_charge_time( new_end_date = now + timedelta(days=1) if subscription.end_date is None or subscription.end_date < new_end_date: subscription.end_date = new_end_date - logger.info(f'📅 Продлена подписка {subscription.id} до {new_end_date}') + logger.info('📅 Продлена подписка до', subscription_id=subscription.id, new_end_date=new_end_date) await db.commit() await db.refresh(subscription) @@ -2033,7 +2072,9 @@ async def suspend_daily_subscription_insufficient_balance( await db.refresh(subscription) logger.info( - f'⚠️ Суточная подписка {subscription.id} приостановлена: недостаточно средств (user_id={subscription.user_id})' + '⚠️ Суточная подписка приостановлена: недостаточно средств (user_id=)', + subscription_id=subscription.id, + user_id=subscription.user_id, ) return subscription diff --git a/app/database/crud/subscription_conversion.py b/app/database/crud/subscription_conversion.py index 62d78743..187c7a92 100644 --- a/app/database/crud/subscription_conversion.py +++ b/app/database/crud/subscription_conversion.py @@ -1,13 +1,13 @@ -import logging from datetime import datetime, timedelta +import structlog from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import SubscriptionConversion, User -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def create_subscription_conversion( @@ -32,7 +32,11 @@ async def create_subscription_conversion( await db.refresh(conversion) logger.info( - f'✅ Создана запись о конверсии для пользователя {user_id}: {trial_duration_days} дн. → {first_paid_period_days} дн. за {first_payment_amount_kopeks / 100}₽' + '✅ Создана запись о конверсии для пользователя дн. → дн. за ₽', + user_id=user_id, + trial_duration_days=trial_duration_days, + first_paid_period_days=first_paid_period_days, + first_payment_amount_kopeks=first_payment_amount_kopeks / 100, ) return conversion @@ -85,9 +89,9 @@ async def get_conversion_statistics(db: AsyncSession) -> dict: month_conversions = month_conversions_result.scalar() or 0 logger.info('📊 Статистика конверсий:') - logger.info(f' Всего пользователей с подписками: {total_users_with_subscriptions}') - logger.info(f' Оплативших подписку: {users_with_paid}') - logger.info(f' Рассчитанная конверсия: {conversion_rate}%') + logger.info('Всего пользователей с подписками', total_users_with_subscriptions=total_users_with_subscriptions) + logger.info('Оплативших подписку', users_with_paid=users_with_paid) + logger.info('Рассчитанная конверсия', conversion_rate=conversion_rate) return { 'total_conversions': total_conversions, diff --git a/app/database/crud/tariff.py b/app/database/crud/tariff.py index dfb97caa..8b8f493c 100644 --- a/app/database/crud/tariff.py +++ b/app/database/crud/tariff.py @@ -1,5 +1,4 @@ -import logging - +import structlog from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -7,7 +6,7 @@ from sqlalchemy.orm import selectinload from app.database.models import PromoGroup, Subscription, Tariff -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _normalize_period_prices(period_prices: dict[int, int] | None) -> dict[str, int]: @@ -241,13 +240,13 @@ async def create_tariff( await db.refresh(tariff) logger.info( - "Создан тариф '%s' (id=%s, tier=%s, traffic=%sGB, devices=%s, prices=%s)", - tariff.name, - tariff.id, - tariff.tier_level, - tariff.traffic_limit_gb, - tariff.device_limit, - normalized_prices, + "Создан тариф '' (id tier traffic=GB, devices prices=)", + tariff_name=tariff.name, + tariff_id=tariff.id, + tier_level=tariff.tier_level, + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + normalized_prices=normalized_prices, ) return tariff @@ -365,11 +364,7 @@ async def update_tariff( await db.commit() await db.refresh(tariff) - logger.info( - "Обновлен тариф '%s' (id=%s)", - tariff.name, - tariff.id, - ) + logger.info("Обновлен тариф '' (id=)", tariff_name=tariff.name, tariff_id=tariff.id) return tariff @@ -393,10 +388,10 @@ async def delete_tariff(db: AsyncSession, tariff: Tariff) -> bool: await db.commit() logger.info( - "Удален тариф '%s' (id=%s), затронуто подписок: %s", - tariff_name, - tariff_id, - affected_subscriptions, + "Удален тариф '' (id=), затронуто подписок", + tariff_name=tariff_name, + tariff_id=tariff_id, + affected_subscriptions=affected_subscriptions, ) return True @@ -486,7 +481,7 @@ async def reorder_tariffs( for order, tariff_id in enumerate(tariff_order): await db.execute(update(Tariff).where(Tariff.id == tariff_id).values(display_order=order)) - logger.info('Изменен порядок тарифов: %s', tariff_order) + logger.info('Изменен порядок тарифов', tariff_order=tariff_order) async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None: @@ -522,7 +517,8 @@ async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None: # Тариф уже существует — НЕ перезаписываем настройки из конфига. # Админ управляет тарифом через кабинет, синхронизация не нужна. logger.info( - "Дефолтный тариф 'Стандартный' (id=%s) уже существует, пропускаем sync из конфига", existing_tariff.id + "Дефолтный тариф 'Стандартный' (id=) уже существует, пропускаем sync из конфига", + existing_tariff_id=existing_tariff.id, ) return existing_tariff @@ -544,7 +540,7 @@ async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None: db.add(new_tariff) await db.commit() await db.refresh(new_tariff) - logger.info("Создан дефолтный тариф 'Стандартный' из конфига: %s", period_prices) + logger.info("Создан дефолтный тариф 'Стандартный' из конфига", period_prices=period_prices) return new_tariff return None @@ -574,7 +570,7 @@ async def load_period_prices_from_db(db: AsyncSession) -> None: return if not tariff.period_prices: - logger.warning("Тариф '%s' (id=%s) найден, но period_prices пуст", tariff.name, tariff.id) + logger.warning("Тариф '' (id=) найден, но period_prices пуст", tariff_name=tariff.name, tariff_id=tariff.id) return # Преобразуем строковые ключи в int @@ -588,10 +584,10 @@ async def load_period_prices_from_db(db: AsyncSession) -> None: {f'{d}д': f'{p // 100}₽' for d, p in period_prices.items()}, ) else: - logger.warning("Тариф '%s' не имеет активных периодов (все цены = 0)", tariff.name) + logger.warning("Тариф '' не имеет активных периодов (все цены = 0)", tariff_name=tariff.name) except Exception as e: - logger.error('Ошибка загрузки периодов из БД: %s', e) + logger.error('Ошибка загрузки периодов из БД', e=e) async def ensure_tariffs_synced(db: AsyncSession) -> None: @@ -604,4 +600,4 @@ async def ensure_tariffs_synced(db: AsyncSession) -> None: # Загружаем периоды из БД в PERIOD_PRICES await load_period_prices_from_db(db) except Exception as e: - logger.error('Ошибка синхронизации тарифов: %s', e) + logger.error('Ошибка синхронизации тарифов', e=e) diff --git a/app/database/crud/ticket.py b/app/database/crud/ticket.py index 34776407..3d821e07 100644 --- a/app/database/crud/ticket.py +++ b/app/database/crud/ticket.py @@ -1,6 +1,6 @@ -import logging from datetime import datetime +import structlog from sqlalchemy import and_, desc, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload from app.database.models import SupportAuditLog, Ticket, TicketMessage, TicketStatus -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class TicketCRUD: @@ -64,7 +64,7 @@ class TicketCRUD: db=db, ) except Exception as error: - logger.warning('Failed to emit ticket.created event: %s', error) + logger.warning('Failed to emit ticket.created event', error=error) return ticket @@ -248,7 +248,7 @@ class TicketCRUD: db=db, ) except Exception as error: - logger.warning('Failed to emit ticket.status_changed event: %s', error) + logger.warning('Failed to emit ticket.status_changed event', error=error) return True @@ -440,7 +440,7 @@ class TicketMessageCRUD: db=db, ) except Exception as error: - logger.warning('Failed to emit ticket.message_added event: %s', error) + logger.warning('Failed to emit ticket.message_added event', error=error) return message diff --git a/app/database/crud/ticket_notification.py b/app/database/crud/ticket_notification.py index 0f801e05..a76ba244 100644 --- a/app/database/crud/ticket_notification.py +++ b/app/database/crud/ticket_notification.py @@ -1,8 +1,8 @@ """CRUD operations for TicketNotification.""" -import logging from datetime import datetime +import structlog from sqlalchemy import desc, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -10,7 +10,7 @@ from sqlalchemy.orm import selectinload from app.database.models import Ticket, TicketNotification -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class TicketNotificationCRUD: diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index 18d33250..85886cfc 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -1,6 +1,6 @@ -import logging from datetime import datetime, timedelta +import structlog from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload from app.database.models import PaymentMethod, Transaction, TransactionType, User -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # Реальные платёжные методы для подсчёта дохода # Исключены: MANUAL (админские), BALANCE (оплата с баланса), NULL (колесо, промокоды, бонусы) @@ -55,7 +55,12 @@ async def create_transaction( await db.commit() await db.refresh(transaction) - logger.info(f'💳 Создана транзакция: {type.value} на {amount_kopeks / 100}₽ для пользователя {user_id}') + logger.info( + '💳 Создана транзакция: на ₽ для пользователя', + type_value=type.value, + amount_kopeks=amount_kopeks / 100, + user_id=user_id, + ) # Отправляем событие о транзакции try: @@ -77,7 +82,7 @@ async def create_transaction( db=db, ) except Exception as error: - logger.warning('Failed to emit transaction event: %s', error) + logger.warning('Failed to emit transaction event', error=error) try: from app.services.promo_group_assignment import ( @@ -86,11 +91,7 @@ async def create_transaction( await maybe_assign_promo_group_by_total_spent(db, user_id) except Exception as exc: - logger.debug( - 'Не удалось проверить автовыдачу промогруппы для пользователя %s: %s', - user_id, - exc, - ) + logger.debug('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc) if type == TransactionType.SUBSCRIPTION_PAYMENT: try: from app.services.referral_contest_service import referral_contest_service @@ -101,11 +102,7 @@ async def create_transaction( amount_kopeks, ) except Exception as exc: - logger.debug( - 'Не удалось записать событие конкурса для пользователя %s: %s', - user_id, - exc, - ) + logger.debug('Не удалось записать событие конкурса для пользователя', user_id=user_id, exc=exc) return transaction @@ -171,7 +168,7 @@ async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Tr await db.commit() await db.refresh(transaction) - logger.info(f'✅ Транзакция {transaction.id} завершена') + logger.info('✅ Транзакция завершена', transaction_id=transaction.id) try: from app.services.promo_group_assignment import ( @@ -181,9 +178,7 @@ async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Tr await maybe_assign_promo_group_by_total_spent(db, transaction.user_id) except Exception as exc: logger.debug( - 'Не удалось проверить автовыдачу промогруппы для пользователя %s: %s', - transaction.user_id, - exc, + 'Не удалось проверить автовыдачу промогруппы для пользователя', user_id=transaction.user_id, exc=exc ) return transaction @@ -392,7 +387,7 @@ async def check_tribute_payment_duplicate( transaction = result.scalar_one_or_none() if transaction: - logger.info(f'🔍 Найден дубликат платежа в течение 24ч: {transaction.id}') + logger.info('🔍 Найден дубликат платежа в течение 24ч', transaction_id=transaction.id) return transaction @@ -408,7 +403,7 @@ async def create_unique_tribute_transaction( timestamp = int(datetime.utcnow().timestamp()) external_id = f'donation_{payment_id}_{amount_kopeks}_{timestamp}' - logger.info(f'Создан уникальный external_id для избежания дубликатов: {external_id}') + logger.info('Создан уникальный external_id для избежания дубликатов', external_id=external_id) return await create_transaction( db=db, diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 0e73f5e3..1df68097 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -1,8 +1,8 @@ -import logging import secrets import string from datetime import UTC, datetime, timedelta +import structlog from sqlalchemy import and_, case, func, nullslast, or_, select, text from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -25,7 +25,7 @@ from app.database.models import ( from app.utils.validators import sanitize_telegram_name -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _normalize_language_code(language: str | None, fallback: str = 'ru') -> str: @@ -269,7 +269,11 @@ async def create_user_no_commit( user.promo_group = default_group # Не коммитим сразу, оставляем для пакетной обработки - logger.info(f'✅ Подготовлен пользователь {telegram_id} с реферальным кодом {referral_code} (ожидает коммита)') + logger.info( + '✅ Подготовлен пользователь с реферальным кодом (ожидает коммита)', + telegram_id=telegram_id, + referral_code=referral_code, + ) return user @@ -316,7 +320,9 @@ async def create_user( await db.refresh(user) user.promo_group = default_group - logger.info(f'✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}') + logger.info( + '✅ Создан пользователь с реферальным кодом', telegram_id=telegram_id, referral_code=referral_code + ) # Отправляем событие о создании пользователя try: @@ -336,7 +342,7 @@ async def create_user( db=db, ) except Exception as error: - logger.warning('Failed to emit user.created event: %s', error) + logger.warning('Failed to emit user.created event', error=error) return user @@ -349,11 +355,10 @@ async def create_user( and attempt < attempts ): logger.warning( - '⚠️ Обнаружено несоответствие последовательности users_id_seq при создании пользователя %s. ' - 'Выполняем повторную синхронизацию (попытка %s/%s)', - telegram_id, - attempt, - attempts, + '⚠️ Обнаружено несоответствие последовательности users_id_seq при создании пользователя . Выполняем повторную синхронизацию (попытка /)', + telegram_id=telegram_id, + attempt=attempt, + attempts=attempts, ) await _sync_users_sequence(db) continue @@ -413,7 +418,11 @@ async def add_user_balance( user_id_display = user.telegram_id or user.email or f'#{user.id}' logger.info( - f'💰 Баланс пользователя {user_id_display} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})' + '💰 Баланс пользователя изменен: → (изменение: +)', + user_id_display=user_id_display, + old_balance=old_balance, + balance_kopeks=user.balance_kopeks, + amount_kopeks=amount_kopeks, ) # Автоматическое возобновление приостановленной суточной подписки @@ -436,8 +445,9 @@ async def add_user_balance( if daily_price > 0 and user.balance_kopeks >= daily_price: await resume_daily_subscription(db, subscription) logger.info( - f'✅ Автоматически возобновлена суточная подписка {subscription.id} ' - f'после пополнения баланса (user_id={user.id})' + '✅ Автоматически возобновлена суточная подписка после пополнения баланса (user_id=)', + subscription_id=subscription.id, + user_id=user.id, ) # Синхронизируем с RemnaWave try: @@ -446,14 +456,14 @@ async def add_user_balance( subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) except Exception as sync_err: - logger.warning(f'Не удалось синхронизировать с RemnaWave: {sync_err}') + logger.warning('Не удалось синхронизировать с RemnaWave', sync_err=sync_err) except Exception as resume_err: - logger.warning(f'Ошибка при попытке возобновить суточную подписку: {resume_err}') + logger.warning('Ошибка при попытке возобновить суточную подписку', resume_err=resume_err) return True except Exception as e: - logger.error(f'Ошибка изменения баланса пользователя {user.id}: {e}') + logger.error('Ошибка изменения баланса пользователя', user_id=user.id, error=e) await db.rollback() return False @@ -469,7 +479,7 @@ async def add_user_balance_by_id( try: user = await get_user_by_telegram_id(db, telegram_id) if not user: - logger.error(f'Пользователь с telegram_id {telegram_id} не найден') + logger.error('Пользователь с telegram_id не найден', telegram_id=telegram_id) return False return await add_user_balance( @@ -482,7 +492,7 @@ async def add_user_balance_by_id( ) except Exception as e: - logger.error(f'Ошибка пополнения баланса пользователя {telegram_id}: {e}') + logger.error('Ошибка пополнения баланса пользователя', telegram_id=telegram_id, error=e) return False @@ -498,10 +508,10 @@ async def subtract_user_balance( ) -> bool: user_id_display = user.telegram_id or user.email or f'#{user.id}' logger.info('💸 ОТЛАДКА subtract_user_balance:') - logger.info(f' 👤 User ID: {user.id} (ID: {user_id_display})') - logger.info(f' 💰 Баланс до списания: {user.balance_kopeks} копеек') - logger.info(f' 💸 Сумма к списанию: {amount_kopeks} копеек') - logger.info(f' 📝 Описание: {description}') + logger.info('👤 User ID: (ID: )', user_id=user.id, user_id_display=user_id_display) + logger.info('💰 Баланс до списания: копеек', balance_kopeks=user.balance_kopeks) + logger.info('💸 Сумма к списанию: копеек', amount_kopeks=amount_kopeks) + logger.info('📝 Описание', description=description) # Lock the user row to prevent concurrent balance race conditions locked_result = await db.execute(select(User).where(User.id == user.id).with_for_update()) @@ -531,9 +541,7 @@ async def subtract_user_balance( offer = await get_latest_claimed_offer_for_user(db, user.id, source) except Exception as lookup_error: # pragma: no cover - defensive logging logger.warning( - 'Failed to fetch latest claimed promo offer for user %s: %s', - user.id, - lookup_error, + 'Failed to fetch latest claimed promo offer for user', user_id=user.id, lookup_error=lookup_error ) offer = None @@ -592,23 +600,21 @@ async def subtract_user_balance( ) except Exception as log_error: # pragma: no cover - defensive logging logger.warning( - 'Failed to record promo offer consumption log for user %s: %s', - user.id, - log_error, + 'Failed to record promo offer consumption log for user', user_id=user.id, log_error=log_error ) try: await db.rollback() except Exception as rollback_error: # pragma: no cover - defensive logging logger.warning( - 'Failed to rollback session after promo offer consumption log failure: %s', - rollback_error, + 'Failed to rollback session after promo offer consumption log failure', + rollback_error=rollback_error, ) - logger.info(f' ✅ Средства списаны: {old_balance} → {user.balance_kopeks}') + logger.info('✅ Средства списаны: →', old_balance=old_balance, balance_kopeks=user.balance_kopeks) return True except Exception as e: - logger.error(f' ❌ ОШИБКА СПИСАНИЯ: {e}') + logger.error('❌ ОШИБКА СПИСАНИЯ', error=e) await db.rollback() return False @@ -643,9 +649,9 @@ async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int: offer = await get_latest_claimed_offer_for_user(db, user.id, source) except Exception as lookup_error: # pragma: no cover - defensive logging logger.warning( - 'Failed to fetch latest claimed promo offer for user %s during expiration cleanup: %s', - user.id, - lookup_error, + 'Failed to fetch latest claimed promo offer for user during expiration cleanup', + user_id=user.id, + lookup_error=lookup_error, ) offer = None @@ -688,17 +694,12 @@ async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int: details={'reason': 'offer_expired'}, ) except Exception as log_error: # pragma: no cover - defensive logging - logger.warning( - 'Failed to log promo offer expiration for user %s: %s', - user_id, - log_error, - ) + logger.warning('Failed to log promo offer expiration for user', user_id=user_id, log_error=log_error) try: await db.rollback() except Exception as rollback_error: # pragma: no cover - defensive logging logger.warning( - 'Failed to rollback session after promo offer expiration log failure: %s', - rollback_error, + 'Failed to rollback session after promo offer expiration log failure', rollback_error=rollback_error ) return len(users) @@ -944,7 +945,7 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> list[Us ), ) else: - logger.warning('Неизвестный сегмент для промо: %s', segment) + logger.warning('Неизвестный сегмент для промо', segment=segment) return [] result = await db.execute(query.order_by(User.id)) @@ -989,7 +990,7 @@ async def delete_user(db: AsyncSession, user: User) -> bool: await db.commit() user_id_display = user.telegram_id or user.email or f'#{user.id}' - logger.info(f'🗑️ Пользователь {user_id_display} помечен как удаленный') + logger.info('🗑️ Пользователь помечен как удаленный', user_id_display=user_id_display) return True @@ -1104,7 +1105,7 @@ async def create_user_by_email( await db.refresh(user) user.promo_group = default_group - logger.info(f'✅ Создан email-пользователь {email} с id={user.id}') + logger.info('✅ Создан email-пользователь с id', email=email, user_id=user.id) # Emit event try: @@ -1123,7 +1124,7 @@ async def create_user_by_email( db=db, ) except Exception as error: - logger.warning('Failed to emit user.created event: %s', error) + logger.warning('Failed to emit user.created event', error=error) return user @@ -1181,7 +1182,7 @@ async def set_email_change_pending( await db.commit() await db.refresh(user) - logger.info(f'Email change pending for user {user.id}: {user.email} -> {new_email}') + logger.info('Email change pending for user', user_id=user.id, email=user.email, new_email=new_email) return user @@ -1235,7 +1236,7 @@ async def verify_and_apply_email_change(db: AsyncSession, user: User, code: str) await db.commit() await db.refresh(user) - logger.info(f'Email changed for user {user.id}: {old_email} -> {new_email}') + logger.info('Email changed for user', user_id=user.id, old_email=old_email, new_email=new_email) return True, 'Email changed successfully' @@ -1253,7 +1254,7 @@ async def clear_email_change_pending(db: AsyncSession, user: User) -> None: user.updated_at = datetime.utcnow() await db.commit() - logger.info(f'Email change cancelled for user {user.id}') + logger.info('Email change cancelled for user', user_id=user.id) # --- OAuth provider functions --- @@ -1286,7 +1287,7 @@ async def set_user_oauth_provider_id(db: AsyncSession, user: User, provider: str value: str | int = int(provider_id) if provider == 'vk' else provider_id setattr(user, column_name, value) user.updated_at = datetime.now(UTC).replace(tzinfo=None) - logger.info(f'Linked {provider} (id={provider_id}) to user {user.id}') + logger.info('Linked (id=) to user', provider=provider, provider_id=provider_id, user_id=user.id) async def create_user_by_oauth( @@ -1332,7 +1333,9 @@ async def create_user_by_oauth( await db.refresh(user) user.promo_group = default_group - logger.info(f'Created OAuth user via {provider} (provider_id={provider_id}) with id={user.id}') + logger.info( + 'Created OAuth user via (provider_id=) with id', provider=provider, provider_id=provider_id, user_id=user.id + ) try: from app.services.event_emitter import event_emitter @@ -1349,6 +1352,6 @@ async def create_user_by_oauth( db=db, ) except Exception as error: - logger.warning('Failed to emit user.created event: %s', error) + logger.warning('Failed to emit user.created event', error=error) return user diff --git a/app/database/crud/user_message.py b/app/database/crud/user_message.py index d238077e..c8f69d48 100644 --- a/app/database/crud/user_message.py +++ b/app/database/crud/user_message.py @@ -1,7 +1,7 @@ -import logging import random from datetime import datetime +import structlog from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -9,7 +9,7 @@ from app.database.models import User, UserMessage from app.utils.validators import sanitize_html, validate_html_tags -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def create_user_message( @@ -36,7 +36,7 @@ async def create_user_message( await db.commit() await db.refresh(message) - logger.info(f'✅ Создано сообщение ID {message.id} пользователем {created_by}') + logger.info('✅ Создано сообщение ID пользователем', message_id=message.id, created_by=created_by) return message @@ -116,7 +116,7 @@ async def update_user_message( await db.commit() await db.refresh(message) - logger.info(f'📝 Обновлено сообщение ID {message_id}') + logger.info('📝 Обновлено сообщение ID', message_id=message_id) return message @@ -133,7 +133,7 @@ async def toggle_user_message_status(db: AsyncSession, message_id: int) -> UserM await db.refresh(message) status_text = 'активировано' if message.is_active else 'деактивировано' - logger.info(f'🔄 Сообщение ID {message_id} {status_text}') + logger.info('🔄 Сообщение ID', message_id=message_id, status_text=status_text) return message @@ -147,7 +147,7 @@ async def delete_user_message(db: AsyncSession, message_id: int) -> bool: await db.delete(message) await db.commit() - logger.info(f'🗑️ Удалено сообщение ID {message_id}') + logger.info('🗑️ Удалено сообщение ID', message_id=message_id) return True diff --git a/app/database/crud/user_promo_group.py b/app/database/crud/user_promo_group.py index ead124e1..1e1e0361 100644 --- a/app/database/crud/user_promo_group.py +++ b/app/database/crud/user_promo_group.py @@ -1,8 +1,8 @@ """CRUD операции для связи пользователей с промогруппами (Many-to-Many).""" -import logging from datetime import datetime +import structlog from sqlalchemy import and_, desc, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -10,7 +10,7 @@ from sqlalchemy.orm import selectinload from app.database.models import PromoGroup, User, UserPromoGroup -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def _sync_user_primary_promo_group( @@ -39,11 +39,7 @@ async def _sync_user_primary_promo_group( user.updated_at = datetime.utcnow() except Exception as error: - logger.error( - 'Ошибка синхронизации primary промогруппы пользователя %s: %s', - user_id, - error, - ) + logger.error('Ошибка синхронизации primary промогруппы пользователя', user_id=user_id, error=error) async def sync_user_primary_promo_group( @@ -74,7 +70,7 @@ async def add_user_to_promo_group( # Проверяем существование связи existing = await has_user_promo_group(db, user_id, promo_group_id) if existing: - logger.info(f'Пользователь {user_id} уже имеет промогруппу {promo_group_id}') + logger.info('Пользователь уже имеет промогруппу', user_id=user_id, promo_group_id=promo_group_id) return None # Создаем новую связь @@ -91,11 +87,16 @@ async def add_user_to_promo_group( await db.commit() await db.refresh(user_promo_group) - logger.info(f'Пользователю {user_id} добавлена промогруппа {promo_group_id} ({assigned_by})') + logger.info( + 'Пользователю добавлена промогруппа', + user_id=user_id, + promo_group_id=promo_group_id, + assigned_by=assigned_by, + ) return user_promo_group except Exception as error: - logger.error(f'Ошибка добавления промогруппы пользователю: {error}') + logger.error('Ошибка добавления промогруппы пользователю', error=error) await db.rollback() return None @@ -121,7 +122,9 @@ async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_gro user_promo_group = result.scalar_one_or_none() if not user_promo_group: - logger.warning(f'Связь пользователя {user_id} с промогруппой {promo_group_id} не найдена') + logger.warning( + 'Связь пользователя с промогруппой не найдена', user_id=user_id, promo_group_id=promo_group_id + ) return False await db.delete(user_promo_group) @@ -131,11 +134,11 @@ async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_gro await db.commit() - logger.info(f'У пользователя {user_id} удалена промогруппа {promo_group_id}') + logger.info('У пользователя удалена промогруппа', user_id=user_id, promo_group_id=promo_group_id) return True except Exception as error: - logger.error(f'Ошибка удаления промогруппы у пользователя: {error}') + logger.error('Ошибка удаления промогруппы у пользователя', error=error) await db.rollback() return False @@ -162,7 +165,7 @@ async def get_user_promo_groups(db: AsyncSession, user_id: int) -> list[UserProm return list(result.scalars().all()) except Exception as error: - logger.error(f'Ошибка получения промогрупп пользователя {user_id}: {error}') + logger.error('Ошибка получения промогрупп пользователя', user_id=user_id, error=error) return [] @@ -187,7 +190,7 @@ async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoG return user_promo_groups[0].promo_group if user_promo_groups[0].promo_group else None except Exception as error: - logger.error(f'Ошибка получения primary промогруппы пользователя {user_id}: {error}') + logger.error('Ошибка получения primary промогруппы пользователя', user_id=user_id, error=error) return None @@ -212,7 +215,7 @@ async def has_user_promo_group(db: AsyncSession, user_id: int, promo_group_id: i return result.scalar_one_or_none() is not None except Exception as error: - logger.error(f'Ошибка проверки промогруппы пользователя: {error}') + logger.error('Ошибка проверки промогруппы пользователя', error=error) return False @@ -232,7 +235,7 @@ async def count_user_promo_groups(db: AsyncSession, user_id: int) -> int: return len(list(result.scalars().all())) except Exception as error: - logger.error(f'Ошибка подсчета промогрупп пользователя: {error}') + logger.error('Ошибка подсчета промогрупп пользователя', error=error) return 0 @@ -264,10 +267,10 @@ async def replace_user_promo_groups( db.add(user_promo_group) await db.commit() - logger.info(f'Промогруппы пользователя {user_id} заменены на {promo_group_ids}') + logger.info('Промогруппы пользователя заменены на', user_id=user_id, promo_group_ids=promo_group_ids) return True except Exception as error: - logger.error(f'Ошибка замены промогрупп пользователя: {error}') + logger.error('Ошибка замены промогрупп пользователя', error=error) await db.rollback() return False diff --git a/app/database/crud/wata.py b/app/database/crud/wata.py index b4e3df34..8cf2bf31 100644 --- a/app/database/crud/wata.py +++ b/app/database/crud/wata.py @@ -1,16 +1,16 @@ """CRUD helpers for WATA payment records.""" -import logging from datetime import datetime from typing import Any +import structlog from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import WataPayment -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def create_wata_payment( @@ -53,11 +53,11 @@ async def create_wata_payment( await db.refresh(payment) logger.info( - 'Создан Wata платеж #%s для пользователя %s: %s копеек (статус %s)', - payment.id, - user_id, - amount_kopeks, - status, + 'Создан Wata платеж # для пользователя : копеек (статус)', + payment_id=payment.id, + user_id=user_id, + amount_kopeks=amount_kopeks, + status=status, ) return payment @@ -128,10 +128,10 @@ async def update_wata_payment_status( await db.refresh(payment) logger.info( - 'Обновлен Wata платеж %s: статус=%s, is_paid=%s', - payment.payment_link_id, - payment.status, - payment.is_paid, + 'Обновлен Wata платеж : статус is_paid', + payment_link_id=payment.payment_link_id, + payment_status=payment.status, + is_paid=payment.is_paid, ) return payment @@ -147,9 +147,7 @@ async def link_wata_payment_to_transaction( await db.refresh(payment) logger.info( - 'Wata платеж %s привязан к транзакции %s', - payment.payment_link_id, - transaction_id, + 'Wata платеж привязан к транзакции', payment_link_id=payment.payment_link_id, transaction_id=transaction_id ) return payment diff --git a/app/database/crud/welcome_text.py b/app/database/crud/welcome_text.py index 6588219a..b48df45e 100644 --- a/app/database/crud/welcome_text.py +++ b/app/database/crud/welcome_text.py @@ -1,13 +1,13 @@ -import logging from datetime import datetime +import structlog from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import User, WelcomeText -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) WELCOME_TEXT_KEY = 'welcome_text' @@ -83,7 +83,7 @@ async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool: await db.refresh(welcome_text) status = 'включен' if welcome_text.is_enabled else 'отключен' - logger.info(f'Приветственный текст {status} администратором {admin_id}') + logger.info('Приветственный текст администратором', status=status, admin_id=admin_id) return welcome_text.is_enabled default_text = await get_current_welcome_text_or_default() new_welcome_text = WelcomeText(text_content=default_text, is_active=True, is_enabled=True, created_by=admin_id) @@ -92,11 +92,11 @@ async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool: await db.commit() await db.refresh(new_welcome_text) - logger.info(f'Создан и включен дефолтный приветственный текст администратором {admin_id}') + logger.info('Создан и включен дефолтный приветственный текст администратором', admin_id=admin_id) return True except Exception as e: - logger.error(f'Ошибка при переключении статуса приветственного текста: {e}') + logger.error('Ошибка при переключении статуса приветственного текста', error=e) await db.rollback() return False @@ -116,11 +116,11 @@ async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) - await db.commit() await db.refresh(new_welcome_text) - logger.info(f'Установлен новый приветственный текст администратором {admin_id}') + logger.info('Установлен новый приветственный текст администратором', admin_id=admin_id) return True except Exception as e: - logger.error(f'Ошибка при установке приветственного текста: {e}') + logger.error('Ошибка при установке приветственного текста', error=e) await db.rollback() return False @@ -154,10 +154,10 @@ async def create_welcome_text( await db.refresh(welcome_text) logger.info( - '✅ Создан приветственный текст ID %s (активный=%s, включен=%s)', - welcome_text.id, - welcome_text.is_active, - welcome_text.is_enabled, + '✅ Создан приветственный текст ID (активный включен=)', + welcome_text_id=welcome_text.id, + is_active=welcome_text.is_active, + is_enabled=welcome_text.is_enabled, ) return welcome_text @@ -188,10 +188,10 @@ async def update_welcome_text( await db.refresh(welcome_text) logger.info( - '📝 Обновлен приветственный текст ID %s (активный=%s, включен=%s)', - welcome_text.id, - welcome_text.is_active, - welcome_text.is_enabled, + '📝 Обновлен приветственный текст ID (активный включен=)', + welcome_text_id=welcome_text.id, + is_active=welcome_text.is_active, + is_enabled=welcome_text.is_enabled, ) return welcome_text @@ -199,7 +199,7 @@ async def update_welcome_text( async def delete_welcome_text(db: AsyncSession, welcome_text: WelcomeText) -> None: await db.delete(welcome_text) await db.commit() - logger.info('🗑️ Удален приветственный текст ID %s', welcome_text.id) + logger.info('🗑️ Удален приветственный текст ID', welcome_text_id=welcome_text.id) async def get_current_welcome_text_or_default() -> str: diff --git a/app/database/crud/wheel.py b/app/database/crud/wheel.py index 0e617653..a494a075 100644 --- a/app/database/crud/wheel.py +++ b/app/database/crud/wheel.py @@ -2,10 +2,10 @@ CRUD операции для колеса удачи (Fortune Wheel). """ -import logging from datetime import datetime from typing import Any +import structlog from sqlalchemy import and_, desc, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -17,7 +17,7 @@ from app.database.models import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # ==================== WHEEL CONFIG ==================== @@ -68,7 +68,7 @@ async def update_wheel_config(db: AsyncSession, **kwargs) -> WheelConfig: config.updated_at = datetime.utcnow() await db.commit() await db.refresh(config) - logger.info(f'🎡 Обновлена конфигурация колеса: {kwargs}') + logger.info('🎡 Обновлена конфигурация колеса', kwargs=kwargs) return config @@ -129,7 +129,7 @@ async def create_wheel_prize( db.add(prize) await db.commit() await db.refresh(prize) - logger.info(f'🎁 Создан приз колеса: {display_name} ({prize_type})') + logger.info('🎁 Создан приз колеса', display_name=display_name, prize_type=prize_type) return prize @@ -146,7 +146,7 @@ async def update_wheel_prize(db: AsyncSession, prize_id: int, **kwargs) -> Wheel prize.updated_at = datetime.utcnow() await db.commit() await db.refresh(prize) - logger.info(f'🎁 Обновлен приз колеса ID={prize_id}: {kwargs}') + logger.info('🎁 Обновлен приз колеса ID', prize_id=prize_id, kwargs=kwargs) return prize @@ -158,7 +158,7 @@ async def delete_wheel_prize(db: AsyncSession, prize_id: int) -> bool: await db.delete(prize) await db.commit() - logger.info(f'🗑️ Удален приз колеса ID={prize_id}') + logger.info('🗑️ Удален приз колеса ID', prize_id=prize_id) return True @@ -170,7 +170,7 @@ async def reorder_wheel_prizes(db: AsyncSession, prize_ids: list[int]) -> bool: prize.sort_order = index await db.commit() - logger.info(f'🔄 Переупорядочены призы колеса: {prize_ids}') + logger.info('🔄 Переупорядочены призы колеса', prize_ids=prize_ids) return True @@ -209,7 +209,7 @@ async def create_wheel_spin( db.add(spin) await db.commit() await db.refresh(spin) - logger.info(f"🎰 Создан спин колеса: user_id={user_id}, prize='{prize_display_name}'") + logger.info('🎰 Создан спин колеса: user_id=, prize', user_id=user_id, prize_display_name=prize_display_name) return spin diff --git a/app/database/crud/yookassa.py b/app/database/crud/yookassa.py index 0fc265b0..05153833 100644 --- a/app/database/crud/yookassa.py +++ b/app/database/crud/yookassa.py @@ -1,6 +1,6 @@ -import logging from datetime import datetime +import structlog from sqlalchemy import and_, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload from app.database.models import YooKassaPayment -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def create_yookassa_payment( @@ -46,15 +46,20 @@ async def create_yookassa_payment( except IntegrityError as e: await db.rollback() logger.error( - 'FK violation при создании платежа YooKassa %s: user_id=%s не существует в БД: %s', - yookassa_payment_id, - user_id, - e, + 'FK violation при создании платежа YooKassa : user_id= не существует в БД', + yookassa_payment_id=yookassa_payment_id, + user_id=user_id, + e=e, ) return None await db.refresh(payment) - logger.info(f'Создан платеж YooKassa: {yookassa_payment_id} на {amount_kopeks / 100}₽ для пользователя {user_id}') + logger.info( + 'Создан платеж YooKassa: на ₽ для пользователя', + yookassa_payment_id=yookassa_payment_id, + amount_kopeks=amount_kopeks / 100, + user_id=user_id, + ) return payment @@ -104,7 +109,12 @@ async def update_yookassa_payment_status( payment = result.scalar_one_or_none() if payment: - logger.info(f'Обновлен статус платежа YooKassa {yookassa_payment_id}: {status}, paid={is_paid}') + logger.info( + 'Обновлен статус платежа YooKassa , paid', + yookassa_payment_id=yookassa_payment_id, + status=status, + is_paid=is_paid, + ) return payment @@ -127,7 +137,11 @@ async def link_yookassa_payment_to_transaction( payment = result.scalar_one_or_none() if payment: - logger.info(f'Платеж YooKassa {yookassa_payment_id} связан с транзакцией {transaction_id}') + logger.info( + 'Платеж YooKassa связан с транзакцией', + yookassa_payment_id=yookassa_payment_id, + transaction_id=transaction_id, + ) return payment @@ -185,7 +199,7 @@ async def delete_yookassa_payment(db: AsyncSession, yookassa_payment_id: str) -> if payment: await db.delete(payment) await db.commit() - logger.info(f'Удален платеж YooKassa: {yookassa_payment_id}') + logger.info('Удален платеж YooKassa', yookassa_payment_id=yookassa_payment_id) return True return False diff --git a/app/database/database.py b/app/database/database.py index 65348cea..d8bdc592 100644 --- a/app/database/database.py +++ b/app/database/database.py @@ -1,11 +1,11 @@ import asyncio -import logging import time from collections.abc import AsyncGenerator, Callable from contextlib import asynccontextmanager from functools import wraps from typing import TypeVar +import structlog from sqlalchemy import bindparam, event, inspect, text from sqlalchemy.engine import Engine from sqlalchemy.exc import InterfaceError, OperationalError, ProgrammingError @@ -16,7 +16,7 @@ from app.config import settings from app.database.models import Base -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) T = TypeVar('T') @@ -126,16 +126,16 @@ def with_db_retry( last_exception = e if attempt < attempts: logger.warning( - 'Ошибка БД (попытка %d/%d): %s. Повтор через %.1f сек...', - attempt, - attempts, - str(e)[:100], - current_delay, + 'Ошибка БД (попытка /): . Повтор через сек...', + attempt=attempt, + attempts=attempts, + e=str(e)[:100], + current_delay=current_delay, ) await asyncio.sleep(current_delay) current_delay *= backoff else: - logger.error('Ошибка БД: все %d попыток исчерпаны. Последняя ошибка: %s', attempts, str(e)) + logger.error('Ошибка БД: все попыток исчерпаны. Последняя ошибка', attempts=attempts, e=str(e)) raise last_exception @@ -159,7 +159,7 @@ async def execute_with_retry( except RETRYABLE_EXCEPTIONS as e: last_exception = e if attempt < attempts: - logger.warning('SQL retry (попытка %d/%d): %s', attempt, attempts, str(e)[:100]) + logger.warning('SQL retry (попытка /)', attempt=attempt, attempts=attempts, e=str(e)[:100]) await asyncio.sleep(delay) delay *= 2 @@ -175,15 +175,15 @@ if settings.DEBUG: @event.listens_for(Engine, 'before_cursor_execute') def before_cursor_execute(conn, cursor, statement, parameters, context, executemany): conn.info.setdefault('query_start_time', []).append(time.time()) - logger.debug(f'🔍 Executing query: {statement[:100]}...') + logger.debug('🔍 Executing query: ...', statement=statement[:100]) @event.listens_for(Engine, 'after_cursor_execute') def after_cursor_execute(conn, cursor, statement, parameters, context, executemany): total = time.time() - conn.info['query_start_time'].pop(-1) if total > 0.1: # Логируем медленные запросы > 100ms - logger.warning(f'🐌 Slow query ({total:.3f}s): {statement[:100]}...') + logger.warning('🐌 Slow query (s): ...', total=round(total, 3), statement=statement[:100]) else: - logger.debug(f'⚡ Query executed in {total:.3f}s') + logger.debug('⚡ Query executed in', total=round(total, 3)) # ============================================================================ # ADVANCED SESSION MANAGER WITH READ REPLICAS @@ -201,7 +201,7 @@ def _validate_database_url(url: str | None) -> str | None: return None # Простая проверка на валидный формат if not ('://' in url or url.startswith('sqlite')): - logger.warning('Невалидный DATABASE_URL: %s', url[:20]) + logger.warning('Невалидный DATABASE_URL', url=url[:20]) return None return url @@ -234,9 +234,9 @@ class DatabaseManager: expire_on_commit=False, autoflush=False, ) - logger.info('Read replica настроена: %s', replica_url[:30] + '...') + logger.info('Read replica настроена', replica_url=replica_url[:30] + '...') except Exception as e: - logger.error('Не удалось настроить read replica: %s', e) + logger.error('Не удалось настроить read replica', e=e) self.read_replica_engine = None @asynccontextmanager @@ -276,10 +276,10 @@ class DatabaseManager: latency = (time.time() - start) * 1000 status = 'healthy' except TimeoutError: - logger.error('Health check таймаут (%s сек)', timeout) + logger.error('Health check таймаут (сек)', timeout=timeout) status = 'timeout' except Exception as e: - logger.error('Database health check failed: %s', e) + logger.error('Database health check failed', e=e) status = 'unhealthy' return { @@ -307,7 +307,7 @@ class DatabaseManager: except TimeoutError: status = 'timeout' except Exception as e: - logger.error('Read replica health check failed: %s', e) + logger.error('Read replica health check failed', e=e) return { 'status': status, @@ -440,7 +440,7 @@ async def init_db(): # Продолжаем выполнение, так как основные таблицы могут быть созданы else: # Для других ошибок пробрасываем исключение - logger.error(f'❌ Ошибка при создании таблиц: {e}') + logger.error('❌ Ошибка при создании таблиц', error=e) raise if not IS_SQLITE: @@ -468,21 +468,19 @@ async def init_db(): if not table_exists: logger.debug( - 'Пропускаем создание индекса %s: таблица %s отсутствует', - index_sql, - table_name, + 'Пропускаем создание индекса : таблица отсутствует', index_sql=index_sql, table_name=table_name ) continue try: await conn.execute(text(index_sql)) except Exception as e: - logger.debug('Index creation skipped for %s: %s', table_name, e) + logger.debug('Index creation skipped for', table_name=table_name, e=e) logger.info('База данных успешно инициализирована') health = await db_manager.health_check() - logger.info('Database health: %s', health) + logger.info('Database health', health=health) async def close_db(): diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index abde8182..85ed3da7 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1,6 +1,6 @@ -import logging from datetime import datetime +import structlog from sqlalchemy import select, text from app.config import settings @@ -9,7 +9,7 @@ from app.database.models import WebApiToken from app.utils.security import hash_api_token -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def get_database_type(): @@ -22,7 +22,7 @@ async def sync_postgres_sequences() -> bool: db_type = await get_database_type() if db_type != 'postgresql': - logger.debug('Пропускаем синхронизацию последовательностей: тип БД %s', db_type) + logger.debug('Пропускаем синхронизацию последовательностей: тип БД', db_type=db_type) return True try: @@ -88,16 +88,16 @@ async def sync_postgres_sequences() -> bool: {'sequence_name': sequence_path, 'new_value': max_value}, ) logger.info( - '🔄 Последовательность %s синхронизирована: MAX=%s, следующий ID=%s', - sequence_path, - max_value, - max_value + 1, + '🔄 Последовательность синхронизирована: MAX следующий ID', + sequence_path=sequence_path, + max_value=max_value, + max_value_2=max_value + 1, ) return True except Exception as error: - logger.error('❌ Ошибка синхронизации последовательностей PostgreSQL: %s', error) + logger.error('❌ Ошибка синхронизации последовательностей PostgreSQL', error=error) return False @@ -138,7 +138,7 @@ async def check_table_exists(table_name: str) -> bool: return False except Exception as e: - logger.error(f'Ошибка проверки существования таблицы {table_name}: {e}') + logger.error('Ошибка проверки существования таблицы', table_name=table_name, error=e) return False @@ -179,7 +179,7 @@ async def check_column_exists(table_name: str, column_name: str) -> bool: return False except Exception as e: - logger.error(f'Ошибка проверки существования колонки {column_name}: {e}') + logger.error('Ошибка проверки существования колонки', column_name=column_name, error=e) return False @@ -226,7 +226,12 @@ async def check_constraint_exists(table_name: str, constraint_name: str) -> bool return False except Exception as e: - logger.error(f'Ошибка проверки существования ограничения {constraint_name} для {table_name}: {e}') + logger.error( + 'Ошибка проверки существования ограничения для', + constraint_name=constraint_name, + table_name=table_name, + error=e, + ) return False @@ -273,7 +278,7 @@ async def check_index_exists(table_name: str, index_name: str) -> bool: return False except Exception as e: - logger.error(f'Ошибка проверки существования индекса {index_name} для {table_name}: {e}') + logger.error('Ошибка проверки существования индекса для', index_name=index_name, table_name=table_name, error=e) return False @@ -443,11 +448,11 @@ async def enforce_wata_payment_link_constraints( return unique_index_exists, legacy_index_exists - logger.warning('⚠️ Неизвестный тип БД %s — не удалось усилить ограничения payment_link_id', db_type) + logger.warning('⚠️ Неизвестный тип БД — не удалось усилить ограничения payment_link_id', db_type=db_type) return unique_index_exists, legacy_index_exists except Exception as e: - logger.error(f'Ошибка настройки ограничений payment_link_id: {e}') + logger.error('Ошибка настройки ограничений payment_link_id', error=e) return unique_index_exists, legacy_index_exists @@ -542,7 +547,7 @@ async def create_cryptobot_payments_table(): CREATE INDEX idx_cryptobot_payments_status ON cryptobot_payments(status); """ else: - logger.error(f'Неподдерживаемый тип БД для создания таблицы: {db_type}') + logger.error('Неподдерживаемый тип БД для создания таблицы', db_type=db_type) return False await conn.execute(text(create_sql)) @@ -550,7 +555,7 @@ async def create_cryptobot_payments_table(): return True except Exception as e: - logger.error(f'Ошибка создания таблицы cryptobot_payments: {e}') + logger.error('Ошибка создания таблицы cryptobot_payments', error=e) return False @@ -656,7 +661,7 @@ async def create_heleket_payments_table(): """ else: - logger.error(f'Неподдерживаемый тип БД для таблицы heleket_payments: {db_type}') + logger.error('Неподдерживаемый тип БД для таблицы heleket_payments', db_type=db_type) return False await conn.execute(text(create_sql)) @@ -664,7 +669,7 @@ async def create_heleket_payments_table(): return True except Exception as e: - logger.error(f'Ошибка создания таблицы heleket_payments: {e}') + logger.error('Ошибка создания таблицы heleket_payments', error=e) return False @@ -758,7 +763,7 @@ async def create_mulenpay_payments_table(): """ else: - logger.error(f'Неподдерживаемый тип БД для таблицы mulenpay_payments: {db_type}') + logger.error('Неподдерживаемый тип БД для таблицы mulenpay_payments', db_type=db_type) return False await conn.execute(text(create_sql)) @@ -766,7 +771,7 @@ async def create_mulenpay_payments_table(): return True except Exception as e: - logger.error(f'Ошибка создания таблицы mulenpay_payments: {e}') + logger.error('Ошибка создания таблицы mulenpay_payments', error=e) return False @@ -793,8 +798,7 @@ async def ensure_mulenpay_payment_schema() -> bool: alter_sql = 'ALTER TABLE mulenpay_payments ADD COLUMN mulen_payment_id INT NULL' else: logger.error( - 'Неподдерживаемый тип БД для добавления mulen_payment_id в mulenpay_payments: %s', - db_type, + 'Неподдерживаемый тип БД для добавления mulen_payment_id в mulenpay_payments', db_type=db_type ) return False @@ -811,10 +815,7 @@ async def ensure_mulenpay_payment_schema() -> bool: elif db_type == 'mysql': alter_paid_at_sql = 'ALTER TABLE mulenpay_payments ADD COLUMN paid_at DATETIME NULL' else: - logger.error( - 'Неподдерживаемый тип БД для добавления paid_at в mulenpay_payments: %s', - db_type, - ) + logger.error('Неподдерживаемый тип БД для добавления paid_at в mulenpay_payments', db_type=db_type) return False await conn.execute(text(alter_paid_at_sql)) @@ -830,10 +831,7 @@ async def ensure_mulenpay_payment_schema() -> bool: elif db_type == 'mysql': create_index_sql = 'CREATE INDEX idx_mulenpay_payment_id ON mulenpay_payments(mulen_payment_id)' else: - logger.error( - 'Неподдерживаемый тип БД для создания индекса mulenpay_payment_id: %s', - db_type, - ) + logger.error('Неподдерживаемый тип БД для создания индекса mulenpay_payment_id', db_type=db_type) return False await conn.execute(text(create_index_sql)) @@ -844,7 +842,7 @@ async def ensure_mulenpay_payment_schema() -> bool: return True except Exception as e: - logger.error(f'Ошибка обновления схемы mulenpay_payments: {e}') + logger.error('Ошибка обновления схемы mulenpay_payments', error=e) return False @@ -980,7 +978,7 @@ async def create_pal24_payments_table(): """ else: - logger.error(f'Неподдерживаемый тип БД для таблицы pal24_payments: {db_type}') + logger.error('Неподдерживаемый тип БД для таблицы pal24_payments', db_type=db_type) return False await conn.execute(text(create_sql)) @@ -988,7 +986,7 @@ async def create_pal24_payments_table(): return True except Exception as e: - logger.error(f'Ошибка создания таблицы pal24_payments: {e}') + logger.error('Ошибка создания таблицы pal24_payments', error=e) return False @@ -1100,7 +1098,7 @@ async def create_wata_payments_table(): """ else: - logger.error(f'Неподдерживаемый тип БД для таблицы wata_payments: {db_type}') + logger.error('Неподдерживаемый тип БД для таблицы wata_payments', db_type=db_type) return False await conn.execute(text(create_sql)) @@ -1108,7 +1106,7 @@ async def create_wata_payments_table(): return True except Exception as e: - logger.error(f'Ошибка создания таблицы wata_payments: {e}') + logger.error('Ошибка создания таблицы wata_payments', error=e) return False @@ -1153,10 +1151,7 @@ async def ensure_wata_payment_schema() -> bool: await conn.execute(text('ALTER TABLE wata_payments ADD COLUMN payment_link_id VARCHAR(64)')) payment_link_column_exists = True else: - logger.warning( - '⚠️ Неизвестный тип БД %s — пропущено добавление payment_link_id', - db_type, - ) + logger.warning('⚠️ Неизвестный тип БД — пропущено добавление payment_link_id', db_type=db_type) if payment_link_column_exists: logger.info('✅ Добавлена колонка payment_link_id в wata_payments') @@ -1180,10 +1175,7 @@ async def ensure_wata_payment_schema() -> bool: await conn.execute(text('ALTER TABLE wata_payments ADD COLUMN order_id VARCHAR(255)')) order_id_column_exists = True else: - logger.warning( - '⚠️ Неизвестный тип БД %s — пропущено добавление order_id', - db_type, - ) + logger.warning('⚠️ Неизвестный тип БД — пропущено добавление order_id', db_type=db_type) if order_id_column_exists: logger.info('✅ Добавлена колонка order_id в wata_payments') @@ -1203,8 +1195,7 @@ async def ensure_wata_payment_schema() -> bool: index_created = True else: logger.warning( - '⚠️ Неизвестный тип БД %s — пропущено создание индекса idx_wata_order_id', - db_type, + '⚠️ Неизвестный тип БД — пропущено создание индекса idx_wata_order_id', db_type=db_type ) if index_created: @@ -1215,7 +1206,7 @@ async def ensure_wata_payment_schema() -> bool: return True except Exception as e: - logger.error(f'Ошибка обновления схемы wata_payments: {e}') + logger.error('Ошибка обновления схемы wata_payments', error=e) return False @@ -1319,7 +1310,7 @@ async def create_freekassa_payments_table(): """ else: - logger.error(f'Неподдерживаемый тип БД для таблицы freekassa_payments: {db_type}') + logger.error('Неподдерживаемый тип БД для таблицы freekassa_payments', db_type=db_type) return False await conn.execute(text(create_sql)) @@ -1327,7 +1318,7 @@ async def create_freekassa_payments_table(): return True except Exception as e: - logger.error(f'Ошибка создания таблицы freekassa_payments: {e}') + logger.error('Ошибка создания таблицы freekassa_payments', error=e) return False @@ -1431,7 +1422,7 @@ async def create_kassa_ai_payments_table(): """ else: - logger.error(f'Неподдерживаемый тип БД для таблицы kassa_ai_payments: {db_type}') + logger.error('Неподдерживаемый тип БД для таблицы kassa_ai_payments', db_type=db_type) return False await conn.execute(text(create_sql)) @@ -1439,7 +1430,7 @@ async def create_kassa_ai_payments_table(): return True except Exception as e: - logger.error(f'Ошибка создания таблицы kassa_ai_payments: {e}') + logger.error('Ошибка создания таблицы kassa_ai_payments', error=e) return False @@ -1545,7 +1536,7 @@ async def create_discount_offers_table(): return True except Exception as e: - logger.error(f'Ошибка создания таблицы discount_offers: {e}') + logger.error('Ошибка создания таблицы discount_offers', error=e) return False @@ -1638,7 +1629,7 @@ async def create_referral_contests_table() -> bool: logger.info('✅ Таблица referral_contests создана') return True except Exception as error: - logger.error(f'Ошибка создания таблицы referral_contests: {error}') + logger.error('Ошибка создания таблицы referral_contests', error=error) return False @@ -1727,7 +1718,7 @@ async def create_referral_contest_events_table() -> bool: logger.info('✅ Таблица referral_contest_events создана') return True except Exception as error: - logger.error(f'Ошибка создания таблицы referral_contest_events: {error}') + logger.error('Ошибка создания таблицы referral_contest_events', error=error) return False @@ -1786,7 +1777,7 @@ async def create_referral_contest_virtual_participants_table() -> bool: logger.info('✅ Таблица referral_contest_virtual_participants создана') return True except Exception as error: - logger.error(f'Ошибка создания таблицы referral_contest_virtual_participants: {error}') + logger.error('Ошибка создания таблицы referral_contest_virtual_participants', error=error) return False @@ -1795,7 +1786,7 @@ async def ensure_referral_contest_summary_columns() -> bool: for column in ['daily_summary_times', 'last_daily_summary_at']: exists = await check_column_exists('referral_contests', column) if exists: - logger.info('Колонка %s в referral_contests уже существует', column) + logger.info('Колонка в referral_contests уже существует', column=column) continue try: async with engine.begin() as conn: @@ -1814,10 +1805,10 @@ async def ensure_referral_contest_summary_columns() -> bool: + ('VARCHAR(255)' if column == 'daily_summary_times' else 'DATETIME') ) ) - logger.info('✅ Колонка %s в referral_contests добавлена', column) + logger.info('✅ Колонка в referral_contests добавлена', column=column) except Exception as error: ok = False - logger.error('Ошибка добавления %s в referral_contests: %s', column, error) + logger.error('Ошибка добавления в referral_contests', column=column, error=error) return ok @@ -1900,7 +1891,7 @@ async def create_contest_templates_table() -> bool: logger.info('✅ Таблица contest_templates создана') return True except Exception as error: - logger.error(f'Ошибка создания таблицы contest_templates: {error}') + logger.error('Ошибка создания таблицы contest_templates', error=error) return False @@ -1996,7 +1987,7 @@ async def create_contest_rounds_table() -> bool: logger.info('✅ Таблица contest_rounds создана') return True except Exception as error: - logger.error(f'Ошибка создания таблицы contest_rounds: {error}') + logger.error('Ошибка создания таблицы contest_rounds', error=error) return False @@ -2069,7 +2060,7 @@ async def create_contest_attempts_table() -> bool: logger.info('✅ Таблица contest_attempts создана') return True except Exception as error: - logger.error(f'Ошибка создания таблицы contest_attempts: {error}') + logger.error('Ошибка создания таблицы contest_attempts', error=error) return False @@ -2096,7 +2087,7 @@ async def ensure_referral_contest_type_column() -> bool: logger.info('✅ Колонка contest_type в referral_contests добавлена') return True except Exception as error: - logger.error(f'Ошибка добавления contest_type в referral_contests: {error}') + logger.error('Ошибка добавления contest_type в referral_contests', error=error) return False @@ -2133,7 +2124,7 @@ async def ensure_discount_offer_columns(): return True except Exception as e: - logger.error(f'Ошибка обновления колонок discount_offers: {e}') + logger.error('Ошибка обновления колонок discount_offers', error=e) return False @@ -2180,7 +2171,7 @@ async def ensure_user_promo_offer_discount_columns(): logger.info('✅ Колонки promo_offer_discount_* для users проверены') return True except Exception as e: - logger.error(f'Ошибка обновления колонок promo_offer_discount_*: {e}') + logger.error('Ошибка обновления колонок promo_offer_discount_*', error=e) return False @@ -2209,7 +2200,7 @@ async def ensure_user_notification_settings_column() -> bool: logger.info('✅ Колонка notification_settings для users добавлена') return True except Exception as e: - logger.error(f'Ошибка добавления колонки notification_settings: {e}') + logger.error('Ошибка добавления колонки notification_settings', error=e) return False @@ -2244,7 +2235,7 @@ async def ensure_promo_offer_template_active_duration_column() -> bool: logger.info('✅ Колонка active_discount_hours в promo_offer_templates актуальна') return True except Exception as e: - logger.error(f'Ошибка обновления active_discount_hours в promo_offer_templates: {e}') + logger.error('Ошибка обновления active_discount_hours в promo_offer_templates', error=e) return False @@ -2257,7 +2248,7 @@ async def migrate_discount_offer_effect_types(): logger.info('✅ Типы эффектов discount_offers обновлены на percent_discount') return True except Exception as e: - logger.error(f'Ошибка обновления типов эффектов discount_offers: {e}') + logger.error('Ошибка обновления типов эффектов discount_offers', error=e) return False @@ -2273,7 +2264,7 @@ async def reset_discount_offer_bonuses(): logger.info('✅ Бонусы промо-предложений сброшены до нуля') return True except Exception as e: - logger.error(f'Ошибка обнуления бонусов промо-предложений: {e}') + logger.error('Ошибка обнуления бонусов промо-предложений', error=e) return False @@ -2364,7 +2355,7 @@ async def create_promo_offer_templates_table(): return True except Exception as e: - logger.error(f'Ошибка создания таблицы promo_offer_templates: {e}') + logger.error('Ошибка создания таблицы promo_offer_templates', error=e) return False @@ -2427,7 +2418,7 @@ async def create_main_menu_buttons_table() -> bool: CREATE INDEX ix_main_menu_buttons_order ON main_menu_buttons(display_order, id); """ else: - logger.error(f'Неподдерживаемый тип БД для таблицы main_menu_buttons: {db_type}') + logger.error('Неподдерживаемый тип БД для таблицы main_menu_buttons', db_type=db_type) return False await conn.execute(text(create_sql)) @@ -2436,7 +2427,7 @@ async def create_main_menu_buttons_table() -> bool: return True except Exception as e: - logger.error(f'Ошибка создания таблицы main_menu_buttons: {e}') + logger.error('Ошибка создания таблицы main_menu_buttons', error=e) return False @@ -2509,13 +2500,13 @@ async def create_promo_offer_logs_table() -> bool: """) ) else: - logger.warning('Неизвестный тип БД для создания promo_offer_logs: %s', db_type) + logger.warning('Неизвестный тип БД для создания promo_offer_logs', db_type=db_type) return False logger.info('✅ Таблица promo_offer_logs успешно создана') return True except Exception as e: - logger.error(f'Ошибка создания таблицы promo_offer_logs: {e}') + logger.error('Ошибка создания таблицы promo_offer_logs', error=e) return False @@ -2596,7 +2587,7 @@ async def create_subscription_temporary_access_table(): return True except Exception as e: - logger.error(f'Ошибка создания таблицы subscription_temporary_access: {e}') + logger.error('Ошибка создания таблицы subscription_temporary_access', error=e) return False @@ -2661,7 +2652,7 @@ async def create_user_messages_table(): CREATE INDEX idx_user_messages_sort ON user_messages(sort_order, created_at); """ else: - logger.error(f'Неподдерживаемый тип БД для создания таблицы: {db_type}') + logger.error('Неподдерживаемый тип БД для создания таблицы', db_type=db_type) return False await conn.execute(text(create_sql)) @@ -2669,7 +2660,7 @@ async def create_user_messages_table(): return True except Exception as e: - logger.error(f'Ошибка создания таблицы user_messages: {e}') + logger.error('Ошибка создания таблицы user_messages', error=e) return False @@ -2740,7 +2731,7 @@ async def ensure_promo_groups_setup(): ) ) else: - logger.error(f'Неподдерживаемый тип БД для promo_groups: {db_type}') + logger.error('Неподдерживаемый тип БД для promo_groups', db_type=db_type) return False logger.info('Создана таблица promo_groups') @@ -2751,7 +2742,7 @@ async def ensure_promo_groups_setup(): text('ALTER TABLE promo_groups ADD CONSTRAINT uq_promo_groups_name UNIQUE (name)') ) except Exception as e: - logger.warning(f'Не удалось добавить уникальное ограничение uq_promo_groups_name: {e}') + logger.warning('Не удалось добавить уникальное ограничение uq_promo_groups_name', error=e) period_discounts_column_exists = await check_column_exists('promo_groups', 'period_discounts') @@ -2772,7 +2763,7 @@ async def ensure_promo_groups_setup(): text('UPDATE promo_groups SET period_discounts = JSON_OBJECT() WHERE period_discounts IS NULL') ) else: - logger.error(f'Неподдерживаемый тип БД для promo_groups.period_discounts: {db_type}') + logger.error('Неподдерживаемый тип БД для promo_groups.period_discounts', db_type=db_type) return False logger.info('Добавлена колонка promo_groups.period_discounts') @@ -2789,7 +2780,9 @@ async def ensure_promo_groups_setup(): text('ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INT DEFAULT 0') ) else: - logger.error(f'Неподдерживаемый тип БД для promo_groups.auto_assign_total_spent_kopeks: {db_type}') + logger.error( + 'Неподдерживаемый тип БД для promo_groups.auto_assign_total_spent_kopeks', db_type=db_type + ) return False logger.info('Добавлена колонка promo_groups.auto_assign_total_spent_kopeks') @@ -2830,7 +2823,7 @@ async def ensure_promo_groups_setup(): ) ) else: - logger.error(f'Неподдерживаемый тип БД для promo_groups.apply_discounts_to_addons: {db_type}') + logger.error('Неподдерживаемый тип БД для promo_groups.apply_discounts_to_addons', db_type=db_type) return False logger.info('Добавлена колонка promo_groups.apply_discounts_to_addons') @@ -2844,7 +2837,7 @@ async def ensure_promo_groups_setup(): elif db_type == 'mysql': await conn.execute(text('ALTER TABLE users ADD COLUMN promo_group_id INT')) else: - logger.error(f'Неподдерживаемый тип БД для promo_group_id: {db_type}') + logger.error('Неподдерживаемый тип БД для promo_group_id', db_type=db_type) return False logger.info('Добавлена колонка users.promo_group_id') @@ -2863,7 +2856,7 @@ async def ensure_promo_groups_setup(): text('ALTER TABLE users ADD COLUMN auto_promo_group_assigned TINYINT(1) DEFAULT 0') ) else: - logger.error(f'Неподдерживаемый тип БД для users.auto_promo_group_assigned: {db_type}') + logger.error('Неподдерживаемый тип БД для users.auto_promo_group_assigned', db_type=db_type) return False logger.info('Добавлена колонка users.auto_promo_group_assigned') @@ -2882,7 +2875,7 @@ async def ensure_promo_groups_setup(): text('ALTER TABLE users ADD COLUMN auto_promo_group_threshold_kopeks BIGINT NOT NULL DEFAULT 0') ) else: - logger.error(f'Неподдерживаемый тип БД для users.auto_promo_group_threshold_kopeks: {db_type}') + logger.error('Неподдерживаемый тип БД для users.auto_promo_group_threshold_kopeks', db_type=db_type) return False logger.info('Добавлена колонка users.auto_promo_group_threshold_kopeks') @@ -2899,7 +2892,7 @@ async def ensure_promo_groups_setup(): await conn.execute(text('CREATE INDEX ix_users_promo_group_id ON users(promo_group_id)')) logger.info('Создан индекс ix_users_promo_group_id') except Exception as e: - logger.warning(f'Не удалось создать индекс ix_users_promo_group_id: {e}') + logger.warning('Не удалось создать индекс ix_users_promo_group_id', error=e) default_group_name = 'Базовый юзер' default_group_id = None @@ -3023,12 +3016,12 @@ async def ensure_promo_groups_setup(): ) logger.info('Добавлен внешний ключ users -> promo_groups') except Exception as e: - logger.warning(f'Не удалось добавить внешний ключ users.promo_group_id: {e}') + logger.warning('Не удалось добавить внешний ключ users.promo_group_id', error=e) try: await conn.execute(text('ALTER TABLE users ALTER COLUMN promo_group_id SET NOT NULL')) except Exception as e: - logger.warning(f'Не удалось сделать users.promo_group_id NOT NULL: {e}') + logger.warning('Не удалось сделать users.promo_group_id NOT NULL', error=e) elif db_type == 'mysql': constraint_exists = await check_constraint_exists('users', 'fk_users_promo_group_id_promo_groups') @@ -3047,18 +3040,18 @@ async def ensure_promo_groups_setup(): ) logger.info('Добавлен внешний ключ users -> promo_groups') except Exception as e: - logger.warning(f'Не удалось добавить внешний ключ users.promo_group_id: {e}') + logger.warning('Не удалось добавить внешний ключ users.promo_group_id', error=e) try: await conn.execute(text('ALTER TABLE users MODIFY promo_group_id INT NOT NULL')) except Exception as e: - logger.warning(f'Не удалось сделать users.promo_group_id NOT NULL: {e}') + logger.warning('Не удалось сделать users.promo_group_id NOT NULL', error=e) logger.info('✅ Промо группы настроены') return True except Exception as e: - logger.error(f'Ошибка настройки промо групп: {e}') + logger.error('Ошибка настройки промо групп', error=e) return False @@ -3077,7 +3070,7 @@ async def add_welcome_text_is_enabled_column(): elif db_type == 'postgresql' or db_type == 'mysql': alter_sql = 'ALTER TABLE welcome_texts ADD COLUMN is_enabled BOOLEAN DEFAULT TRUE NOT NULL' else: - logger.error(f'Неподдерживаемый тип БД для добавления колонки: {db_type}') + logger.error('Неподдерживаемый тип БД для добавления колонки', db_type=db_type) return False await conn.execute(text(alter_sql)) @@ -3090,12 +3083,12 @@ async def add_welcome_text_is_enabled_column(): result = await conn.execute(text(update_sql)) updated_count = result.rowcount - logger.info(f'Обновлено {updated_count} существующих записей welcome_texts') + logger.info('Обновлено существующих записей welcome_texts', updated_count=updated_count) return True except Exception as e: - logger.error(f'Ошибка при добавлении поля is_enabled: {e}') + logger.error('Ошибка при добавлении поля is_enabled', error=e) return False @@ -3163,7 +3156,7 @@ async def create_welcome_texts_table(): CREATE INDEX idx_welcome_texts_updated ON welcome_texts(updated_at); """ else: - logger.error(f'Неподдерживаемый тип БД для создания таблицы: {db_type}') + logger.error('Неподдерживаемый тип БД для создания таблицы', db_type=db_type) return False await conn.execute(text(create_sql)) @@ -3171,7 +3164,7 @@ async def create_welcome_texts_table(): return True except Exception as e: - logger.error(f'Ошибка создания таблицы welcome_texts: {e}') + logger.error('Ошибка создания таблицы welcome_texts', error=e) return False @@ -3242,7 +3235,7 @@ async def create_pinned_messages_table(): """ else: - logger.error(f'Неподдерживаемый тип БД для создания таблицы pinned_messages: {db_type}') + logger.error('Неподдерживаемый тип БД для создания таблицы pinned_messages', db_type=db_type) return False await conn.execute(text(create_sql)) @@ -3251,7 +3244,7 @@ async def create_pinned_messages_table(): return True except Exception as e: - logger.error(f'Ошибка создания таблицы pinned_messages: {e}') + logger.error('Ошибка создания таблицы pinned_messages', error=e) return False @@ -3300,7 +3293,7 @@ async def ensure_pinned_message_media_columns(): return True except Exception as e: - logger.error(f'Ошибка обновления медиа полей pinned_messages: {e}') + logger.error('Ошибка обновления медиа полей pinned_messages', error=e) return False @@ -3312,7 +3305,7 @@ async def ensure_user_last_pinned_column(): logger.info('✅ Поле last_pinned_message_id у пользователей готово') return True except Exception as e: - logger.error(f'Ошибка добавления поля last_pinned_message_id: {e}') + logger.error('Ошибка добавления поля last_pinned_message_id', error=e) return False @@ -3334,7 +3327,7 @@ async def add_media_fields_to_broadcast_history(): field_exists = await check_column_exists('broadcast_history', field_name) if not field_exists: - logger.info(f'Добавление поля {field_name} в таблицу broadcast_history') + logger.info('Добавление поля в таблицу broadcast_history', field_name=field_name) if db_type == 'sqlite': if 'BOOLEAN' in field_type: @@ -3345,15 +3338,15 @@ async def add_media_fields_to_broadcast_history(): alter_sql = f'ALTER TABLE broadcast_history ADD COLUMN {field_name} {field_type}' await conn.execute(text(alter_sql)) - logger.info(f'✅ Поле {field_name} успешно добавлено') + logger.info('✅ Поле успешно добавлено', field_name=field_name) else: - logger.info(f'Поле {field_name} уже существует в broadcast_history') + logger.info('Поле уже существует в broadcast_history', field_name=field_name) logger.info('✅ Все поля медиа в broadcast_history готовы') return True except Exception as e: - logger.error(f'Ошибка при добавлении полей медиа в broadcast_history: {e}') + logger.error('Ошибка при добавлении полей медиа в broadcast_history', error=e) return False @@ -3376,13 +3369,13 @@ async def add_email_fields_to_broadcast_history(): field_exists = await check_column_exists('broadcast_history', field_name) if not field_exists: - logger.info(f'Добавление поля {field_name} в таблицу broadcast_history') + logger.info('Добавление поля в таблицу broadcast_history', field_name=field_name) alter_sql = f'ALTER TABLE broadcast_history ADD COLUMN {field_name} {field_type}' await conn.execute(text(alter_sql)) - logger.info(f'✅ Поле {field_name} успешно добавлено') + logger.info('✅ Поле успешно добавлено', field_name=field_name) else: - logger.info(f'Поле {field_name} уже существует в broadcast_history') + logger.info('Поле уже существует в broadcast_history', field_name=field_name) # Сделать message_text nullable для email-only рассылок try: @@ -3395,13 +3388,13 @@ async def add_email_fields_to_broadcast_history(): # SQLite не поддерживает ALTER COLUMN, но там по умолчанию nullable except Exception as e: # Игнорируем если уже nullable или другая ошибка - logger.debug(f'message_text nullable: {e}') + logger.debug('message_text nullable', error=e) logger.info('✅ Все поля email в broadcast_history готовы') return True except Exception as e: - logger.error(f'Ошибка при добавлении полей email в broadcast_history: {e}') + logger.error('Ошибка при добавлении полей email в broadcast_history', error=e) return False @@ -3424,7 +3417,7 @@ async def add_ticket_reply_block_columns(): 'ALTER TABLE tickets ADD COLUMN user_reply_block_permanent BOOLEAN DEFAULT FALSE NOT NULL' ) else: - logger.error(f'Неподдерживаемый тип БД для добавления user_reply_block_permanent: {db_type}') + logger.error('Неподдерживаемый тип БД для добавления user_reply_block_permanent', db_type=db_type) return False await conn.execute(text(alter_sql)) logger.info('✅ Добавлена колонка tickets.user_reply_block_permanent') @@ -3437,14 +3430,14 @@ async def add_ticket_reply_block_columns(): elif db_type == 'mysql': alter_sql = 'ALTER TABLE tickets ADD COLUMN user_reply_block_until DATETIME NULL' else: - logger.error(f'Неподдерживаемый тип БД для добавления user_reply_block_until: {db_type}') + logger.error('Неподдерживаемый тип БД для добавления user_reply_block_until', db_type=db_type) return False await conn.execute(text(alter_sql)) logger.info('✅ Добавлена колонка tickets.user_reply_block_until') return True except Exception as e: - logger.error(f'Ошибка добавления колонок блокировок в tickets: {e}') + logger.error('Ошибка добавления колонок блокировок в tickets', error=e) return False @@ -3462,13 +3455,13 @@ async def add_ticket_sla_columns(): elif db_type == 'mysql': alter_sql = 'ALTER TABLE tickets ADD COLUMN last_sla_reminder_at DATETIME NULL' else: - logger.error(f'Неподдерживаемый тип БД для добавления last_sla_reminder_at: {db_type}') + logger.error('Неподдерживаемый тип БД для добавления last_sla_reminder_at', db_type=db_type) return False await conn.execute(text(alter_sql)) logger.info('✅ Добавлена колонка tickets.last_sla_reminder_at') return True except Exception as e: - logger.error(f'Ошибка добавления SLA колонки в tickets: {e}') + logger.error('Ошибка добавления SLA колонки в tickets', error=e) return False @@ -3496,7 +3489,7 @@ async def add_user_restriction_columns() -> bool: text('ALTER TABLE users ADD COLUMN restriction_topup BOOLEAN DEFAULT FALSE NOT NULL') ) else: - logger.error(f'Неподдерживаемый тип БД: {db_type}') + logger.error('Неподдерживаемый тип БД', db_type=db_type) return False logger.info('✅ Добавлена колонка users.restriction_topup') @@ -3510,7 +3503,7 @@ async def add_user_restriction_columns() -> bool: text('ALTER TABLE users ADD COLUMN restriction_subscription BOOLEAN DEFAULT FALSE NOT NULL') ) else: - logger.error(f'Неподдерживаемый тип БД: {db_type}') + logger.error('Неподдерживаемый тип БД', db_type=db_type) return False logger.info('✅ Добавлена колонка users.restriction_subscription') @@ -3518,14 +3511,14 @@ async def add_user_restriction_columns() -> bool: if db_type == 'sqlite' or db_type == 'postgresql' or db_type == 'mysql': await conn.execute(text('ALTER TABLE users ADD COLUMN restriction_reason VARCHAR(500) NULL')) else: - logger.error(f'Неподдерживаемый тип БД: {db_type}') + logger.error('Неподдерживаемый тип БД', db_type=db_type) return False logger.info('✅ Добавлена колонка users.restriction_reason') return True except Exception as e: - logger.error(f'Ошибка добавления колонок ограничений пользователей: {e}') + logger.error('Ошибка добавления колонок ограничений пользователей', error=e) return False @@ -3565,17 +3558,17 @@ async def add_user_cabinet_columns() -> bool: await conn.execute(text(f'ALTER TABLE users ADD COLUMN {col_name} {col_type}')) added_count += 1 - logger.info(f'✅ Добавлена колонка users.{col_name}') + logger.info('✅ Добавлена колонка users.', col_name=col_name) if added_count == 0: logger.info('ℹ️ Все колонки cabinet уже существуют в таблице users') else: - logger.info(f'✅ Добавлено {added_count} колонок cabinet в таблицу users') + logger.info('✅ Добавлено колонок cabinet в таблицу users', added_count=added_count) return True except Exception as e: - logger.error(f'Ошибка добавления колонок cabinet: {e}') + logger.error('Ошибка добавления колонок cabinet', error=e) return False @@ -3596,7 +3589,7 @@ async def add_subscription_crypto_link_column() -> bool: elif db_type == 'mysql': await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN subscription_crypto_link VARCHAR(512)')) else: - logger.error(f'Неподдерживаемый тип БД для добавления subscription_crypto_link: {db_type}') + logger.error('Неподдерживаемый тип БД для добавления subscription_crypto_link', db_type=db_type) return False await conn.execute( @@ -3609,7 +3602,7 @@ async def add_subscription_crypto_link_column() -> bool: logger.info('✅ Добавлена колонка subscription_crypto_link в таблицу subscriptions') return True except Exception as e: - logger.error(f'Ошибка добавления колонки subscription_crypto_link: {e}') + logger.error('Ошибка добавления колонки subscription_crypto_link', error=e) return False @@ -3630,13 +3623,13 @@ async def add_subscription_last_webhook_update_column() -> bool: elif db_type == 'mysql': await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN last_webhook_update_at DATETIME')) else: - logger.error(f'Неподдерживаемый тип БД для добавления last_webhook_update_at: {db_type}') + logger.error('Неподдерживаемый тип БД для добавления last_webhook_update_at', db_type=db_type) return False logger.info('✅ Добавлена колонка last_webhook_update_at в таблицу subscriptions') return True except Exception as e: - logger.error(f'Ошибка добавления колонки last_webhook_update_at: {e}') + logger.error('Ошибка добавления колонки last_webhook_update_at', error=e) return False @@ -3663,7 +3656,7 @@ async def fix_foreign_keys_for_user_deletion(): ) logger.info('Обновлен внешний ключ user_messages.created_by') except Exception as e: - logger.warning(f'Ошибка обновления FK user_messages: {e}') + logger.warning('Ошибка обновления FK user_messages', error=e) try: await conn.execute( @@ -3682,13 +3675,13 @@ async def fix_foreign_keys_for_user_deletion(): ) logger.info('Обновлен внешний ключ promocodes.created_by') except Exception as e: - logger.warning(f'Ошибка обновления FK promocodes: {e}') + logger.warning('Ошибка обновления FK promocodes', error=e) logger.info('Внешние ключи обновлены для безопасного удаления пользователей') return True except Exception as e: - logger.error(f'Ошибка обновления внешних ключей: {e}') + logger.error('Ошибка обновления внешних ключей', error=e) return False @@ -3707,7 +3700,7 @@ async def add_referral_commission_percent_column() -> bool: elif db_type == 'mysql': alter_sql = 'ALTER TABLE users ADD COLUMN referral_commission_percent INT NULL' else: - logger.error(f'Неподдерживаемый тип БД для добавления referral_commission_percent: {db_type}') + logger.error('Неподдерживаемый тип БД для добавления referral_commission_percent', db_type=db_type) return False await conn.execute(text(alter_sql)) @@ -3715,7 +3708,7 @@ async def add_referral_commission_percent_column() -> bool: return True except Exception as error: - logger.error(f'Ошибка добавления referral_commission_percent: {error}') + logger.error('Ошибка добавления referral_commission_percent', error=error) return False @@ -3757,7 +3750,7 @@ async def add_referral_system_columns(): result = await conn.execute(text(update_sql)) updated_count = result.rowcount - logger.info(f'Обновлено {updated_count} пользователей с has_made_first_topup = TRUE') + logger.info('Обновлено пользователей с has_made_first_topup TRUE', updated_count=updated_count) logger.info('✅ Миграция реферальной системы завершена') return True @@ -3765,7 +3758,7 @@ async def add_referral_system_columns(): return True except Exception as e: - logger.error(f'Ошибка миграции реферальной системы: {e}') + logger.error('Ошибка миграции реферальной системы', error=e) return False @@ -3833,7 +3826,7 @@ async def create_subscription_conversions_table(): CREATE INDEX idx_subscription_conversions_converted_at ON subscription_conversions(converted_at); """ else: - logger.error(f'Неподдерживаемый тип БД для создания таблицы: {db_type}') + logger.error('Неподдерживаемый тип БД для создания таблицы', db_type=db_type) return False await conn.execute(text(create_sql)) @@ -3841,7 +3834,7 @@ async def create_subscription_conversions_table(): return True except Exception as e: - logger.error(f'Ошибка создания таблицы subscription_conversions: {e}') + logger.error('Ошибка создания таблицы subscription_conversions', error=e) return False @@ -3921,7 +3914,7 @@ async def create_subscription_events_table(): CREATE INDEX ix_subscription_events_user_id ON subscription_events(user_id); """ else: - logger.error(f'Неподдерживаемый тип БД для создания таблицы subscription_events: {db_type}') + logger.error('Неподдерживаемый тип БД для создания таблицы subscription_events', db_type=db_type) return False await conn.execute(text(create_sql)) @@ -3929,14 +3922,14 @@ async def create_subscription_events_table(): return True except Exception as e: - logger.error(f'Ошибка создания таблицы subscription_events: {e}') + logger.error('Ошибка создания таблицы subscription_events', error=e) return False async def fix_subscription_duplicates_universal(): async with engine.begin() as conn: db_type = await get_database_type() - logger.info(f'Обнаружен тип базы данных: {db_type}') + logger.info('Обнаружен тип базы данных', db_type=db_type) try: result = await conn.execute( @@ -3954,7 +3947,7 @@ async def fix_subscription_duplicates_universal(): logger.info('Дублирующихся подписок не найдено') return 0 - logger.info(f'Найдено {len(duplicates)} пользователей с дублирующимися подписками') + logger.info('Найдено пользователей с дублирующимися подписками', duplicates_count=len(duplicates)) total_deleted = 0 @@ -4016,13 +4009,15 @@ async def fix_subscription_duplicates_universal(): deleted_count = delete_result.rowcount total_deleted += deleted_count - logger.info(f'Удалено {deleted_count} дублирующихся подписок для пользователя {user_id}') + logger.info( + 'Удалено дублирующихся подписок для пользователя', deleted_count=deleted_count, user_id=user_id + ) - logger.info(f'Всего удалено дублирующихся подписок: {total_deleted}') + logger.info('Всего удалено дублирующихся подписок', total_deleted=total_deleted) return total_deleted except Exception as e: - logger.error(f'Ошибка при очистке дублирующихся подписок: {e}') + logger.error('Ошибка при очистке дублирующихся подписок', error=e) raise @@ -4113,14 +4108,14 @@ async def ensure_server_promo_groups_setup() -> bool: assigned_count += 1 if assigned_count: - logger.info(f'✅ Базовая промогруппа назначена {assigned_count} серверам') + logger.info('✅ Базовая промогруппа назначена серверам', assigned_count=assigned_count) else: logger.info('ℹ️ Все серверы уже имеют назначенные промогруппы') return True except Exception as e: - logger.error(f'Ошибка настройки таблицы server_squad_promo_groups: {e}') + logger.error('Ошибка настройки таблицы server_squad_promo_groups', error=e) return False @@ -4150,7 +4145,7 @@ async def add_server_trial_flag_column() -> bool: return True except Exception as error: - logger.error(f'Ошибка добавления колонки is_trial_eligible: {error}') + logger.error('Ошибка добавления колонки is_trial_eligible', error=error) return False @@ -4203,7 +4198,7 @@ async def create_system_settings_table() -> bool: return True except Exception as error: - logger.error(f'Ошибка создания таблицы system_settings: {error}') + logger.error('Ошибка создания таблицы system_settings', error=error) return False @@ -4258,7 +4253,7 @@ async def create_menu_layout_history_table() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка создания таблицы menu_layout_history: {error}') + logger.error('❌ Ошибка создания таблицы menu_layout_history', error=error) return False @@ -4328,7 +4323,7 @@ async def create_button_click_logs_table() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка создания таблицы button_click_logs: {error}') + logger.error('❌ Ошибка создания таблицы button_click_logs', error=error) return False @@ -4396,7 +4391,7 @@ async def fix_button_click_logs_fk() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка исправления FK button_click_logs: {error}') + logger.error('❌ Ошибка исправления FK button_click_logs', error=error) return False @@ -4476,7 +4471,7 @@ async def create_web_api_tokens_table() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка создания таблицы web_api_tokens: {error}') + logger.error('❌ Ошибка создания таблицы web_api_tokens', error=error) return False @@ -4529,7 +4524,7 @@ async def create_privacy_policies_table() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка создания таблицы privacy_policies: {error}') + logger.error('❌ Ошибка создания таблицы privacy_policies', error=error) return False @@ -4582,7 +4577,7 @@ async def create_public_offers_table() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка создания таблицы public_offers: {error}') + logger.error('❌ Ошибка создания таблицы public_offers', error=error) return False @@ -4632,7 +4627,7 @@ async def create_faq_settings_table() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка создания таблицы faq_settings: {error}') + logger.error('❌ Ошибка создания таблицы faq_settings', error=error) return False @@ -4696,7 +4691,7 @@ async def create_faq_pages_table() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка создания таблицы faq_pages: {error}') + logger.error('❌ Ошибка создания таблицы faq_pages', error=error) return False @@ -4743,7 +4738,7 @@ async def ensure_default_web_api_token() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка создания дефолтного веб-API токена: {error}') + logger.error('❌ Ошибка создания дефолтного веб-API токена', error=error) return False @@ -4777,7 +4772,7 @@ async def add_promo_group_priority_column() -> bool: return True except Exception as error: - logger.error(f'Ошибка добавления колонки priority: {error}') + logger.error('Ошибка добавления колонки priority', error=error) return False @@ -4838,7 +4833,7 @@ async def create_user_promo_groups_table() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка создания таблицы user_promo_groups: {error}') + logger.error('❌ Ошибка создания таблицы user_promo_groups', error=error) return False @@ -4861,7 +4856,7 @@ async def migrate_existing_user_promo_groups_data() -> bool: count = result.scalar() if count > 0: - logger.info(f'ℹ️ В таблице user_promo_groups уже есть {count} записей, пропускаем миграцию') + logger.info('ℹ️ В таблице user_promo_groups уже есть записей, пропускаем миграцию', count=count) return True # Переносим данные из users.promo_group_id @@ -4885,11 +4880,11 @@ async def migrate_existing_user_promo_groups_data() -> bool: result = await conn.execute(text(migrate_sql)) migrated_count = result.rowcount if hasattr(result, 'rowcount') else 0 - logger.info(f'✅ Перенесено {migrated_count} связей пользователей с промогруппами') + logger.info('✅ Перенесено связей пользователей с промогруппами', migrated_count=migrated_count) return True except Exception as error: - logger.error(f'❌ Ошибка миграции данных user_promo_groups: {error}') + logger.error('❌ Ошибка миграции данных user_promo_groups', error=error) return False @@ -4940,7 +4935,7 @@ async def add_promocode_promo_group_column() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка добавления promo_group_id в promocodes: {error}') + logger.error('❌ Ошибка добавления promo_group_id в promocodes', error=error) return False @@ -4964,7 +4959,7 @@ async def add_promocode_first_purchase_only_column() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка добавления first_purchase_only в promocodes: {error}') + logger.error('❌ Ошибка добавления first_purchase_only в promocodes', error=error) return False @@ -5018,7 +5013,7 @@ async def migrate_contest_templates_prize_columns() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка миграции prize_type/prize_value в contest_templates: {error}') + logger.error('❌ Ошибка миграции prize_type/prize_value в contest_templates', error=error) return False @@ -5044,7 +5039,7 @@ async def add_subscription_modem_enabled_column() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка добавления modem_enabled в subscriptions: {error}') + logger.error('❌ Ошибка добавления modem_enabled в subscriptions', error=error) return False @@ -5068,7 +5063,7 @@ async def add_subscription_purchased_traffic_column() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка добавления purchased_traffic_gb в subscriptions: {error}') + logger.error('❌ Ошибка добавления purchased_traffic_gb в subscriptions', error=error) return False @@ -5112,12 +5107,12 @@ async def add_transaction_receipt_columns() -> bool: else: await conn.execute(text('CREATE INDEX ix_transactions_receipt_uuid ON transactions (receipt_uuid)')) except Exception as idx_error: - logger.warning(f'Индекс на receipt_uuid возможно уже существует: {idx_error}') + logger.warning('Индекс на receipt_uuid возможно уже существует', idx_error=idx_error) return True except Exception as error: - logger.error(f'❌ Ошибка добавления колонок чеков в transactions: {error}') + logger.error('❌ Ошибка добавления колонок чеков в transactions', error=error) return False @@ -5164,12 +5159,12 @@ async def add_oauth_provider_columns() -> bool: else: await conn.execute(text(f'CREATE UNIQUE INDEX uq_users_{col} ON users ({col})')) except Exception as idx_error: - logger.warning(f'Индекс uq_users_{col} возможно уже существует: {idx_error}') + logger.warning('Индекс uq_users_ возможно уже существует', col=col, idx_error=idx_error) return True except Exception as error: - logger.error(f'❌ Ошибка добавления колонок OAuth провайдеров в users: {error}') + logger.error('❌ Ошибка добавления колонок OAuth провайдеров в users', error=error) return False @@ -5251,7 +5246,7 @@ async def create_withdrawal_requests_table() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка создания таблицы withdrawal_requests: {error}') + logger.error('❌ Ошибка создания таблицы withdrawal_requests', error=error) return False @@ -5314,7 +5309,7 @@ async def create_traffic_purchases_table() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка создания таблицы traffic_purchases: {error}') + logger.error('❌ Ошибка создания таблицы traffic_purchases', error=error) return False @@ -5398,7 +5393,7 @@ async def create_tariffs_table() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка создания таблицы tariffs: {error}') + logger.error('❌ Ошибка создания таблицы tariffs', error=error) return False @@ -5451,7 +5446,7 @@ async def create_tariff_promo_groups_table() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка создания таблицы tariff_promo_groups: {error}') + logger.error('❌ Ошибка создания таблицы tariff_promo_groups', error=error) return False @@ -5475,7 +5470,7 @@ async def ensure_tariff_max_device_limit_column() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка добавления колонки max_device_limit: {error}') + logger.error('❌ Ошибка добавления колонки max_device_limit', error=error) return False @@ -5517,7 +5512,7 @@ async def add_subscription_tariff_id_column() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка добавления колонки tariff_id: {error}') + logger.error('❌ Ошибка добавления колонки tariff_id', error=error) return False @@ -5606,7 +5601,7 @@ async def add_campaign_tariff_columns() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка добавления колонок tariff в рекламные кампании: {error}') + logger.error('❌ Ошибка добавления колонок tariff в рекламные кампании', error=error) return False @@ -5629,7 +5624,7 @@ async def add_tariff_device_price_column() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка добавления колонки device_price_kopeks: {error}') + logger.error('❌ Ошибка добавления колонки device_price_kopeks', error=error) return False @@ -5654,7 +5649,7 @@ async def add_tariff_server_traffic_limits_column() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка добавления колонки server_traffic_limits: {error}') + logger.error('❌ Ошибка добавления колонки server_traffic_limits', error=error) return False @@ -5685,7 +5680,7 @@ async def add_tariff_allow_traffic_topup_column() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка добавления колонки allow_traffic_topup: {error}') + logger.error('❌ Ошибка добавления колонки allow_traffic_topup', error=error) return False @@ -5916,7 +5911,7 @@ async def create_wheel_tables() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка создания таблиц для колеса удачи: {error}') + logger.error('❌ Ошибка создания таблиц для колеса удачи', error=error) return False @@ -5987,7 +5982,7 @@ async def add_tariff_traffic_topup_columns() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка добавления колонок для докупки трафика: {error}') + logger.error('❌ Ошибка добавления колонок для докупки трафика', error=error) return False @@ -6033,7 +6028,7 @@ async def add_tariff_daily_columns() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка добавления колонок суточного тарифа: {error}') + logger.error('❌ Ошибка добавления колонок суточного тарифа', error=error) return False @@ -6144,12 +6139,12 @@ async def add_tariff_custom_days_traffic_columns() -> bool: logger.info('ℹ️ Колонка max_traffic_gb уже существует в tariffs') if columns_added > 0: - logger.info(f'✅ Добавлено {columns_added} колонок для произвольных дней/трафика') + logger.info('✅ Добавлено колонок для произвольных дней/трафика', columns_added=columns_added) return True except Exception as error: - logger.error(f'❌ Ошибка добавления колонок произвольных дней/трафика: {error}') + logger.error('❌ Ошибка добавления колонок произвольных дней/трафика', error=error) return False @@ -6169,7 +6164,7 @@ async def add_tariff_traffic_reset_mode_column() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка добавления колонки traffic_reset_mode: {error}') + logger.error('❌ Ошибка добавления колонки traffic_reset_mode', error=error) return False @@ -6221,7 +6216,7 @@ async def add_subscription_daily_columns() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка добавления колонок суточной подписки: {error}') + logger.error('❌ Ошибка добавления колонок суточной подписки', error=error) return False @@ -6246,7 +6241,7 @@ async def add_subscription_traffic_reset_at_column() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка добавления колонки traffic_reset_at: {error}') + logger.error('❌ Ошибка добавления колонки traffic_reset_at', error=error) return False @@ -6271,7 +6266,7 @@ async def add_user_email_auth_columns() -> bool: text("ALTER TABLE users ADD COLUMN auth_type VARCHAR(20) DEFAULT 'telegram' NOT NULL") ) else: - logger.error(f'Неподдерживаемый тип БД: {db_type}') + logger.error('Неподдерживаемый тип БД', db_type=db_type) return False logger.info('✅ Добавлена колонка users.auth_type') else: @@ -6325,7 +6320,7 @@ async def add_user_email_auth_columns() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка миграции email auth: {error}') + logger.error('❌ Ошибка миграции email auth', error=error) return False @@ -6389,7 +6384,7 @@ async def create_email_templates_table() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка создания таблицы email_templates: {error}') + logger.error('❌ Ошибка создания таблицы email_templates', error=error) return False @@ -6455,7 +6450,7 @@ async def migrate_cloudpayments_transaction_id_to_bigint() -> bool: return True except Exception as error: - logger.error(f'❌ Ошибка миграции transaction_id_cp на BIGINT: {error}') + logger.error('❌ Ошибка миграции transaction_id_cp на BIGINT', error=error) return False @@ -6464,7 +6459,7 @@ async def run_universal_migration(): try: db_type = await get_database_type() - logger.info(f'Тип базы данных: {db_type}') + logger.info('Тип базы данных', db_type=db_type) if db_type == 'postgresql': logger.info('=== СИНХРОНИЗАЦИЯ ПОСЛЕДОВАТЕЛЬНОСТЕЙ PostgreSQL ===') @@ -6576,18 +6571,18 @@ async def run_universal_migration(): logger.warning('⚠️ Проблемы с таблицей Heleket payments') mulenpay_name = settings.get_mulenpay_display_name() - logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ %s ===', mulenpay_name) + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ =', mulenpay_name=mulenpay_name) mulenpay_created = await create_mulenpay_payments_table() if mulenpay_created: - logger.info('✅ Таблица %s payments готова', mulenpay_name) + logger.info('✅ Таблица payments готова', mulenpay_name=mulenpay_name) else: - logger.warning('⚠️ Проблемы с таблицей %s payments', mulenpay_name) + logger.warning('⚠️ Проблемы с таблицей payments', mulenpay_name=mulenpay_name) mulenpay_schema_ok = await ensure_mulenpay_payment_schema() if mulenpay_schema_ok: - logger.info('✅ Схема %s payments актуальна', mulenpay_name) + logger.info('✅ Схема payments актуальна', mulenpay_name=mulenpay_name) else: - logger.warning('⚠️ Не удалось обновить схему %s payments', mulenpay_name) + logger.warning('⚠️ Не удалось обновить схему payments', mulenpay_name=mulenpay_name) logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ PAL24 ===') pal24_created = await create_pal24_payments_table() @@ -6821,14 +6816,12 @@ async def run_universal_migration(): else: logger.warning('⚠️ Не удалось обновить колонку last_pinned_message_id') - logger.info('=== ДОБАВЛЕНИЕ МЕДИА ПОЛЕЙ В BROADCAST_HISTORY ===') media_fields_added = await add_media_fields_to_broadcast_history() if media_fields_added: logger.info('✅ Медиа поля в broadcast_history готовы') else: logger.warning('⚠️ Проблемы с добавлением медиа полей') - logger.info('=== ДОБАВЛЕНИЕ EMAIL ПОЛЕЙ В BROADCAST_HISTORY ===') email_fields_added = await add_email_fields_to_broadcast_history() if email_fields_added: logger.info('✅ Email поля в broadcast_history готовы') @@ -6948,9 +6941,8 @@ async def run_universal_migration(): else: logger.info('ℹ️ Таблица support_audit_logs уже существует') except Exception as e: - logger.warning(f'⚠️ Проблемы с созданием таблицы support_audit_logs: {e}') + logger.warning('⚠️ Проблемы с созданием таблицы support_audit_logs', error=e) - logger.info('=== НАСТРОЙКА ПРОМО ГРУПП ===') promo_groups_ready = await ensure_promo_groups_setup() if promo_groups_ready: logger.info('✅ Промо группы готовы') @@ -7145,8 +7137,8 @@ async def run_universal_migration(): total_count = total_subs.fetchone()[0] unique_count = unique_users.fetchone()[0] - logger.info(f'Всего подписок: {total_count}') - logger.info(f'Уникальных пользователей: {unique_count}') + logger.info('Всего подписок', total_count=total_count) + logger.info('Уникальных пользователей', unique_count=unique_count) if total_count == unique_count: logger.info('База данных уже в корректном состоянии') @@ -7168,7 +7160,9 @@ async def run_universal_migration(): remaining_duplicates = final_check.fetchall() if remaining_duplicates: - logger.warning(f'Остались дубликаты у {len(remaining_duplicates)} пользователей') + logger.warning( + 'Остались дубликаты у пользователей', remaining_duplicates_count=len(remaining_duplicates) + ) return False logger.info('=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===') logger.info('✅ Реферальная система обновлена') @@ -7182,7 +7176,7 @@ async def run_universal_migration(): return True except Exception as e: - logger.error(f'=== ОШИБКА ВЫПОЛНЕНИЯ МИГРАЦИИ: {e} ===') + logger.error('ОШИБКА ВЫПОЛНЕНИЯ МИГРАЦИИ', error=e) return False @@ -7470,7 +7464,9 @@ async def check_migration_status(): for check_key, check_status in status.items(): check_name = check_names.get(check_key, check_key) icon = '✅' if check_status else '❌' - logger.info(f'{icon} {check_name}: {"OK" if check_status else "ТРЕБУЕТ ВНИМАНИЯ"}') + logger.info( + 'log event', icon=icon, check_name=check_name, value='OK' if check_status else 'ТРЕБУЕТ ВНИМАНИЯ' + ) all_good = all(status.values()) if all_good: @@ -7489,18 +7485,22 @@ async def check_migration_status(): broadcast_count = broadcasts_count.fetchone()[0] logger.info( - f'📊 Статистика: {usr_count} пользователей, {conv_count} конверсий, {welcome_count} приветственных текстов, {broadcast_count} рассылок' + '📊 Статистика: пользователей, конверсий, приветственных текстов, рассылок', + usr_count=usr_count, + conv_count=conv_count, + welcome_count=welcome_count, + broadcast_count=broadcast_count, ) except Exception as stats_error: - logger.debug(f'Не удалось получить дополнительную статистику: {stats_error}') + logger.debug('Не удалось получить дополнительную статистику', stats_error=stats_error) else: logger.warning('⚠️ Некоторые миграции требуют внимания') missing_migrations = [check_names[k] for k, v in status.items() if not v] - logger.warning(f'Требуют выполнения: {", ".join(missing_migrations)}') + logger.warning('Требуют выполнения', value=', '.join(missing_migrations)) return status except Exception as e: - logger.error(f'Ошибка проверки статуса миграций: {e}') + logger.error('Ошибка проверки статуса миграций', error=e) return None diff --git a/app/external/ban_system_api.py b/app/external/ban_system_api.py index 7ad39479..a0903406 100644 --- a/app/external/ban_system_api.py +++ b/app/external/ban_system_api.py @@ -4,13 +4,13 @@ Ban System API Client. Client for interacting with the BedolagaBan monitoring system. """ -import logging from typing import Any import aiohttp +import structlog -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class BanSystemAPIError(Exception): @@ -78,7 +78,7 @@ class BanSystemAPI: response_text = await response.text() if response.status >= 400: - logger.error(f'Ban System API error: {response.status} - {response_text}') + logger.error('Ban System API error', status=response.status, response_text=response_text) raise BanSystemAPIError( message=f'API error {response.status}: {response_text}', status_code=response.status, @@ -93,7 +93,7 @@ class BanSystemAPI: return {} except aiohttp.ClientError as e: - logger.error(f'Ban System API connection error: {e}') + logger.error('Ban System API connection error', error=e) raise BanSystemAPIError(message=f'Connection error: {e!s}', status_code=None, response_data=None) except TimeoutError: logger.error('Ban System API request timeout') diff --git a/app/external/cryptobot.py b/app/external/cryptobot.py index d91d4849..75cf9165 100644 --- a/app/external/cryptobot.py +++ b/app/external/cryptobot.py @@ -1,14 +1,14 @@ import hashlib import hmac -import logging from typing import Any import aiohttp +import structlog from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class CryptoBotService: @@ -49,11 +49,11 @@ class CryptoBotService: if response.status == 200 and response_data.get('ok'): return response_data.get('result') - logger.error(f'CryptoBot API ошибка: {response_data}') + logger.error('CryptoBot API ошибка', response_data=response_data) return None except Exception as e: - logger.error(f'Ошибка запроса к CryptoBot API: {e}') + logger.error('Ошибка запроса к CryptoBot API', error=e) return None async def get_me(self) -> dict[str, Any] | None: @@ -81,7 +81,7 @@ class CryptoBotService: result = await self._make_request('POST', 'createInvoice', data) if result: - logger.info(f'Создан CryptoBot invoice {result.get("invoice_id")} на {amount} {asset}') + logger.info('Создан CryptoBot invoice на', get=result.get('invoice_id'), amount=amount, asset=asset) return result @@ -140,7 +140,7 @@ class CryptoBotService: return is_valid except Exception as e: - logger.error(f'Ошибка проверки подписи CryptoBot webhook: {e}') + logger.error('Ошибка проверки подписи CryptoBot webhook', error=e) return False async def process_webhook(self, webhook_data: dict[str, Any]) -> dict[str, Any] | None: @@ -161,9 +161,9 @@ class CryptoBotService: 'payment_system': 'cryptobot', } - logger.warning(f'Неизвестный тип CryptoBot webhook: {update_type}') + logger.warning('Неизвестный тип CryptoBot webhook', update_type=update_type) return None except Exception as e: - logger.error(f'Ошибка обработки CryptoBot webhook: {e}') + logger.error('Ошибка обработки CryptoBot webhook', error=e) return None diff --git a/app/external/heleket.py b/app/external/heleket.py index b4ed0163..5694c94f 100644 --- a/app/external/heleket.py +++ b/app/external/heleket.py @@ -6,15 +6,15 @@ import base64 import hashlib import hmac import json -import logging from typing import Any import aiohttp +import structlog from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class HeleketService: @@ -93,26 +93,28 @@ class HeleketService: ): text = await response.text() if response.content_type != 'application/json': - logger.error('Ответ Heleket не JSON (%s): %s', response.content_type, text) + logger.error('Ответ Heleket не JSON', content_type=response.content_type, text=text) return None try: data = json.loads(text) except json.JSONDecodeError: - logger.error('Ошибка парсинга Heleket JSON: %s', text) + logger.error('Ошибка парсинга Heleket JSON', text=text) return None if response.status >= 400: - logger.error('Heleket API %s вернул статус %s: %s', endpoint, response.status, data) + logger.error( + 'Heleket API вернул статус', endpoint=endpoint, response_status=response.status, data=data + ) return None if isinstance(data, dict) and data.get('state') == 0: return data - logger.error('Heleket API вернул ошибку: %s', data) + logger.error('Heleket API вернул ошибку', data=data) return None except Exception as error: # pragma: no cover - defensive - logger.error('Ошибка запроса к Heleket API: %s', error) + logger.error('Ошибка запроса к Heleket API', error=error) return None async def create_payment(self, payload: dict[str, Any]) -> dict[str, Any] | None: @@ -157,7 +159,7 @@ class HeleketService: return True if not isinstance(payload, dict): - logger.error('Heleket webhook payload не dict: %s', payload) + logger.error('Heleket webhook payload не dict', payload=payload) return False signature = payload.get('sign') @@ -173,5 +175,7 @@ class HeleketService: is_valid = hmac.compare_digest(expected, str(signature)) if not is_valid: - logger.error('Неверная подпись Heleket webhook: ожидается %s, получено %s', expected, signature) + logger.error( + 'Неверная подпись Heleket webhook: ожидается , получено', expected=expected, signature=signature + ) return is_valid diff --git a/app/external/heleket_webhook.py b/app/external/heleket_webhook.py index 7bea2ad6..05f267e7 100644 --- a/app/external/heleket_webhook.py +++ b/app/external/heleket_webhook.py @@ -1,8 +1,8 @@ import asyncio import json -import logging from typing import Any +import structlog from aiohttp import web from app.config import settings @@ -11,7 +11,7 @@ from app.external.heleket import HeleketService from app.services.payment_service import PaymentService -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class HeleketWebhookHandler: @@ -39,7 +39,7 @@ class HeleketWebhookHandler: processed = await self.payment_service.process_heleket_webhook(db, payload) await db.commit() except Exception as e: - logger.error(f'Ошибка обработки Heleket webhook: {e}') + logger.error('Ошибка обработки Heleket webhook', error=e) await db.rollback() return web.json_response({'status': 'error', 'reason': 'internal_error'}, status=500) @@ -97,15 +97,15 @@ async def start_heleket_webhook_server(payment_service: PaymentService) -> None: try: await site.start() logger.info( - 'Heleket webhook сервер запущен на %s:%s', - settings.HELEKET_WEBHOOK_HOST, - settings.HELEKET_WEBHOOK_PORT, + 'Heleket webhook сервер запущен на', + HELEKET_WEBHOOK_HOST=settings.HELEKET_WEBHOOK_HOST, + HELEKET_WEBHOOK_PORT=settings.HELEKET_WEBHOOK_PORT, ) logger.info( - 'Heleket webhook URL: http://%s:%s%s', - settings.HELEKET_WEBHOOK_HOST, - settings.HELEKET_WEBHOOK_PORT, - settings.HELEKET_WEBHOOK_PATH, + 'Heleket webhook URL: http://', + HELEKET_WEBHOOK_HOST=settings.HELEKET_WEBHOOK_HOST, + HELEKET_WEBHOOK_PORT=settings.HELEKET_WEBHOOK_PORT, + HELEKET_WEBHOOK_PATH=settings.HELEKET_WEBHOOK_PATH, ) while True: diff --git a/app/external/pal24_client.py b/app/external/pal24_client.py index 999e4404..e2abe069 100644 --- a/app/external/pal24_client.py +++ b/app/external/pal24_client.py @@ -3,17 +3,17 @@ from __future__ import annotations import hashlib -import logging from dataclasses import dataclass from decimal import Decimal, InvalidOperation from typing import Any import aiohttp +import structlog from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class Pal24APIError(Exception): @@ -96,30 +96,21 @@ class Pal24Client: payload = await response.json(content_type=None) except aiohttp.ContentTypeError: text_body = await response.text() - logger.error( - 'Pal24 API returned non-JSON response for %s: %s', - endpoint, - text_body, - ) + logger.error('Pal24 API returned non-JSON response for', endpoint=endpoint, text_body=text_body) raise Pal24APIError(f'Pal24 API returned non-JSON response: {text_body}') from None result = Pal24Response.from_payload(payload, status) if status >= 400 or not result.success: - logger.error( - 'Pal24 API error %s %s: %s', - status, - endpoint, - payload, - ) + logger.error('Pal24 API error', status=status, endpoint=endpoint, payload=payload) result.raise_for_status(endpoint) return result except TimeoutError as error: - logger.error('Pal24 API request timeout for %s: %s', endpoint, error) + logger.error('Pal24 API request timeout for', endpoint=endpoint, error=error) raise Pal24APIError(f'Pal24 API request timeout for {endpoint}') from error except aiohttp.ClientError as error: - logger.error('Pal24 API client error for %s: %s', endpoint, error) + logger.error('Pal24 API client error for', endpoint=endpoint, error=error) raise Pal24APIError(str(error)) from error # API methods ----------------------------------------------------------------- diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index 087beb04..ef571e4a 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -1,7 +1,6 @@ import asyncio import base64 import json -import logging import ssl from dataclasses import dataclass from datetime import datetime @@ -10,9 +9,10 @@ from typing import Any from urllib.parse import urlparse import aiohttp +import structlog -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class UserStatus(Enum): @@ -310,7 +310,7 @@ class RemnaWaveAPI: async def __aenter__(self): conn_type = self._detect_connection_type() - logger.debug(f'Подключение к Remnawave: {self.base_url} (тип: {conn_type})') + logger.debug('Подключение к Remnawave: (тип: )', base_url=self.base_url, conn_type=conn_type) headers = self._prepare_auth_headers() @@ -319,10 +319,10 @@ class RemnaWaveAPI: if ':' in self.secret_key: key_name, key_value = self.secret_key.split(':', 1) cookies = {key_name: key_value} - logger.debug(f'Используем куки: {key_name}=***') + logger.debug('Используем куки: =***', key_name=key_name) else: cookies = {self.secret_key: self.secret_key} - logger.debug(f'Используем куки: {self.secret_key}=***') + logger.debug('Используем куки: =***', secret_key=self.secret_key) connector_kwargs = {} @@ -388,12 +388,12 @@ class RemnaWaveAPI: if response.status == 429 and attempt < max_retries: retry_after = float(response.headers.get('Retry-After', base_delay * (2**attempt))) logger.warning( - 'Rate limited (429) on %s %s, retry %d/%d after %.1fs', - method, - endpoint, - attempt + 1, - max_retries, - retry_after, + 'Rate limited (429) on , retry / after s', + method=method, + endpoint=endpoint, + attempt=attempt + 1, + max_retries=max_retries, + retry_after=retry_after, ) await asyncio.sleep(retry_after) continue @@ -411,17 +411,17 @@ class RemnaWaveAPI: if attempt < max_retries: delay = base_delay * (2**attempt) logger.warning( - 'Request failed on %s %s: %s, retry %d/%d after %.1fs', - method, - endpoint, - e, - attempt + 1, - max_retries, - delay, + 'Request failed on retry / after s', + method=method, + endpoint=endpoint, + e=e, + attempt=attempt + 1, + max_retries=max_retries, + delay=delay, ) await asyncio.sleep(delay) continue - logger.error(f'Request failed: {e}') + logger.error('Request failed', error=e) raise RemnaWaveAPIError(f'Request failed: {e!s}') raise RemnaWaveAPIError(f'Max retries exceeded for {method} {endpoint}') @@ -461,7 +461,7 @@ class RemnaWaveAPI: if active_internal_squads: data['activeInternalSquads'] = active_internal_squads - logger.debug('Создание пользователя в панели: %s', data) + logger.debug('Создание пользователя в панели', data=data) response = await self._make_request('POST', '/api/users', data) user = self._parse_user(response['response']) return await self.enrich_user_with_happ_link(user) @@ -1049,13 +1049,13 @@ class RemnaWaveAPI: delete_data = {'userUuid': user_uuid, 'hwid': device_hwid} await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data) except Exception as device_error: - logger.error(f'Ошибка удаления устройства {device_hwid}: {device_error}') + logger.error('Ошибка удаления устройства', device_hwid=device_hwid, device_error=device_error) failed_count += 1 return failed_count < len(devices) / 2 except Exception as e: - logger.error(f'Ошибка при сбросе устройств: {e}') + logger.error('Ошибка при сбросе устройств', error=e) return False async def remove_device(self, user_uuid: str, device_hwid: str) -> bool: @@ -1064,7 +1064,7 @@ class RemnaWaveAPI: await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data) return True except Exception as e: - logger.error(f'Ошибка удаления устройства {device_hwid}: {e}') + logger.error('Ошибка удаления устройства', device_hwid=device_hwid, error=e) return False async def encrypt_happ_crypto_link(self, link_to_encrypt: str) -> str | None: @@ -1073,10 +1073,10 @@ class RemnaWaveAPI: response = await self._make_request('POST', '/api/system/tools/happ/encrypt', data) return response.get('response', {}).get('encryptedLink') except RemnaWaveAPIError as e: - logger.warning(f'Не удалось зашифровать happ ссылку: {e.message}') + logger.warning('Не удалось зашифровать happ ссылку', message=e.message) return None except Exception as e: - logger.warning(f'Ошибка при шифровании happ ссылки: {e}') + logger.warning('Ошибка при шифровании happ ссылки', error=e) return None async def enrich_user_with_happ_link(self, user: RemnaWaveUser) -> RemnaWaveUser: @@ -1112,7 +1112,7 @@ class RemnaWaveAPI: try: status = UserStatus(status_str) except ValueError: - logger.warning(f'Неизвестный статус пользователя: {status_str}, используем ACTIVE') + logger.warning('Неизвестный статус пользователя: используем ACTIVE', status_str=status_str) status = UserStatus.ACTIVE # Получаем trafficLimitStrategy с fallback @@ -1120,7 +1120,7 @@ class RemnaWaveAPI: try: traffic_strategy = TrafficLimitStrategy(strategy_str) except ValueError: - logger.warning(f'Неизвестная стратегия трафика: {strategy_str}, используем NO_RESET') + logger.warning('Неизвестная стратегия трафика: используем NO_RESET', strategy_str=strategy_str) traffic_strategy = TrafficLimitStrategy.NO_RESET return RemnaWaveUser( @@ -1285,5 +1285,5 @@ async def test_api_connection(api: RemnaWaveAPI) -> bool: await api.get_system_stats() return True except Exception as e: - logger.warning('API connection test failed: %s', e) + logger.warning('API connection test failed', e=e) return False diff --git a/app/external/telegram_stars.py b/app/external/telegram_stars.py index bb1f3e49..0b4560c7 100644 --- a/app/external/telegram_stars.py +++ b/app/external/telegram_stars.py @@ -1,14 +1,14 @@ -import logging from decimal import ROUND_HALF_UP, Decimal from typing import Any +import structlog from aiogram import Bot from aiogram.types import InlineKeyboardMarkup, LabeledPrice from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class TelegramStarsService: @@ -49,13 +49,16 @@ class TelegramStarsService: ) logger.info( - f'Создан Stars invoice на {stars_amount} звезд (~{settings.format_price(amount_kopeks)}) ' - f'для {chat_id}, курс: {stars_rate}₽/⭐' + 'Создан Stars invoice на звезд (~) для курс: ₽/⭐', + stars_amount=stars_amount, + settings=settings.format_price(amount_kopeks), + chat_id=chat_id, + stars_rate=stars_rate, ) return invoice_link except Exception as e: - logger.error(f'Ошибка создания Stars invoice: {e}') + logger.error('Ошибка создания Stars invoice', error=e) return None async def send_invoice( @@ -84,8 +87,11 @@ class TelegramStarsService: ) logger.info( - f'Отправлен Stars invoice {message.message_id} на {stars_amount} звезд ' - f'(~{settings.format_price(amount_kopeks)}), курс: {stars_rate}₽/⭐' + 'Отправлен Stars invoice на звезд (~), курс: ₽/⭐', + message_id=message.message_id, + stars_amount=stars_amount, + settings=settings.format_price(amount_kopeks), + stars_rate=stars_rate, ) return { 'message_id': message.message_id, @@ -95,7 +101,7 @@ class TelegramStarsService: } except Exception as e: - logger.error(f'Ошибка отправки Stars invoice: {e}') + logger.error('Ошибка отправки Stars invoice', error=e) return None async def answer_pre_checkout_query( @@ -105,8 +111,8 @@ class TelegramStarsService: await self.bot.answer_pre_checkout_query( pre_checkout_query_id=pre_checkout_query_id, ok=ok, error_message=error_message ) - logger.info(f'Ответ на pre_checkout_query: ok={ok}') + logger.info('Ответ на pre_checkout_query: ok', ok=ok) return True except Exception as e: - logger.error(f'Ошибка ответа на pre_checkout_query: {e}') + logger.error('Ошибка ответа на pre_checkout_query', error=e) return False diff --git a/app/external/tribute.py b/app/external/tribute.py index 7fc1b948..af40608a 100644 --- a/app/external/tribute.py +++ b/app/external/tribute.py @@ -1,13 +1,14 @@ import hashlib import hmac import json -import logging from typing import Any +import structlog + from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class TributeService: @@ -25,11 +26,11 @@ class TributeService: try: payment_url = f'{self.donate_link}&user_id={user_id}' - logger.info(f'Создана ссылка Tribute для пользователя {user_id}') + logger.info('Создана ссылка Tribute для пользователя', user_id=user_id) return payment_url except Exception as e: - logger.error(f'Ошибка создания Tribute ссылки: {e}') + logger.error('Ошибка создания Tribute ссылки', error=e) return None def verify_webhook_signature(self, payload: str, signature: str) -> bool: @@ -50,7 +51,7 @@ class TributeService: return is_valid except Exception as e: - logger.error(f'Ошибка проверки подписи webhook: {e}') + logger.error('Ошибка проверки подписи webhook', error=e) return False async def process_webhook(self, payload_or_data) -> dict[str, Any] | None: @@ -60,9 +61,9 @@ class TributeService: if isinstance(payload_or_data, str): try: webhook_data = json.loads(payload_or_data) - logger.info(f'📊 Распарсенные данные: {webhook_data}') + logger.info('📊 Распарсенные данные', webhook_data=webhook_data) except json.JSONDecodeError as e: - logger.error(f'❌ Ошибка парсинга JSON: {e}') + logger.error('❌ Ошибка парсинга JSON', error=e) return None else: webhook_data = payload_or_data @@ -99,18 +100,24 @@ class TributeService: status = 'unknown' logger.info( - f'📝 Извлеченные данные: payment_id={payment_id}, status={status}, amount_kopeks={amount_kopeks}, user_id={telegram_user_id}' + '📝 Извлеченные данные: payment_id=, status=, amount_kopeks=, user_id', + payment_id=payment_id, + status=status, + amount_kopeks=amount_kopeks, + telegram_user_id=telegram_user_id, ) if not telegram_user_id: logger.error('❌ Не найден telegram_user_id в webhook данных') - logger.error(f'🔍 Полные данные для отладки: {json.dumps(webhook_data, ensure_ascii=False, indent=2)}') + logger.error( + '🔍 Полные данные для отладки', dumps=json.dumps(webhook_data, ensure_ascii=False, indent=2) + ) return None try: telegram_user_id = int(telegram_user_id) except (ValueError, TypeError): - logger.error(f'❌ Некорректный telegram_user_id: {telegram_user_id}') + logger.error('❌ Некорректный telegram_user_id', telegram_user_id=telegram_user_id) return None result = { @@ -123,28 +130,28 @@ class TributeService: 'payment_system': 'tribute', } - logger.info(f'✅ Tribute webhook обработан успешно: {result}') + logger.info('✅ Tribute webhook обработан успешно', result=result) return result except Exception as e: - logger.error(f'❌ Ошибка обработки Tribute webhook: {e}', exc_info=True) - logger.error(f'🔍 Webhook data для отладки: {json.dumps(webhook_data, ensure_ascii=False, indent=2)}') + logger.error('❌ Ошибка обработки Tribute webhook', error=e, exc_info=True) + logger.error('🔍 Webhook data для отладки', dumps=json.dumps(webhook_data, ensure_ascii=False, indent=2)) return None async def get_payment_status(self, payment_id: str) -> dict[str, Any] | None: try: - logger.info(f'Запрос статуса платежа {payment_id}') + logger.info('Запрос статуса платежа', payment_id=payment_id) return {'status': 'unknown', 'payment_id': payment_id} except Exception as e: - logger.error(f'Ошибка получения статуса платежа: {e}') + logger.error('Ошибка получения статуса платежа', error=e) return None async def refund_payment( self, payment_id: str, amount_kopeks: int | None = None, reason: str = 'Возврат по запросу' ) -> dict[str, Any] | None: try: - logger.info(f'Создание возврата для платежа {payment_id}') + logger.info('Создание возврата для платежа', payment_id=payment_id) return {'refund_id': f'refund_{payment_id}', 'status': 'pending'} except Exception as e: - logger.error(f'Ошибка создания возврата: {e}') + logger.error('Ошибка создания возврата', error=e) return None diff --git a/app/external/wata_webhook.py b/app/external/wata_webhook.py index eea4f22e..dbcfbfe5 100644 --- a/app/external/wata_webhook.py +++ b/app/external/wata_webhook.py @@ -3,11 +3,11 @@ from __future__ import annotations import asyncio import base64 import json -import logging from datetime import datetime, timedelta from typing import Any import aiohttp +import structlog from aiohttp import web from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import hashes, serialization @@ -18,7 +18,7 @@ from app.database.database import AsyncSessionLocal from app.services.payment_service import PaymentService -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class WataPublicKeyProvider: @@ -67,28 +67,24 @@ class WataPublicKeyProvider: async with aiohttp.ClientSession(timeout=timeout) as session, session.get(url) as response: text = await response.text() if response.status >= 400: - logger.error( - 'Ошибка получения публичного ключа WATA %s: %s', - response.status, - text, - ) + logger.error('Ошибка получения публичного ключа WATA', response_status=response.status, text=text) return None try: payload = await response.json() except aiohttp.ContentTypeError: - logger.error('Ответ WATA public-key не является JSON: %s', text) + logger.error('Ответ WATA public-key не является JSON', text=text) return None if isinstance(payload, dict): value = payload.get('value') if value: return value - logger.error('Ответ WATA public-key не содержит ключ: %s', payload) + logger.error('Ответ WATA public-key не содержит ключ', payload=payload) else: - logger.error('Неожиданный формат ответа WATA public-key: %s', payload) + logger.error('Неожиданный формат ответа WATA public-key', payload=payload) except Exception as error: - logger.error('Ошибка запроса публичного ключа WATA: %s', error) + logger.error('Ошибка запроса публичного ключа WATA', error=error) return None @@ -125,7 +121,7 @@ class WataWebhookHandler: try: public_key = serialization.load_pem_public_key(public_key_pem.encode('utf-8')) except ValueError as error: - logger.error('Ошибка загрузки публичного ключа WATA: %s', error) + logger.error('Ошибка загрузки публичного ключа WATA', error=error) return False try: @@ -140,7 +136,7 @@ class WataWebhookHandler: logger.warning('Подпись WATA webhook не прошла проверку') return False except Exception as error: - logger.error('Ошибка проверки подписи WATA: %s', error) + logger.error('Ошибка проверки подписи WATA', error=error) return False async def handle_webhook(self, request: web.Request) -> web.Response: @@ -164,9 +160,9 @@ class WataWebhookHandler: return web.json_response({'status': 'error', 'reason': 'invalid_json'}, status=400) logger.info( - 'Получен WATA webhook: order_id=%s, status=%s', - payload.get('orderId'), - payload.get('transactionStatus'), + 'Получен WATA webhook: order_id status', + payload=payload.get('orderId'), + payload_2=payload.get('transactionStatus'), ) processed: bool | None = None @@ -175,7 +171,7 @@ class WataWebhookHandler: processed = await self.payment_service.process_wata_webhook(db, payload) await db.commit() except Exception as e: - logger.error(f'Ошибка обработки WATA webhook: {e}') + logger.error('Ошибка обработки WATA webhook', error=e) await db.rollback() return web.json_response({'status': 'error', 'reason': 'internal_error'}, status=500) @@ -218,10 +214,7 @@ def create_wata_webhook_app(payment_service: PaymentService) -> web.Application: app.router.add_options(settings.WATA_WEBHOOK_PATH, handler.options_handler) app.router.add_get('/health', handler.health_check) - logger.info( - 'Настроен WATA webhook endpoint на %s', - settings.WATA_WEBHOOK_PATH, - ) + logger.info('Настроен WATA webhook endpoint на', WATA_WEBHOOK_PATH=settings.WATA_WEBHOOK_PATH) return app @@ -244,15 +237,15 @@ async def start_wata_webhook_server(payment_service: PaymentService) -> None: try: await site.start() logger.info( - 'WATA webhook сервер запущен на %s:%s', - settings.WATA_WEBHOOK_HOST, - settings.WATA_WEBHOOK_PORT, + 'WATA webhook сервер запущен на', + WATA_WEBHOOK_HOST=settings.WATA_WEBHOOK_HOST, + WATA_WEBHOOK_PORT=settings.WATA_WEBHOOK_PORT, ) logger.info( - 'WATA webhook URL: http://%s:%s%s', - settings.WATA_WEBHOOK_HOST, - settings.WATA_WEBHOOK_PORT, - settings.WATA_WEBHOOK_PATH, + 'WATA webhook URL: http://', + WATA_WEBHOOK_HOST=settings.WATA_WEBHOOK_HOST, + WATA_WEBHOOK_PORT=settings.WATA_WEBHOOK_PORT, + WATA_WEBHOOK_PATH=settings.WATA_WEBHOOK_PATH, ) while True: diff --git a/app/external/webhook_server.py b/app/external/webhook_server.py index 483327f5..346eaef8 100644 --- a/app/external/webhook_server.py +++ b/app/external/webhook_server.py @@ -2,9 +2,9 @@ import base64 import hashlib import hmac import json -import logging from collections.abc import Iterable +import structlog from aiogram import Bot from aiohttp import web @@ -14,7 +14,7 @@ from app.services.payment_service import PaymentService from app.services.tribute_service import TributeService -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class WebhookServer: @@ -51,8 +51,8 @@ class WebhookServer: missing.append('FREEKASSA_SECRET_WORD_2') if missing: logger.warning( - f'Freekassa ENABLED=true, но webhook не зарегистрирован. ' - f'Отсутствуют параметры: {", ".join(missing)}' + 'Freekassa ENABLED=true, но webhook не зарегистрирован. Отсутствуют параметры', + value=', '.join(missing), ) self.app.router.add_get('/health', self._health_check) @@ -66,18 +66,16 @@ class WebhookServer: self.app.router.add_options(settings.FREEKASSA_WEBHOOK_PATH, self._options_handler) logger.info('Webhook сервер настроен:') - logger.info(f' - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}') + logger.info('Tribute webhook: POST', TRIBUTE_WEBHOOK_PATH=settings.TRIBUTE_WEBHOOK_PATH) if settings.is_mulenpay_enabled(): mulenpay_name = settings.get_mulenpay_display_name() logger.info( - ' - %s webhook: POST %s', - mulenpay_name, - settings.MULENPAY_WEBHOOK_PATH, + '- webhook: POST', mulenpay_name=mulenpay_name, MULENPAY_WEBHOOK_PATH=settings.MULENPAY_WEBHOOK_PATH ) if settings.is_cryptobot_enabled(): - logger.info(f' - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}') + logger.info('CryptoBot webhook: POST', CRYPTOBOT_WEBHOOK_PATH=settings.CRYPTOBOT_WEBHOOK_PATH) if settings.is_freekassa_enabled(): - logger.info(f' - Freekassa webhook: POST {settings.FREEKASSA_WEBHOOK_PATH}') + logger.info('Freekassa webhook: POST', FREEKASSA_WEBHOOK_PATH=settings.FREEKASSA_WEBHOOK_PATH) logger.info(' - Health check: GET /health') return self.app @@ -95,35 +93,35 @@ class WebhookServer: await self.site.start() logger.info( - 'Webhook сервер запущен на %s:%s', - settings.TRIBUTE_WEBHOOK_HOST, - settings.TRIBUTE_WEBHOOK_PORT, + 'Webhook сервер запущен на', + TRIBUTE_WEBHOOK_HOST=settings.TRIBUTE_WEBHOOK_HOST, + TRIBUTE_WEBHOOK_PORT=settings.TRIBUTE_WEBHOOK_PORT, ) logger.info( - 'Tribute webhook URL: http://%s:%s%s', - settings.TRIBUTE_WEBHOOK_HOST, - settings.TRIBUTE_WEBHOOK_PORT, - settings.TRIBUTE_WEBHOOK_PATH, + 'Tribute webhook URL: http://', + TRIBUTE_WEBHOOK_HOST=settings.TRIBUTE_WEBHOOK_HOST, + TRIBUTE_WEBHOOK_PORT=settings.TRIBUTE_WEBHOOK_PORT, + TRIBUTE_WEBHOOK_PATH=settings.TRIBUTE_WEBHOOK_PATH, ) if settings.is_mulenpay_enabled(): mulenpay_name = settings.get_mulenpay_display_name() logger.info( - '%s webhook URL: http://%s:%s%s', - mulenpay_name, - settings.TRIBUTE_WEBHOOK_HOST, - settings.TRIBUTE_WEBHOOK_PORT, - settings.MULENPAY_WEBHOOK_PATH, + 'webhook URL: http://', + mulenpay_name=mulenpay_name, + TRIBUTE_WEBHOOK_HOST=settings.TRIBUTE_WEBHOOK_HOST, + TRIBUTE_WEBHOOK_PORT=settings.TRIBUTE_WEBHOOK_PORT, + MULENPAY_WEBHOOK_PATH=settings.MULENPAY_WEBHOOK_PATH, ) if settings.is_cryptobot_enabled(): logger.info( - 'CryptoBot webhook URL: http://%s:%s%s', - settings.TRIBUTE_WEBHOOK_HOST, - settings.TRIBUTE_WEBHOOK_PORT, - settings.CRYPTOBOT_WEBHOOK_PATH, + 'CryptoBot webhook URL: http://', + TRIBUTE_WEBHOOK_HOST=settings.TRIBUTE_WEBHOOK_HOST, + TRIBUTE_WEBHOOK_PORT=settings.TRIBUTE_WEBHOOK_PORT, + CRYPTOBOT_WEBHOOK_PATH=settings.CRYPTOBOT_WEBHOOK_PATH, ) except Exception as e: - logger.error(f'Ошибка запуска webhook сервера: {e}') + logger.error('Ошибка запуска webhook сервера', error=e) raise async def stop(self): @@ -137,7 +135,7 @@ class WebhookServer: logger.info('Webhook runner очищен') except Exception as e: - logger.error(f'Ошибка остановки webhook сервера: {e}') + logger.error('Ошибка остановки webhook сервера', error=e) async def _options_handler(self, request: web.Request) -> web.Response: return web.Response( @@ -152,36 +150,27 @@ class WebhookServer: async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response: try: mulenpay_name = settings.get_mulenpay_display_name() - logger.info( - '%s webhook: %s %s', - mulenpay_name, - request.method, - request.path, - ) - logger.info( - '%s webhook headers: %s', - mulenpay_name, - dict(request.headers), - ) + logger.info('webhook', mulenpay_name=mulenpay_name, method=request.method, request_path=request.path) + logger.info('webhook headers', mulenpay_name=mulenpay_name, headers=dict(request.headers)) raw_body = await request.read() if not raw_body: - logger.warning('Пустой %s webhook', mulenpay_name) + logger.warning('Пустой webhook', mulenpay_name=mulenpay_name) return web.json_response({'status': 'error', 'reason': 'empty_body'}, status=400) # Временно отключаем проверку подписи для отладки # TODO: Включить обратно после настройки MulenPay if not self._verify_mulenpay_signature(request, raw_body): logger.warning( - '%s webhook signature verification failed, but processing anyway for debugging', - mulenpay_name, + 'webhook signature verification failed, but processing anyway for debugging', + mulenpay_name=mulenpay_name, ) # return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401) try: payload = json.loads(raw_body.decode('utf-8')) except json.JSONDecodeError as error: - logger.error(f'Ошибка парсинга {mulenpay_name} webhook: {error}') + logger.error('Ошибка парсинга webhook', mulenpay_name=mulenpay_name, error=error) return web.json_response({'status': 'error', 'reason': 'invalid_json'}, status=400) payment_service = PaymentService(self.bot) @@ -196,10 +185,7 @@ class WebhookServer: return web.json_response({'status': 'ok'}, status=200) return web.json_response({'status': 'error', 'reason': 'processing_failed'}, status=400) except Exception as error: - logger.error( - f'Ошибка обработки {mulenpay_name} webhook: {error}', - exc_info=True, - ) + logger.error('Ошибка обработки webhook', mulenpay_name=mulenpay_name, error=error, exc_info=True) return web.json_response({'status': 'error', 'reason': 'internal_error'}, status=500) finally: try: @@ -209,10 +195,7 @@ class WebhookServer: except Exception as error: mulenpay_name = settings.get_mulenpay_display_name() - logger.error( - f'Критическая ошибка {mulenpay_name} webhook: {error}', - exc_info=True, - ) + logger.error('Критическая ошибка webhook', mulenpay_name=mulenpay_name, error=error, exc_info=True) return web.json_response({'status': 'error', 'reason': 'internal_error', 'message': str(error)}, status=500) @staticmethod @@ -228,14 +211,14 @@ class WebhookServer: secret_key = settings.MULENPAY_SECRET_KEY display_name = settings.get_mulenpay_display_name() if not secret_key: - logger.error('%s secret key is not configured', display_name) + logger.error('secret key is not configured', display_name=display_name) return False # Логируем все заголовки для отладки - logger.info('%s webhook headers for signature verification:', display_name) + logger.info('webhook headers for signature verification', display_name=display_name) for header_name, header_value in request.headers.items(): if any(keyword in header_name.lower() for keyword in ['signature', 'sign', 'token', 'auth']): - logger.info(f' {header_name}: {header_value}') + logger.info('log event', header_name=header_name, header_value=header_value) signature = WebhookServer._extract_mulenpay_header( request, @@ -283,7 +266,7 @@ class WebhookServer: if hmac.compare_digest(normalized_signature_no_padding, expected_urlsafe_base64_signature.rstrip('=')): return True - logger.error('Неверная подпись %s webhook', display_name) + logger.error('Неверная подпись webhook', display_name=display_name) return False authorization_header = request.headers.get('Authorization') @@ -296,7 +279,7 @@ class WebhookServer: if hmac.compare_digest(token, secret_key): return True - logger.error('Неверный %s токен %s webhook', scheme, display_name) + logger.error('Неверный токен webhook', scheme=scheme, display_name=display_name) return False if not value and hmac.compare_digest(token, secret_key): @@ -319,13 +302,13 @@ class WebhookServer: {key: value for key, value in request.headers.items() if 'authorization' not in key.lower()}, ) - logger.error('Отсутствует подпись %s webhook', display_name) + logger.error('Отсутствует подпись webhook', display_name=display_name) return False async def _tribute_webhook_handler(self, request: web.Request) -> web.Response: try: - logger.info(f'Получен Tribute webhook: {request.method} {request.path}') - logger.info(f'Headers: {dict(request.headers)}') + logger.info('Получен Tribute webhook', method=request.method, path=request.path) + logger.info('Headers', value=dict(request.headers)) raw_body = await request.read() @@ -334,17 +317,17 @@ class WebhookServer: return web.json_response({'status': 'error', 'reason': 'empty_body'}, status=400) payload = raw_body.decode('utf-8') - logger.info(f'Payload: {payload}') + logger.info('Payload', payload=payload) try: webhook_data = json.loads(payload) - logger.info(f'Распарсенные данные: {webhook_data}') + logger.info('Распарсенные данные', webhook_data=webhook_data) except json.JSONDecodeError as e: - logger.error(f'Ошибка парсинга JSON: {e}') + logger.error('Ошибка парсинга JSON', error=e) return web.json_response({'status': 'error', 'reason': 'invalid_json'}, status=400) signature = request.headers.get('trbt-signature') - logger.info(f'Signature: {signature}') + logger.info('Signature', signature=signature) if not signature: logger.error('Отсутствует заголовок подписи Tribute webhook') @@ -361,19 +344,19 @@ class WebhookServer: result = await self.tribute_service.process_webhook(payload) if result: - logger.info(f'Tribute webhook обработан успешно: {result}') + logger.info('Tribute webhook обработан успешно', result=result) return web.json_response({'status': 'ok', 'result': result}, status=200) logger.error('Ошибка обработки Tribute webhook') return web.json_response({'status': 'error', 'reason': 'processing_failed'}, status=400) except Exception as e: - logger.error(f'Критическая ошибка обработки Tribute webhook: {e}', exc_info=True) + logger.error('Критическая ошибка обработки Tribute webhook', error=e, exc_info=True) return web.json_response({'status': 'error', 'reason': 'internal_error', 'message': str(e)}, status=500) async def _cryptobot_webhook_handler(self, request: web.Request) -> web.Response: try: - logger.info(f'Получен CryptoBot webhook: {request.method} {request.path}') - logger.info(f'Headers: {dict(request.headers)}') + logger.info('Получен CryptoBot webhook', method=request.method, path=request.path) + logger.info('Headers', value=dict(request.headers)) raw_body = await request.read() @@ -382,17 +365,17 @@ class WebhookServer: return web.json_response({'status': 'error', 'reason': 'empty_body'}, status=400) payload = raw_body.decode('utf-8') - logger.info(f'CryptoBot Payload: {payload}') + logger.info('CryptoBot Payload', payload=payload) try: webhook_data = json.loads(payload) - logger.info(f'CryptoBot данные: {webhook_data}') + logger.info('CryptoBot данные', webhook_data=webhook_data) except json.JSONDecodeError as e: - logger.error(f'Ошибка парсинга CryptoBot JSON: {e}') + logger.error('Ошибка парсинга CryptoBot JSON', error=e) return web.json_response({'status': 'error', 'reason': 'invalid_json'}, status=400) signature = request.headers.get('Crypto-Pay-API-Signature') - logger.info(f'CryptoBot Signature: {signature}') + logger.info('CryptoBot Signature', signature=signature) if signature and settings.CRYPTOBOT_WEBHOOK_SECRET: from app.external.cryptobot import CryptoBotService @@ -417,7 +400,7 @@ class WebhookServer: return web.json_response({'status': 'error', 'reason': 'processing_failed'}, status=400) except Exception as e: - logger.error(f'Критическая ошибка обработки CryptoBot webhook: {e}', exc_info=True) + logger.error('Критическая ошибка обработки CryptoBot webhook', error=e, exc_info=True) return web.json_response({'status': 'error', 'reason': 'internal_error', 'message': str(e)}, status=500) async def _health_check(self, request: web.Request) -> web.Response: @@ -448,22 +431,22 @@ class WebhookServer: - CUR_ID: ID валюты/платежной системы """ try: - logger.info(f'Получен Freekassa webhook: {request.method} {request.path}') + logger.info('Получен Freekassa webhook', method=request.method, path=request.path) # Получаем IP клиента client_ip = request.headers.get('X-Forwarded-For', '').split(',')[0].strip() if not client_ip: client_ip = request.remote or 'unknown' - logger.info(f'Freekassa webhook IP: {client_ip}') + logger.info('Freekassa webhook IP', client_ip=client_ip) # Freekassa отправляет form-data try: form_data = await request.post() except Exception as e: - logger.error(f'Ошибка парсинга Freekassa form-data: {e}') + logger.error('Ошибка парсинга Freekassa form-data', error=e) return web.Response(text='NO', status=400) - logger.info(f'Freekassa webhook data: {dict(form_data)}') + logger.info('Freekassa webhook data', value=dict(form_data)) # Извлекаем параметры merchant_id = int(form_data.get('MERCHANT_ID', 0)) @@ -496,12 +479,12 @@ class WebhookServer: ) if success: - logger.info(f'Freekassa webhook обработан успешно: order_id={order_id}') + logger.info('Freekassa webhook обработан успешно: order_id', order_id=order_id) # Freekassa ожидает YES в ответе return web.Response(text='YES', status=200) - logger.error(f'Ошибка обработки Freekassa webhook: order_id={order_id}') + logger.error('Ошибка обработки Freekassa webhook: order_id', order_id=order_id) return web.Response(text='NO', status=400) except Exception as e: - logger.error(f'Критическая ошибка обработки Freekassa webhook: {e}', exc_info=True) + logger.error('Критическая ошибка обработки Freekassa webhook', error=e, exc_info=True) return web.Response(text='NO', status=500) diff --git a/app/external/yookassa_webhook.py b/app/external/yookassa_webhook.py index df2b8e8c..4d75be4a 100644 --- a/app/external/yookassa_webhook.py +++ b/app/external/yookassa_webhook.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio import json -import logging from collections.abc import Iterable from ipaddress import ( IPv4Address, @@ -14,6 +13,7 @@ from ipaddress import ( ) from typing import TYPE_CHECKING, Union +import structlog from aiohttp import web from app.config import settings @@ -23,7 +23,7 @@ from app.database.database import AsyncSessionLocal if TYPE_CHECKING: from app.services.payment_service import PaymentService -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) IPAddress = Union[IPv4Address, IPv6Address] @@ -140,7 +140,7 @@ def _get_trusted_proxy_networks() -> tuple[IPNetwork, ...]: try: networks.append(ip_network(candidate, strict=False)) except ValueError: - logger.warning('Неверная сеть доверенного прокси YooKassa: %s', candidate) + logger.warning('Неверная сеть доверенного прокси YooKassa', candidate=candidate) cached_networks = tuple(networks) _TRUSTED_PROXY_NETWORKS_CACHE = (raw_value, cached_networks) @@ -206,8 +206,8 @@ class YooKassaWebhookHandler: async def handle_webhook(self, request: web.Request) -> web.Response: try: - logger.info(f'📥 Получен YooKassa webhook: {request.method} {request.path}') - logger.info(f'📋 Headers: {dict(request.headers)}') + logger.info('📥 Получен YooKassa webhook', method=request.method, path=request.path) + logger.info('📋 Headers', value=dict(request.headers)) header_ip_candidates = collect_yookassa_ip_candidates( request.headers.get('X-Forwarded-For'), @@ -221,8 +221,8 @@ class YooKassaWebhookHandler: if client_ip is None: logger.warning( - '🚫 Не удалось определить IP-адрес отправителя YooKassa webhook. Кандидаты: %s', - header_ip_candidates + ([request.remote] if request.remote else []), + '🚫 Не удалось определить IP-адрес отправителя YooKassa webhook. Кандидаты', + header_ip_candidates=header_ip_candidates + ([request.remote] if request.remote else []), ) return web.Response(status=403, text='Forbidden') @@ -234,7 +234,7 @@ class YooKassaWebhookHandler: ) return web.Response(status=403, text='Forbidden') - logger.info('🌐 IP-адрес YooKassa подтверждён: %s', client_ip) + logger.info('🌐 IP-адрес YooKassa подтверждён', client_ip=client_ip) body = await request.text() @@ -242,20 +242,20 @@ class YooKassaWebhookHandler: logger.warning('⚠️ Получен пустой webhook от YooKassa') return web.Response(status=400, text='Empty body') - logger.info(f'📄 Body: {body}') + logger.info('📄 Body', body=body) signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature') if signature: - logger.info('ℹ️ Получена подпись YooKassa: %s', signature) + logger.info('ℹ️ Получена подпись YooKassa', signature=signature) try: webhook_data = json.loads(body) except json.JSONDecodeError as e: - logger.error(f'❌ Ошибка парсинга JSON webhook YooKassa: {e}') + logger.error('❌ Ошибка парсинга JSON webhook YooKassa', error=e) return web.Response(status=400, text='Invalid JSON') - logger.info(f'📊 Обработка webhook YooKassa: {webhook_data.get("event", "unknown_event")}') - logger.debug(f'🔍 Полные данные webhook: {webhook_data}') + logger.info('📊 Обработка webhook YooKassa', get=webhook_data.get('event', 'unknown_event')) + logger.debug('🔍 Полные данные webhook', webhook_data=webhook_data) event_type = webhook_data.get('event') if not event_type: @@ -269,7 +269,7 @@ class YooKassaWebhookHandler: return web.Response(status=400, text='No payment id') if event_type not in YOOKASSA_ALLOWED_EVENTS: - logger.info(f'ℹ️ Игнорируем событие YooKassa: {event_type}') + logger.info('ℹ️ Игнорируем событие YooKassa', event_type=event_type) return web.Response(status=200, text='OK') async with AsyncSessionLocal() as db: @@ -286,7 +286,8 @@ class YooKassaWebhookHandler: if existing_transaction and event_type == 'payment.succeeded': logger.info( - f'ℹ️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.' + 'ℹ️ Платеж YooKassa уже был обработан. Пропускаем дублирующий вебхук.', + yookassa_payment_id=yookassa_payment_id, ) return web.Response(status=200, text='OK') @@ -295,22 +296,26 @@ class YooKassaWebhookHandler: if success: await db.commit() logger.info( - f'✅ Успешно обработан webhook YooKassa: {event_type} для платежа {yookassa_payment_id}' + '✅ Успешно обработан webhook YooKassa: для платежа', + event_type=event_type, + yookassa_payment_id=yookassa_payment_id, ) return web.Response(status=200, text='OK') await db.rollback() logger.error( - f'❌ Ошибка обработки webhook YooKassa: {event_type} для платежа {yookassa_payment_id}' + '❌ Ошибка обработки webhook YooKassa: для платежа', + event_type=event_type, + yookassa_payment_id=yookassa_payment_id, ) return web.Response(status=500, text='Processing error') except Exception as e: await db.rollback() - logger.error(f'❌ Ошибка обработки webhook YooKassa: {e}', exc_info=True) + logger.error('❌ Ошибка обработки webhook YooKassa', error=e, exc_info=True) return web.Response(status=500, text='Processing error') except Exception as e: - logger.error(f'❌ Критическая ошибка обработки webhook YooKassa: {e}', exc_info=True) + logger.error('❌ Критическая ошибка обработки webhook YooKassa', error=e, exc_info=True) return web.Response(status=500, text='Internal server error') def setup_routes(self, app: web.Application) -> None: @@ -319,7 +324,7 @@ class YooKassaWebhookHandler: app.router.add_get(webhook_path, self._get_handler) app.router.add_options(webhook_path, self._options_handler) - logger.info(f'✅ Настроен YooKassa webhook на пути: POST {webhook_path}') + logger.info('✅ Настроен YooKassa webhook на пути: POST', webhook_path=webhook_path) async def _get_handler(self, request: web.Request) -> web.Response: return web.json_response( @@ -381,15 +386,15 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None await site.start() logger.info( - '✅ YooKassa webhook сервер запущен на %s:%s', - settings.YOOKASSA_WEBHOOK_HOST, - settings.YOOKASSA_WEBHOOK_PORT, + '✅ YooKassa webhook сервер запущен на', + YOOKASSA_WEBHOOK_HOST=settings.YOOKASSA_WEBHOOK_HOST, + YOOKASSA_WEBHOOK_PORT=settings.YOOKASSA_WEBHOOK_PORT, ) logger.info( - '🎯 YooKassa webhook URL: http://%s:%s%s', - settings.YOOKASSA_WEBHOOK_HOST, - settings.YOOKASSA_WEBHOOK_PORT, - settings.YOOKASSA_WEBHOOK_PATH, + '🎯 YooKassa webhook URL: http://', + YOOKASSA_WEBHOOK_HOST=settings.YOOKASSA_WEBHOOK_HOST, + YOOKASSA_WEBHOOK_PORT=settings.YOOKASSA_WEBHOOK_PORT, + YOOKASSA_WEBHOOK_PATH=settings.YOOKASSA_WEBHOOK_PATH, ) try: @@ -403,5 +408,5 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None logger.info('✅ YooKassa webhook сервер остановлен') except Exception as e: - logger.error(f'❌ Ошибка запуска YooKassa webhook сервера: {e}', exc_info=True) + logger.error('❌ Ошибка запуска YooKassa webhook сервера', error=e, exc_info=True) raise diff --git a/app/handlers/admin/backup.py b/app/handlers/admin/backup.py index 7953486d..bac029ae 100644 --- a/app/handlers/admin/backup.py +++ b/app/handlers/admin/backup.py @@ -1,6 +1,6 @@ -import logging from datetime import datetime +import structlog from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup @@ -12,7 +12,7 @@ from app.services.backup_service import backup_service from app.utils.decorators import admin_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class BackupStates(StatesGroup): @@ -446,7 +446,7 @@ async def handle_backup_file_upload(message: types.Message, db_user: User, db: A await state.clear() except Exception as e: - logger.error(f'Ошибка загрузки файла бекапа: {e}') + logger.error('Ошибка загрузки файла бекапа', error=e) await message.answer( f'❌ Ошибка загрузки файла: {e!s}', reply_markup=InlineKeyboardMarkup( diff --git a/app/handlers/admin/blacklist.py b/app/handlers/admin/blacklist.py index 13121e15..f3724f43 100644 --- a/app/handlers/admin/blacklist.py +++ b/app/handlers/admin/blacklist.py @@ -2,8 +2,7 @@ Обработчики админ-панели для управления черным списком """ -import logging - +import structlog from aiogram import types from aiogram.filters import StateFilter from aiogram.fsm.context import FSMContext @@ -14,7 +13,7 @@ from app.states import BlacklistStates from app.utils.decorators import admin_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @admin_required @@ -23,7 +22,7 @@ async def show_blacklist_settings(callback: types.CallbackQuery, db_user: User, """ Показывает настройки черного списка """ - logger.info(f'Вызван обработчик show_blacklist_settings для пользователя {callback.from_user.id}') + logger.info('Вызван обработчик show_blacklist_settings для пользователя', from_user_id=callback.from_user.id) is_enabled = blacklist_service.is_blacklist_check_enabled() github_url = blacklist_service.get_blacklist_github_url() diff --git a/app/handlers/admin/blocked_users.py b/app/handlers/admin/blocked_users.py index fa77457e..5407c7f6 100644 --- a/app/handlers/admin/blocked_users.py +++ b/app/handlers/admin/blocked_users.py @@ -5,11 +5,11 @@ и выполнять очистку БД и панели Remnawave. """ -import logging from datetime import UTC, datetime from enum import Enum from typing import Any +import structlog from aiogram import Bot, Dispatcher, F, types from aiogram.enums import ParseMode from aiogram.fsm.context import FSMContext @@ -26,7 +26,7 @@ from app.services.blocked_users_service import ( from app.utils.decorators import admin_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # ============================================================================= @@ -644,9 +644,11 @@ async def handle_confirm_action( ) logger.info( - f'Очистка заблокированных пользователей завершена: ' - f'DB={result.deleted_from_db}, RW={result.deleted_from_remnawave}, ' - f'marked={result.marked_as_blocked}, errors={len(result.errors)}' + 'Очистка заблокированных пользователей завершена: DB=, RW=, marked=, errors', + deleted_from_db=result.deleted_from_db, + deleted_from_remnawave=result.deleted_from_remnawave, + marked_as_blocked=result.marked_as_blocked, + errors_count=len(result.errors), ) await callback.answer() diff --git a/app/handlers/admin/bot_configuration.py b/app/handlers/admin/bot_configuration.py index d4dad256..454cc6ae 100644 --- a/app/handlers/admin/bot_configuration.py +++ b/app/handlers/admin/bot_configuration.py @@ -1,6 +1,5 @@ import html import io -import logging import math import time from collections.abc import Iterable @@ -693,17 +692,12 @@ async def apply_preset( await bot_configuration_service.set_value(db, setting_key, value) applied.append(setting_key) except ReadOnlySettingError: - logging.getLogger(__name__).info( - 'Пропускаем настройку %s из пресета %s: только для чтения', - setting_key, - preset_key, + logger.info( + 'Пропускаем настройку из пресета : только для чтения', setting_key=setting_key, preset_key=preset_key ) except Exception as error: - logging.getLogger(__name__).warning( - 'Не удалось применить пресет %s для %s: %s', - preset_key, - setting_key, - error, + logger.warning( + 'Не удалось применить пресет для', preset_key=preset_key, setting_key=setting_key, error=error ) await db.commit() diff --git a/app/handlers/admin/bulk_ban.py b/app/handlers/admin/bulk_ban.py index cb83c7bd..37b38245 100644 --- a/app/handlers/admin/bulk_ban.py +++ b/app/handlers/admin/bulk_ban.py @@ -2,8 +2,7 @@ Обработчики команд для массовой блокировки пользователей """ -import logging - +import structlog from aiogram import types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -14,7 +13,7 @@ from app.states import AdminStates from app.utils.decorators import admin_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @admin_required @@ -77,7 +76,7 @@ async def process_bulk_ban_list(message: types.Message, db_user: User, state: FS try: telegram_ids = await bulk_ban_service.parse_telegram_ids_from_text(input_text) except Exception as e: - logger.error(f'Ошибка парсинга Telegram ID: {e}') + logger.error('Ошибка парсинга Telegram ID', error=e) await message.answer( '❌ Ошибка при обработке списка ID. Проверьте формат ввода.', reply_markup=types.InlineKeyboardMarkup( @@ -143,7 +142,7 @@ async def process_bulk_ban_list(message: types.Message, db_user: User, state: FS ) except Exception as e: - logger.error(f'Ошибка при выполнении массовой блокировки: {e}') + logger.error('Ошибка при выполнении массовой блокировки', error=e) await message.answer( '❌ Произошла ошибка при выполнении массовой блокировки', reply_markup=types.InlineKeyboardMarkup( diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py index 701c87b3..07ab7090 100644 --- a/app/handlers/admin/campaigns.py +++ b/app/handlers/admin/campaigns.py @@ -1,6 +1,6 @@ -import logging import re +import structlog from aiogram import Bot, Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -33,7 +33,7 @@ from app.states import AdminStates from app.utils.decorators import admin_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) _CAMPAIGN_PARAM_REGEX = re.compile(r'^[A-Za-z0-9_-]{3,32}$') _CAMPAIGNS_PAGE_SIZE = 5 @@ -1120,7 +1120,7 @@ async def toggle_campaign_status( new_status = not campaign.is_active await update_campaign(db, campaign, is_active=new_status) status_text = 'включена' if new_status else 'выключена' - logger.info('🔄 Кампания %s переключена: %s', campaign_id, status_text) + logger.info('🔄 Кампания переключена', campaign_id=campaign_id, status_text=status_text) await show_campaign_detail(callback, db_user, db) diff --git a/app/handlers/admin/contests.py b/app/handlers/admin/contests.py index f2e0af1a..0136fab6 100644 --- a/app/handlers/admin/contests.py +++ b/app/handlers/admin/contests.py @@ -1,8 +1,8 @@ -import logging import math from datetime import UTC, datetime, time from zoneinfo import ZoneInfo +import structlog from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -35,7 +35,7 @@ from app.states import AdminStates from app.utils.decorators import admin_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) PAGE_SIZE = 5 @@ -44,7 +44,7 @@ def _ensure_timezone(tz_name: str) -> ZoneInfo: try: return ZoneInfo(tz_name) except Exception: - logger.warning('Не удалось загрузить TZ %s, используем UTC', tz_name) + logger.warning('Не удалось загрузить TZ , используем UTC', tz_name=tz_name) return ZoneInfo('UTC') diff --git a/app/handlers/admin/daily_contests.py b/app/handlers/admin/daily_contests.py index 7fc6c013..69032479 100644 --- a/app/handlers/admin/daily_contests.py +++ b/app/handlers/admin/daily_contests.py @@ -1,7 +1,7 @@ import json -import logging from datetime import datetime, timedelta +import structlog from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -23,7 +23,7 @@ from app.states import AdminStates from app.utils.decorators import admin_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) EDITABLE_FIELDS: dict[str, dict] = { 'prize_type': {'type': str, 'label': 'тип приза (days/balance/custom)'}, diff --git a/app/handlers/admin/faq.py b/app/handlers/admin/faq.py index 884ebe27..5645469a 100644 --- a/app/handlers/admin/faq.py +++ b/app/handlers/admin/faq.py @@ -1,7 +1,7 @@ import html -import logging from datetime import datetime +import structlog from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -14,7 +14,7 @@ from app.utils.decorators import admin_required, error_handler from app.utils.validators import get_html_help_text, validate_html_tags -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _format_timestamp(value: datetime | None) -> str: @@ -389,11 +389,7 @@ async def process_new_faq_content( content=content, ) - logger.info( - 'Админ %s создал страницу FAQ (%d символов)', - db_user.telegram_id, - len(content), - ) + logger.info('Админ создал страницу FAQ (символов)', telegram_id=db_user.telegram_id, content_count=len(content)) await state.clear() diff --git a/app/handlers/admin/main.py b/app/handlers/admin/main.py index 218602d0..1e2e9405 100644 --- a/app/handlers/admin/main.py +++ b/app/handlers/admin/main.py @@ -1,5 +1,4 @@ -import logging - +import structlog from aiogram import Dispatcher, F, types from aiogram.filters import Command from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup @@ -24,7 +23,7 @@ from app.services.support_settings_service import SupportSettingsService from app.utils.decorators import admin_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @admin_required @@ -52,7 +51,7 @@ async def show_admin_panel(callback: types.CallbackQuery, db_user: User, db: Asy ), ) except Exception as e: - logger.error(f'Не удалось получить статистику Remnawave для админ-панели: {e}') + logger.error('Не удалось получить статистику Remnawave для админ-панели', error=e) await callback.message.edit_text(admin_text, reply_markup=get_admin_main_keyboard(db_user.language)) await callback.answer() @@ -288,12 +287,14 @@ async def clear_rules_command(message: types.Message, db_user: User, db: AsyncSe f'Теперь используются стандартные правила по умолчанию.' ) - logger.info(f'Правила очищены командой администратором {db_user.telegram_id} ({db_user.full_name})') + logger.info( + 'Правила очищены командой администратором', telegram_id=db_user.telegram_id, full_name=db_user.full_name + ) else: await message.reply('⚠️ Нет правил для очистки\n\nАктивные правила не найдены.') except Exception as e: - logger.error(f'Ошибка при очистке правил командой: {e}') + logger.error('Ошибка при очистке правил командой', error=e) await message.reply( '❌ Ошибка при очистке правил\n\n' f'Произошла ошибка: {e!s}\n' @@ -330,7 +331,7 @@ async def rules_stats_command(message: types.Message, db_user: User, db: AsyncSe await message.reply(text) except Exception as e: - logger.error(f'Ошибка при получении статистики правил: {e}') + logger.error('Ошибка при получении статистики правил', error=e) await message.reply(f'❌ Ошибка получения статистики\n\nПроизошла ошибка: {e!s}') diff --git a/app/handlers/admin/maintenance.py b/app/handlers/admin/maintenance.py index 0fa7c07a..7510a5e1 100644 --- a/app/handlers/admin/maintenance.py +++ b/app/handlers/admin/maintenance.py @@ -1,5 +1,4 @@ -import logging - +import structlog from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup @@ -12,7 +11,7 @@ from app.services.maintenance_service import maintenance_service from app.utils.decorators import admin_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class MaintenanceStates(StatesGroup): @@ -33,7 +32,7 @@ async def show_maintenance_panel(callback: types.CallbackQuery, db_user: User, d rw_service = RemnaWaveService() panel_status = await rw_service.get_panel_status_summary() except Exception as e: - logger.error(f'Ошибка получения статуса панели: {e}') + logger.error('Ошибка получения статуса панели', error=e) panel_status = {'description': '❓ Не удалось проверить', 'has_issues': True} status_emoji = '🔧' if status_info['is_active'] else '✅' @@ -316,7 +315,7 @@ async def process_notification_message(message: types.Message, db_user: User, db await message.answer('❌ Ошибка отправки уведомления') except Exception as e: - logger.error(f'Ошибка отправки ручного уведомления: {e}') + logger.error('Ошибка отправки ручного уведомления', error=e) await message.answer(f'❌ Ошибка: {e!s}') await state.clear() diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py index 8c8db193..cd623ebc 100644 --- a/app/handlers/admin/messages.py +++ b/app/handlers/admin/messages.py @@ -1,8 +1,8 @@ import asyncio import html -import logging from datetime import datetime, timedelta +import structlog from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter from aiogram.fsm.context import FSMContext @@ -48,7 +48,7 @@ from app.utils.decorators import admin_required, error_handler from app.utils.miniapp_buttons import BUTTON_KEY_TO_CABINET_PATH, build_miniapp_or_callback_button -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def safe_edit_or_send_text(callback: types.CallbackQuery, text: str, reply_markup=None, parse_mode: str = 'HTML'): @@ -158,8 +158,7 @@ async def _persist_broadcast_result( broadcast_history = await session.get(BroadcastHistory, broadcast_id) if not broadcast_history: logger.critical( - 'Не удалось найти запись BroadcastHistory #%s для записи результатов', - broadcast_id, + 'Не удалось найти запись BroadcastHistory # для записи результатов', broadcast_id=broadcast_id ) return @@ -170,35 +169,35 @@ async def _persist_broadcast_result( await session.commit() logger.info( - 'Результаты рассылки сохранены (id=%s, sent=%d, failed=%d, status=%s)', - broadcast_id, - sent_count, - failed_count, - status, + 'Результаты рассылки сохранены (id sent failed status=)', + broadcast_id=broadcast_id, + sent_count=sent_count, + failed_count=failed_count, + status=status, ) return except InterfaceError as error: logger.warning( - 'Ошибка соединения при сохранении результатов рассылки (попытка %d/%d): %s', - attempt, - max_retries, - error, + 'Ошибка соединения при сохранении результатов рассылки (попытка /)', + attempt=attempt, + max_retries=max_retries, + error=error, ) if attempt < max_retries: await asyncio.sleep(retry_delay) retry_delay *= 2 else: logger.critical( - 'Не удалось сохранить результаты рассылки после %d попыток (id=%s)', - max_retries, - broadcast_id, + 'Не удалось сохранить результаты рассылки после попыток (id=)', + max_retries=max_retries, + broadcast_id=broadcast_id, ) except Exception as error: logger.critical( - 'Неожиданная ошибка при сохранении результатов рассылки (id=%s)', - broadcast_id, + 'Неожиданная ошибка при сохранении результатов рассылки (id=)', + broadcast_id=broadcast_id, exc_info=error, ) return @@ -1268,11 +1267,11 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state: wait_seconds = e.retry_after + 1 flood_wait_until = asyncio.get_event_loop().time() + wait_seconds logger.warning( - 'FloodWait: Telegram просит подождать %d сек (пользователь %d, попытка %d/%d)', - e.retry_after, - telegram_id, - attempt + 1, - _MAX_SEND_RETRIES, + 'FloodWait: Telegram просит подождать сек (пользователь , попытка /)', + retry_after=e.retry_after, + telegram_id=telegram_id, + attempt=attempt + 1, + MAX_SEND_RETRIES=_MAX_SEND_RETRIES, ) await asyncio.sleep(wait_seconds) @@ -1280,16 +1279,16 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state: return False except TelegramBadRequest as e: - logger.debug('BadRequest при рассылке пользователю %d: %s', telegram_id, e) + logger.debug('BadRequest при рассылке пользователю', telegram_id=telegram_id, e=e) return False except Exception as e: logger.error( - 'Ошибка отправки пользователю %d (попытка %d/%d): %s', - telegram_id, - attempt + 1, - _MAX_SEND_RETRIES, - e, + 'Ошибка отправки пользователю (попытка /)', + telegram_id=telegram_id, + attempt=attempt + 1, + MAX_SEND_RETRIES=_MAX_SEND_RETRIES, + e=e, ) if attempt < _MAX_SEND_RETRIES - 1: await asyncio.sleep(0.5 * (attempt + 1)) @@ -1341,7 +1340,7 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state: await progress_message.edit_text(text, parse_mode='HTML') except TelegramRetryAfter as e: # Не паникуем — пропускаем обновление прогресса - logger.debug('FloodWait при обновлении прогресса, пропускаем: %d сек', e.retry_after) + logger.debug('FloodWait при обновлении прогресса, пропускаем: сек', retry_after=e.retry_after) except TelegramBadRequest: # Сообщение удалено или контент не изменился — отправляем новое try: @@ -1378,7 +1377,7 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state: failed_count += 1 elif isinstance(result, Exception): failed_count += 1 - logger.error('Необработанное исключение в рассылке: %s', result) + logger.error('Необработанное исключение в рассылке', result=result) # Обновляем прогресс каждые _PROGRESS_UPDATE_INTERVAL батчей if batch_idx % _PROGRESS_UPDATE_INTERVAL == 0: @@ -1390,7 +1389,7 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state: # Учитываем пропущенных email-only пользователей skipped_email_users = total_users_count - total_recipients if skipped_email_users > 0: - logger.info('Пропущено %d email-only пользователей при рассылке', skipped_email_users) + logger.info('Пропущено email-only пользователей при рассылке', skipped_email_users=skipped_email_users) status = 'completed' if failed_count == 0 else 'partial' @@ -1439,12 +1438,12 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state: await state.clear() logger.info( - 'Рассылка завершена админом %s: sent=%d, failed=%d, total=%d (медиа: %s)', - admin_telegram_id, - sent_count, - failed_count, - total_users_count, - has_media, + 'Рассылка завершена админом : sent failed total= (медиа:)', + admin_telegram_id=admin_telegram_id, + sent_count=sent_count, + failed_count=failed_count, + total_users_count=total_users_count, + has_media=has_media, ) diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index 48fb8426..6cd23fdd 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -1,7 +1,7 @@ import asyncio -import logging from datetime import datetime, timedelta +import structlog from aiogram import F, Router from aiogram.exceptions import TelegramBadRequest from aiogram.filters import Command @@ -23,7 +23,7 @@ from app.utils.decorators import admin_required from app.utils.pagination import paginate_list -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = Router() @@ -393,7 +393,7 @@ async def admin_monitoring_menu(callback: CallbackQuery): await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f'Ошибка в админ меню мониторинга: {e}') + logger.error('Ошибка в админ меню мониторинга', error=e) await callback.answer('❌ Ошибка получения данных', show_alert=True) @@ -428,7 +428,7 @@ async def admin_monitoring_settings(callback: CallbackQuery): await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f'Ошибка отображения настроек мониторинга: {e}') + logger.error('Ошибка отображения настроек мониторинга', error=e) await callback.answer('❌ Не удалось открыть настройки', show_alert=True) @@ -438,7 +438,7 @@ async def admin_notify_settings(callback: CallbackQuery): try: await _render_notification_settings(callback) except Exception as e: - logger.error(f'Ошибка отображения настроек уведомлений: {e}') + logger.error('Ошибка отображения настроек уведомлений', error=e) await callback.answer('❌ Не удалось загрузить настройки', show_alert=True) @@ -459,7 +459,7 @@ async def preview_trial_channel_notification(callback: CallbackQuery): await _send_notification_preview(callback.bot, callback.from_user.id, language, 'trial_channel_unsubscribed') await callback.answer('✅ Пример отправлен') except Exception as exc: - logger.error('Failed to send trial channel preview: %s', exc) + logger.error('Failed to send trial channel preview', exc=exc) await callback.answer('❌ Не удалось отправить тест', show_alert=True) @@ -480,7 +480,7 @@ async def preview_expired_1d_notification(callback: CallbackQuery): await _send_notification_preview(callback.bot, callback.from_user.id, language, 'expired_1d') await callback.answer('✅ Пример отправлен') except Exception as exc: - logger.error('Failed to send expired 1d preview: %s', exc) + logger.error('Failed to send expired 1d preview', exc=exc) await callback.answer('❌ Не удалось отправить тест', show_alert=True) @@ -501,7 +501,7 @@ async def preview_second_wave_notification(callback: CallbackQuery): await _send_notification_preview(callback.bot, callback.from_user.id, language, 'expired_2d') await callback.answer('✅ Пример отправлен') except Exception as exc: - logger.error('Failed to send second wave preview: %s', exc) + logger.error('Failed to send second wave preview', exc=exc) await callback.answer('❌ Не удалось отправить тест', show_alert=True) @@ -522,7 +522,7 @@ async def preview_third_wave_notification(callback: CallbackQuery): await _send_notification_preview(callback.bot, callback.from_user.id, language, 'expired_nd') await callback.answer('✅ Пример отправлен') except Exception as exc: - logger.error('Failed to send third wave preview: %s', exc) + logger.error('Failed to send third wave preview', exc=exc) await callback.answer('❌ Не удалось отправить тест', show_alert=True) @@ -541,7 +541,7 @@ async def preview_all_notifications(callback: CallbackQuery): await _send_notification_preview(callback.bot, chat_id, language, notification_type) await callback.answer('✅ Все тестовые уведомления отправлены') except Exception as exc: - logger.error('Failed to send all notification previews: %s', exc) + logger.error('Failed to send all notification previews', exc=exc) await callback.answer('❌ Не удалось отправить тесты', show_alert=True) @@ -655,7 +655,7 @@ async def start_monitoring_callback(callback: CallbackQuery): await admin_monitoring_menu(callback) except Exception as e: - logger.error(f'Ошибка запуска мониторинга: {e}') + logger.error('Ошибка запуска мониторинга', error=e) await callback.answer(f'❌ Ошибка запуска: {e!s}', show_alert=True) @@ -673,7 +673,7 @@ async def stop_monitoring_callback(callback: CallbackQuery): await admin_monitoring_menu(callback) except Exception as e: - logger.error(f'Ошибка остановки мониторинга: {e}') + logger.error('Ошибка остановки мониторинга', error=e) await callback.answer(f'❌ Ошибка остановки: {e!s}', show_alert=True) @@ -708,7 +708,7 @@ async def force_check_callback(callback: CallbackQuery): await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f'Ошибка принудительной проверки: {e}') + logger.error('Ошибка принудительной проверки', error=e) await callback.answer(f'❌ Ошибка проверки: {e!s}', show_alert=True) @@ -774,7 +774,7 @@ async def traffic_check_callback(callback: CallbackQuery): await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f'Ошибка проверки трафика: {e}') + logger.error('Ошибка проверки трафика', error=e) await callback.answer(f'❌ Ошибка: {e!s}', show_alert=True) @@ -826,7 +826,7 @@ async def monitoring_logs_callback(callback: CallbackQuery): await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f'Ошибка получения логов: {e}') + logger.error('Ошибка получения логов', error=e) await callback.answer('❌ Ошибка получения логов', show_alert=True) @@ -846,7 +846,7 @@ async def clear_logs_callback(callback: CallbackQuery): await monitoring_logs_callback(callback) except Exception as e: - logger.error(f'Ошибка очистки логов: {e}') + logger.error('Ошибка очистки логов', error=e) await callback.answer(f'❌ Ошибка очистки: {e!s}', show_alert=True) @@ -872,7 +872,7 @@ async def test_notifications_callback(callback: CallbackQuery): await callback.answer('✅ Тестовое уведомление отправлено!') except Exception as e: - logger.error(f'Ошибка отправки тестового уведомления: {e}') + logger.error('Ошибка отправки тестового уведомления', error=e) await callback.answer(f'❌ Ошибка отправки: {e!s}', show_alert=True) @@ -971,7 +971,7 @@ async def monitoring_statistics_callback(callback: CallbackQuery): await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f'Ошибка получения статистики: {e}') + logger.error('Ошибка получения статистики', error=e) await callback.answer(f'❌ Ошибка получения статистики: {e!s}', show_alert=True) @@ -1082,7 +1082,7 @@ async def nalogo_force_process_callback(callback: CallbackQuery): await callback.message.edit_text(stats_text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f'Ошибка принудительной обработки чеков: {e}') + logger.error('Ошибка принудительной обработки чеков', error=e) await callback.answer(f'❌ Ошибка: {e!s}', show_alert=True) @@ -1143,7 +1143,7 @@ async def nalogo_pending_callback(callback: CallbackQuery): await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f'Ошибка просмотра очереди проверки: {e}') + logger.error('Ошибка просмотра очереди проверки', error=e) await callback.answer(f'❌ Ошибка: {e!s}', show_alert=True) @@ -1168,7 +1168,7 @@ async def nalogo_mark_verified_callback(callback: CallbackQuery): await callback.answer('❌ Чек не найден', show_alert=True) except Exception as e: - logger.error(f'Ошибка пометки чека: {e}') + logger.error('Ошибка пометки чека', error=e) await callback.answer(f'❌ Ошибка: {e!s}', show_alert=True) @@ -1194,7 +1194,7 @@ async def nalogo_retry_callback(callback: CallbackQuery): await callback.answer('❌ Не удалось создать чек', show_alert=True) except Exception as e: - logger.error(f'Ошибка повторной отправки чека: {e}') + logger.error('Ошибка повторной отправки чека', error=e) await callback.answer(f'❌ Ошибка: {e!s}', show_alert=True) @@ -1218,7 +1218,7 @@ async def nalogo_clear_pending_callback(callback: CallbackQuery): ) except Exception as e: - logger.error(f'Ошибка очистки очереди: {e}') + logger.error('Ошибка очистки очереди', error=e) await callback.answer(f'❌ Ошибка: {e!s}', show_alert=True) @@ -1334,7 +1334,7 @@ async def receipts_link_old_callback(callback: CallbackQuery): await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f'Ошибка привязки старых чеков: {e}', exc_info=True) + logger.error('Ошибка привязки старых чеков', error=e, exc_info=True) await callback.answer(f'❌ Ошибка: {e!s}', show_alert=True) @@ -1415,7 +1415,7 @@ async def _do_reconcile_logs(callback: CallbackQuery): date_str, payment_id, receipt_uuid = match.groups() receipts[payment_id] = {'date': date_str, 'receipt_uuid': receipt_uuid} except Exception as e: - logger.error(f'Ошибка чтения логов: {e}') + logger.error('Ошибка чтения логов', error=e) await callback.message.edit_text( f'❌ Ошибка чтения логов\n\n{e!s}', parse_mode='HTML', @@ -1479,7 +1479,7 @@ async def _do_reconcile_logs(callback: CallbackQuery): except TelegramBadRequest: pass # Игнорируем если сообщение не изменилось except Exception as e: - logger.error(f'Ошибка сверки по логам: {e}', exc_info=True) + logger.error('Ошибка сверки по логам', error=e, exc_info=True) await callback.answer(f'❌ Ошибка: {e!s}', show_alert=True) @@ -1575,7 +1575,7 @@ async def receipts_reconcile_logs_details_callback(callback: CallbackQuery): except TelegramBadRequest: pass except Exception as e: - logger.error(f'Ошибка детализации: {e}', exc_info=True) + logger.error('Ошибка детализации', error=e, exc_info=True) await callback.answer(f'❌ Ошибка: {e!s}', show_alert=True) @@ -1647,7 +1647,7 @@ async def monitoring_command(message: Message): await message.answer(text, parse_mode='HTML') except Exception as e: - logger.error(f'Ошибка команды /monitoring: {e}') + logger.error('Ошибка команды /monitoring', error=e) await message.answer(f'❌ Ошибка: {e!s}') @@ -1836,7 +1836,7 @@ async def admin_traffic_settings(callback: CallbackQuery): keyboard = _build_traffic_settings_keyboard() await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f'Ошибка отображения настроек трафика: {e}') + logger.error('Ошибка отображения настроек трафика', error=e) await callback.answer('❌ Ошибка загрузки настроек', show_alert=True) @@ -1862,7 +1862,7 @@ async def toggle_fast_check(callback: CallbackQuery): await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f'Ошибка переключения быстрой проверки: {e}') + logger.error('Ошибка переключения быстрой проверки', error=e) await callback.answer('❌ Ошибка', show_alert=True) @@ -1887,7 +1887,7 @@ async def toggle_daily_check(callback: CallbackQuery): await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f'Ошибка переключения суточной проверки: {e}') + logger.error('Ошибка переключения суточной проверки', error=e) await callback.answer('❌ Ошибка', show_alert=True) @@ -2037,7 +2037,7 @@ async def process_traffic_setting_input(message: Message, state: FSMContext): pass # Игнорируем если сообщение уже удалено except Exception as e: - logger.error(f'Ошибка сохранения настройки трафика: {e}') + logger.error('Ошибка сохранения настройки трафика', error=e) await message.answer(f'❌ Ошибка сохранения: {e!s}') await state.clear() diff --git a/app/handlers/admin/payments.py b/app/handlers/admin/payments.py index 950e6a37..682ce7a1 100644 --- a/app/handlers/admin/payments.py +++ b/app/handlers/admin/payments.py @@ -3,6 +3,7 @@ from __future__ import annotations import html import math +import structlog from aiogram import Dispatcher, F, types from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession @@ -22,6 +23,8 @@ from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_datetime, format_time_ago, format_username +logger = structlog.get_logger(__name__) + PAGE_SIZE = 6 @@ -603,32 +606,29 @@ async def manual_check_payment( db_user: User, db: AsyncSession, ) -> None: - import logging - - logger = logging.getLogger(__name__) - logger.info('manual_check_payment called: %s', callback.data) + logger.info('manual_check_payment called', callback_data=callback.data) parsed = _parse_method_and_id(callback.data, prefix='admin_payment_check_') if not parsed: - logger.warning('Failed to parse: %s', callback.data) + logger.warning('Failed to parse', callback_data=callback.data) await callback.answer('❌ Invalid payment reference', show_alert=True) return method, payment_id = parsed - logger.info('Checking payment: method=%s, id=%s', method, payment_id) + logger.info('Checking payment: method id', method=method, payment_id=payment_id) record = await get_payment_record(db, method, payment_id) texts = get_texts(db_user.language) if not record: - logger.warning('Payment not found: method=%s, id=%s', method, payment_id) + logger.warning('Payment not found: method id', method=method, payment_id=payment_id) await callback.answer(texts.t('ADMIN_PAYMENT_NOT_FOUND', 'Payment not found.'), show_alert=True) return - logger.info('Record found: status=%s, is_paid=%s', record.status, record.is_paid) + logger.info('Record found: status is_paid', record_status=record.status, is_paid=record.is_paid) if not _is_checkable(record): - logger.info('Payment not checkable: method=%s, status=%s', method, record.status) + logger.info('Payment not checkable: method status', method=method, record_status=record.status) await callback.answer( texts.t('ADMIN_PAYMENT_CHECK_NOT_AVAILABLE', 'Manual check is not available for this invoice.'), show_alert=True, @@ -638,7 +638,7 @@ async def manual_check_payment( logger.info('Running manual check...') payment_service = PaymentService(callback.bot) updated = await run_manual_check(db, method, payment_id, payment_service) - logger.info('Check result: updated=%s', updated is not None) + logger.info('Check result', updated=updated is not None) if not updated: await callback.answer( @@ -672,19 +672,16 @@ async def check_all_payments( db: AsyncSession, ) -> None: """Массовая проверка всех ожидающих платежей.""" - import logging - - logger = logging.getLogger(__name__) logger.info('check_all_payments called') texts = get_texts(db_user.language) # Получаем все ожидающие платежи records = await list_recent_pending_payments(db) - logger.info('Found %d total records', len(records)) + logger.info('Found total records', records_count=len(records)) checkable_records = [r for r in records if _is_checkable(r) and not r.is_paid] - logger.info('Found %d checkable records', len(checkable_records)) + logger.info('Found checkable records', checkable_records_count=len(checkable_records)) if not checkable_records: await callback.answer( @@ -704,17 +701,17 @@ async def check_all_payments( for record in checkable_records: try: - logger.info('Checking %s payment id=%s', record.method.value, record.local_id) + logger.info('Checking payment', method=record.method.value, local_id=record.local_id) updated = await run_manual_check(db, record.method, record.local_id, payment_service) checked += 1 - logger.info('Check result: is_paid=%s', updated.is_paid if updated else None) + logger.info('Check result: is_paid', is_paid=updated.is_paid if updated else None) if updated and updated.is_paid and not record.is_paid: confirmed += 1 except Exception as e: - logger.error('Check failed for %s id=%s: %s', record.method.value, record.local_id, e, exc_info=True) + logger.error('Check failed', method=record.method.value, local_id=record.local_id, error=e, exc_info=True) failed += 1 - logger.info('Check complete: checked=%d, confirmed=%d, failed=%d', checked, confirmed, failed) + logger.info('Check complete: checked confirmed failed', checked=checked, confirmed=confirmed, failed=failed) # Показываем результат result_lines = [ @@ -759,7 +756,7 @@ async def check_all_payments( ) logger.info('Message updated successfully') except Exception as e: - logger.error('Failed to update message: %s', e, exc_info=True) + logger.error('Failed to update message', e=e, exc_info=True) @admin_required diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 51497909..d51000b9 100644 --- a/app/handlers/admin/polls.py +++ b/app/handlers/admin/polls.py @@ -1,7 +1,7 @@ import html -import logging from decimal import ROUND_HALF_UP, Decimal, InvalidOperation +import structlog from aiogram import Bot, Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext @@ -31,14 +31,14 @@ from app.utils.decorators import admin_required, error_handler from app.utils.validators import get_html_help_text, validate_html_tags -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _safe_format_price(amount_kopeks: int) -> str: try: return settings.format_price(amount_kopeks) except Exception as error: # pragma: no cover - defensive logging - logger.error('Не удалось отформатировать сумму %s: %s', amount_kopeks, error) + logger.error('Не удалось отформатировать сумму', amount_kopeks=amount_kopeks, error=error) return f'{amount_kopeks / 100:.2f} ₽' @@ -47,9 +47,9 @@ async def _safe_delete_message(message: types.Message) -> None: await message.delete() except TelegramBadRequest as error: if 'message to delete not found' in str(error).lower(): - logger.debug('Сообщение уже удалено: %s', error) + logger.debug('Сообщение уже удалено', error=error) else: - logger.warning('Не удалось удалить сообщение %s: %s', message.message_id, error) + logger.warning('Не удалось удалить сообщение', message_id=message.message_id, error=error) async def _edit_creation_message( @@ -87,9 +87,7 @@ async def _edit_creation_message( ) except Exception as error: # pragma: no cover - defensive logging logger.error( - 'Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s', - message_id, - error, + 'Непредвиденная ошибка при обновлении сообщения создания опроса', message_id=message_id, error=error ) return False @@ -112,18 +110,12 @@ async def _send_creation_message( except TelegramBadRequest as error: error_text = str(error).lower() if 'message to delete not found' in error_text: - logger.debug('Сообщение уже удалено: %s', error) + logger.debug('Сообщение уже удалено', error=error) else: - logger.warning( - 'Не удалось удалить сообщение создания опроса %s: %s', - message_id, - error, - ) + logger.warning('Не удалось удалить сообщение создания опроса', message_id=message_id, error=error) except Exception as error: # pragma: no cover - defensive logging logger.error( - 'Непредвиденная ошибка при удалении сообщения создания опроса %s: %s', - message_id, - error, + 'Непредвиденная ошибка при удалении сообщения создания опроса', message_id=message_id, error=error ) sent_message = await message.bot.send_message( diff --git a/app/handlers/admin/pricing.py b/app/handlers/admin/pricing.py index f9f95462..d6d95666 100644 --- a/app/handlers/admin/pricing.py +++ b/app/handlers/admin/pricing.py @@ -1,9 +1,9 @@ -import logging from collections.abc import Iterable from dataclasses import dataclass from decimal import ROUND_HALF_UP, Decimal, InvalidOperation from typing import Any +import structlog from aiogram import Bot, Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext @@ -17,7 +17,7 @@ from app.states import PricingStates from app.utils.decorators import admin_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) PriceItem = tuple[str, str, int] @@ -825,7 +825,7 @@ async def _render_message( try: await message.edit_text(text, reply_markup=keyboard, parse_mode='HTML') except TelegramBadRequest as error: # message changed elsewhere - logger.debug('Failed to edit pricing message: %s', error) + logger.debug('Failed to edit pricing message', error=error) await message.answer(text, reply_markup=keyboard, parse_mode='HTML') @@ -845,7 +845,7 @@ async def _render_message_by_id( parse_mode='HTML', ) except TelegramBadRequest as error: - logger.debug('Failed to edit pricing message by id: %s', error) + logger.debug('Failed to edit pricing message by id', error=error) await bot.send_message(chat_id, text, reply_markup=keyboard, parse_mode='HTML') @@ -1136,7 +1136,7 @@ async def process_pricing_input( try: await message.delete() except TelegramBadRequest as error: - logger.debug('Failed to delete pricing input message: %s', error) + logger.debug('Failed to delete pricing input message', error=error) await state.clear() return entry = SETTING_ENTRY_BY_KEY.get(key) diff --git a/app/handlers/admin/privacy_policy.py b/app/handlers/admin/privacy_policy.py index 92d9bd44..a4f57cb3 100644 --- a/app/handlers/admin/privacy_policy.py +++ b/app/handlers/admin/privacy_policy.py @@ -1,7 +1,7 @@ import html -import logging from datetime import datetime +import structlog from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -14,7 +14,7 @@ from app.utils.decorators import admin_required, error_handler from app.utils.validators import get_html_help_text, validate_html_tags -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _format_timestamp(value: datetime | None) -> str: @@ -344,9 +344,9 @@ async def process_privacy_policy_edit( await PrivacyPolicyService.save_policy(db, db_user.language, new_text) logger.info( - 'Админ %s обновил текст политики конфиденциальности (%d символов)', - db_user.telegram_id, - len(new_text), + 'Админ обновил текст политики конфиденциальности (символов)', + telegram_id=db_user.telegram_id, + new_text_count=len(new_text), ) await state.clear() diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index dd86b067..74260f40 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -1,6 +1,6 @@ -import logging from decimal import ROUND_HALF_UP, Decimal, InvalidOperation +import structlog from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext @@ -27,7 +27,7 @@ from app.utils.decorators import admin_required, error_handler from app.utils.pricing_utils import format_period_description -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _format_discount_lines(texts, group) -> list[str]: @@ -806,7 +806,7 @@ async def process_create_group_auto_assign( auto_assign_total_spent_kopeks=auto_assign_kopeks, ) except Exception as e: - logger.error(f'Не удалось создать промогруппу: {e}') + logger.error('Не удалось создать промогруппу', error=e) await message.answer(texts.ERROR) await state.clear() return diff --git a/app/handlers/admin/promo_offers.py b/app/handlers/admin/promo_offers.py index 33c187b7..38ea47eb 100644 --- a/app/handlers/admin/promo_offers.py +++ b/app/handlers/admin/promo_offers.py @@ -2,11 +2,11 @@ from __future__ import annotations import asyncio import html -import logging import re from collections.abc import Sequence from datetime import datetime +import structlog from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.fsm.context import FSMContext @@ -48,7 +48,7 @@ from app.utils.miniapp_buttons import build_miniapp_or_callback_button from app.utils.subscription_utils import get_display_subscription_link -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) SQUADS_PAGE_LIMIT = 10 @@ -61,7 +61,7 @@ async def _safe_delete_message(message: Message) -> None: await message.delete() except TelegramBadRequest as exc: if 'message to delete not found' not in str(exc).lower(): - logger.debug('Не удалось удалить сообщение администратора: %s', exc) + logger.debug('Не удалось удалить сообщение администратора', exc=exc) except TelegramForbiddenError: logger.debug('Недостаточно прав для удаления сообщения администратора') @@ -72,16 +72,11 @@ async def _safe_delete_message_by_id(bot, chat_id: int, message_id: int) -> None except TelegramBadRequest as exc: if 'message to delete not found' not in str(exc).lower(): logger.debug( - 'Не удалось удалить сообщение администратора (%s, %s): %s', - chat_id, - message_id, - exc, + 'Не удалось удалить сообщение администратора (,)', chat_id=chat_id, message_id=message_id, exc=exc ) except TelegramForbiddenError: logger.debug( - 'Недостаточно прав для удаления сообщения администратора (%s, %s)', - chat_id, - message_id, + 'Недостаточно прав для удаления сообщения администратора (,)', chat_id=chat_id, message_id=message_id ) @@ -168,7 +163,7 @@ def _render_template_text( try: return template.message_text.format(**replacements) except Exception: # pragma: no cover - fallback for invalid placeholders - logger.warning('Не удалось форматировать текст промо-предложения %s', template.id) + logger.warning('Не удалось форматировать текст промо-предложения', template_id=template.id) return template.message_text @@ -1181,7 +1176,7 @@ async def _handle_edit_field( except TelegramBadRequest: logger.debug('Не удалось удалить сообщение промо без текста') else: - logger.warning('Не удалось обновить сообщение редактирования промо: %s', exc) + logger.warning('Не удалось обновить сообщение редактирования промо', exc=exc) await message.answer(description, reply_markup=reply_markup, parse_mode='HTML') else: await message.answer(description, reply_markup=reply_markup, parse_mode='HTML') @@ -1928,7 +1923,7 @@ async def _send_offer_to_users( """Отправляет одно предложение с семафором ограничения""" # Skip email-only users (no telegram_id) if not user.telegram_id: - logger.debug('Пропуск email-пользователя %s при рассылке промо', user.id) + logger.debug('Пропуск email-пользователя при рассылке промо', user_id=user.id) return False async with semaphore: @@ -1989,10 +1984,14 @@ async def _send_offer_to_users( ) return True except (TelegramForbiddenError, TelegramBadRequest) as exc: - logger.warning('Не удалось отправить предложение пользователю %s: %s', user.telegram_id or user.id, exc) + logger.warning( + 'Не удалось отправить предложение пользователю', telegram_id=user.telegram_id or user.id, exc=exc + ) return False except Exception as exc: # pragma: no cover - defensive logging - logger.error('Ошибка рассылки промо предложения пользователю %s: %s', user.telegram_id or user.id, exc) + logger.error( + 'Ошибка рассылки промо предложения пользователю', telegram_id=user.telegram_id or user.id, exc=exc + ) return False # Отправляем предложения пакетами для эффективности diff --git a/app/handlers/admin/promocodes.py b/app/handlers/admin/promocodes.py index 62390a5b..15ee323c 100644 --- a/app/handlers/admin/promocodes.py +++ b/app/handlers/admin/promocodes.py @@ -1,6 +1,6 @@ -import logging from datetime import datetime, timedelta +import structlog from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -28,7 +28,7 @@ from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_datetime -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @admin_required @@ -597,7 +597,11 @@ async def handle_edit_value(message: types.Message, db_user: User, state: FSMCon await state.clear() logger.info( - f'Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: {edit_action} = {value}' + 'Промокод отредактирован администратором', + code=promo.code, + telegram_id=db_user.telegram_id, + edit_action=edit_action, + value=value, ) except ValueError: @@ -672,7 +676,10 @@ async def handle_edit_uses(message: types.Message, db_user: User, state: FSMCont await state.clear() logger.info( - f'Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: max_uses = {max_uses}' + 'Промокод отредактирован администратором max_uses', + code=promo.code, + telegram_id=db_user.telegram_id, + max_uses=max_uses, ) except ValueError: @@ -770,7 +777,7 @@ async def process_promocode_expiry(message: types.Message, db_user: User, state: ) await state.clear() - logger.info(f'Создан промокод {code} администратором {db_user.telegram_id}') + logger.info('Создан промокод администратором', code=code, telegram_id=db_user.telegram_id) except ValueError: await message.answer('❌ Введите корректное число дней') @@ -840,7 +847,11 @@ async def process_discount_hours(message: types.Message, db_user: User, state: F await state.clear() logger.info( - f'Создан DISCOUNT промокод {code} ({value}%, {discount_hours}ч) администратором {db_user.telegram_id}' + 'Создан DISCOUNT промокод (%, ч) администратором', + code=code, + value=value, + discount_hours=discount_hours, + telegram_id=db_user.telegram_id, ) except ValueError: @@ -886,7 +897,10 @@ async def handle_edit_expiry(message: types.Message, db_user: User, state: FSMCo await state.clear() logger.info( - f'Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: expiry = {expiry_days} дней' + 'Промокод отредактирован администратором expiry дней', + code=promo.code, + telegram_id=db_user.telegram_id, + expiry_days=expiry_days, ) except ValueError: diff --git a/app/handlers/admin/public_offer.py b/app/handlers/admin/public_offer.py index 31043965..792584ca 100644 --- a/app/handlers/admin/public_offer.py +++ b/app/handlers/admin/public_offer.py @@ -1,7 +1,7 @@ import html -import logging from datetime import datetime +import structlog from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -14,7 +14,7 @@ from app.utils.decorators import admin_required, error_handler from app.utils.validators import get_html_help_text, validate_html_tags -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _format_timestamp(value: datetime | None) -> str: @@ -349,9 +349,7 @@ async def process_public_offer_edit( await PublicOfferService.save_offer(db, db_user.language, new_text) logger.info( - 'Админ %s обновил текст публичной оферты (%d символов)', - db_user.telegram_id, - len(new_text), + 'Админ обновил текст публичной оферты (символов)', telegram_id=db_user.telegram_id, new_text_count=len(new_text) ) await state.clear() diff --git a/app/handlers/admin/referrals.py b/app/handlers/admin/referrals.py index 0ba96478..e854d277 100644 --- a/app/handlers/admin/referrals.py +++ b/app/handlers/admin/referrals.py @@ -1,7 +1,7 @@ import datetime import json -import logging +import structlog from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy import select @@ -20,7 +20,7 @@ from app.states import AdminStates from app.utils.decorators import admin_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @admin_required @@ -64,7 +64,7 @@ async def show_referral_statistics(callback: types.CallbackQuery, db_user: User, if count > 0: text += f'{i}. ID {user_id}: {settings.format_price(earned)} ({count} реф.)\n' else: - logger.warning(f'Реферер {user_id} имеет {count} рефералов, но есть в топе') + logger.warning('Реферер имеет рефералов, но есть в топе', user_id=user_id, count=count) else: text += 'Нет данных\n' @@ -108,11 +108,11 @@ async def show_referral_statistics(callback: types.CallbackQuery, db_user: User, if 'message is not modified' in str(edit_error): await callback.answer('Данные актуальны') else: - logger.error(f'Ошибка редактирования сообщения: {edit_error}') + logger.error('Ошибка редактирования сообщения', edit_error=edit_error) await callback.answer('Ошибка обновления') except Exception as e: - logger.error(f'Ошибка в show_referral_statistics: {e}', exc_info=True) + logger.error('Ошибка в show_referral_statistics', error=e, exc_info=True) current_time = datetime.datetime.now().strftime('%H:%M:%S') text = f""" @@ -253,7 +253,7 @@ async def _show_top_referrers_filtered(callback: types.CallbackQuery, db: AsyncS raise except Exception as e: - logger.error(f'Ошибка в show_top_referrers_filtered: {e}', exc_info=True) + logger.error('Ошибка в show_top_referrers_filtered', error=e, exc_info=True) await callback.answer('Ошибка загрузки топа рефереров') @@ -448,7 +448,7 @@ async def approve_withdrawal_request(callback: types.CallbackQuery, db_user: Use ).format(id=request.id, amount=texts.format_price(request.amount_kopeks)), ) except Exception as e: - logger.error(f'Ошибка отправки уведомления пользователю: {e}') + logger.error('Ошибка отправки уведомления пользователю', error=e) await callback.answer('✅ Заявка одобрена, средства списаны с баланса') @@ -489,7 +489,7 @@ async def reject_withdrawal_request(callback: types.CallbackQuery, db_user: User ).format(id=request.id, amount=texts.format_price(request.amount_kopeks)), ) except Exception as e: - logger.error(f'Ошибка отправки уведомления пользователю: {e}') + logger.error('Ошибка отправки уведомления пользователю', error=e) await callback.answer('❌ Заявка отклонена') @@ -530,7 +530,7 @@ async def complete_withdrawal_request(callback: types.CallbackQuery, db_user: Us ).format(id=request.id, amount=texts.format_price(request.amount_kopeks)), ) except Exception as e: - logger.error(f'Ошибка отправки уведомления пользователю: {e}') + logger.error('Ошибка отправки уведомления пользователю', error=e) await callback.answer('✅ Заявка выполнена') @@ -647,7 +647,10 @@ async def process_test_referral_earning(message: types.Message, db_user: User, d ) logger.info( - f'Тестовое начисление: админ {db_user.telegram_id} начислил {amount_rubles}₽ пользователю {target_telegram_id}' + 'Тестовое начисление: админ начислил ₽ пользователю', + telegram_id=db_user.telegram_id, + amount_rubles=amount_rubles, + target_telegram_id=target_telegram_id, ) @@ -794,7 +797,7 @@ async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncS await callback.message.edit_text(text, reply_markup=keyboard) except Exception as e: - logger.error(f'Ошибка в _show_diagnostics_for_period: {e}', exc_info=True) + logger.error('Ошибка в _show_diagnostics_for_period', error=e, exc_info=True) await callback.answer('Ошибка при анализе логов', show_alert=True) @@ -899,7 +902,7 @@ async def preview_referral_fixes(callback: types.CallbackQuery, db_user: User, d await callback.message.edit_text(text, reply_markup=keyboard) except Exception as e: - logger.error(f'Ошибка в preview_referral_fixes: {e}', exc_info=True) + logger.error('Ошибка в preview_referral_fixes', error=e, exc_info=True) await callback.answer('Ошибка при создании предпросмотра', show_alert=True) @@ -1002,7 +1005,7 @@ async def apply_referral_fixes(callback: types.CallbackQuery, db_user: User, db: await state.update_data(uploaded_file_report=None) except Exception as e: - logger.error(f'Ошибка в apply_referral_fixes: {e}', exc_info=True) + logger.error('Ошибка в apply_referral_fixes', error=e, exc_info=True) await callback.answer('Ошибка при применении исправлений', show_alert=True) @@ -1075,7 +1078,7 @@ async def check_missing_bonuses(callback: types.CallbackQuery, db_user: User, db await callback.message.edit_text(text, reply_markup=keyboard) except Exception as e: - logger.error(f'Ошибка в check_missing_bonuses: {e}', exc_info=True) + logger.error('Ошибка в check_missing_bonuses', error=e, exc_info=True) await callback.answer('Ошибка при проверке бонусов', show_alert=True) @@ -1134,7 +1137,7 @@ async def apply_missing_bonuses(callback: types.CallbackQuery, db_user: User, db await callback.message.edit_text(text, reply_markup=keyboard) except Exception as e: - logger.error(f'Ошибка в apply_missing_bonuses: {e}', exc_info=True) + logger.error('Ошибка в apply_missing_bonuses', error=e, exc_info=True) await callback.answer('Ошибка при начислении бонусов', show_alert=True) @@ -1211,7 +1214,7 @@ async def sync_referrals_with_contest( await callback.message.edit_text(text, reply_markup=keyboard) except Exception as e: - logger.error(f'Ошибка в sync_referrals_with_contest: {e}', exc_info=True) + logger.error('Ошибка в sync_referrals_with_contest', error=e, exc_info=True) await callback.answer('Ошибка при синхронизации', show_alert=True) @@ -1306,7 +1309,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi file = await message.bot.get_file(message.document.file_id) await message.bot.download_file(file.file_path, temp_file_path) - logger.info(f'📥 Файл загружен: {temp_file_path} ({message.document.file_size} байт)') + logger.info('📥 Файл загружен: ( байт)', temp_file_path=temp_file_path, file_size=message.document.file_size) # Обновляем статус await status_message.edit_text(f'🔍 Анализирую файл {file_name}...\n\nЭто может занять некоторое время.') @@ -1396,7 +1399,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi await state.set_state(AdminStates.referral_diagnostics_period) except Exception as e: - logger.error(f'❌ Ошибка при обработке файла: {e}', exc_info=True) + logger.error('❌ Ошибка при обработке файла', error=e, exc_info=True) try: await status_message.edit_text( @@ -1434,9 +1437,9 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi if temp_file_path and Path(temp_file_path).exists(): try: Path(temp_file_path).unlink() - logger.info(f'🗑️ Временный файл удалён: {temp_file_path}') + logger.info('🗑️ Временный файл удалён', temp_file_path=temp_file_path) except Exception as e: - logger.error(f'Ошибка удаления временного файла: {e}') + logger.error('Ошибка удаления временного файла', error=e) def register_handlers(dp: Dispatcher): diff --git a/app/handlers/admin/remnawave.py b/app/handlers/admin/remnawave.py index 868ce62e..d374e650 100644 --- a/app/handlers/admin/remnawave.py +++ b/app/handlers/admin/remnawave.py @@ -1,8 +1,8 @@ -import logging import math from datetime import datetime from typing import Any +import structlog from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -37,7 +37,7 @@ from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_bytes, format_datetime -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) squad_inbound_selections = {} squad_create_data = {} @@ -1403,7 +1403,7 @@ async def show_node_statistics(callback: types.CallbackQuery, db_user: User, db: await callback.answer() except Exception as e: - logger.error(f'Ошибка получения статистики ноды {node_uuid}: {e}') + logger.error('Ошибка получения статистики ноды', node_uuid=node_uuid, error=e) text = f""" 📊 Статистика ноды: {node['name']} @@ -1842,7 +1842,7 @@ async def save_squad_inbounds(callback: types.CallbackQuery, db_user: User, db: await callback.answer('❌ Ошибка сохранения изменений', show_alert=True) except Exception as e: - logger.error(f'Error saving squad inbounds: {e}') + logger.error('Error saving squad inbounds', error=e) await callback.answer('❌ Ошибка при сохранении', show_alert=True) diff --git a/app/handlers/admin/reports.py b/app/handlers/admin/reports.py index 50761ffa..0ec6cc04 100644 --- a/app/handlers/admin/reports.py +++ b/app/handlers/admin/reports.py @@ -1,5 +1,4 @@ -import logging - +import structlog from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from sqlalchemy.ext.asyncio import AsyncSession @@ -18,7 +17,7 @@ from app.services.reporting_service import ( from app.utils.decorators import admin_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @admin_required @@ -74,11 +73,11 @@ async def _send_report( try: report_text = await reporting_service.send_report(period, send_to_topic=True) except ReportingServiceError as exc: - logger.warning('Не удалось отправить отчет: %s', exc) + logger.warning('Не удалось отправить отчет', exc=exc) await callback.answer(str(exc), show_alert=True) return except Exception as exc: - logger.error('Непредвиденная ошибка при отправке отчета: %s', exc) + logger.error('Непредвиденная ошибка при отправке отчета', exc=exc) await callback.answer('Не удалось отправить отчет. Попробуйте позже.', show_alert=True) return @@ -101,7 +100,7 @@ async def close_report_message( try: await callback.message.delete() except (TelegramBadRequest, TelegramForbiddenError) as exc: - logger.warning('Не удалось закрыть сообщение отчета: %s', exc) + logger.warning('Не удалось закрыть сообщение отчета', exc=exc) await callback.answer(texts.t('REPORT_CLOSE_ERROR', 'Не удалось закрыть отчет.'), show_alert=True) return diff --git a/app/handlers/admin/rules.py b/app/handlers/admin/rules.py index 36aef825..84ebe2d3 100644 --- a/app/handlers/admin/rules.py +++ b/app/handlers/admin/rules.py @@ -1,6 +1,6 @@ -import logging import re +import structlog from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -20,7 +20,7 @@ def _safe_preview(html_text: str, limit: int = 500) -> str: return plain[:limit] + '...' -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @admin_required @@ -69,7 +69,7 @@ async def view_current_rules(callback: types.CallbackQuery, db_user: User, db: A ) await callback.answer() except Exception as e: - logger.error(f'Ошибка при показе правил: {e}') + logger.error('Ошибка при показе правил', error=e) await callback.message.edit_text( '❌ Ошибка при загрузке правил. Возможно, в тексте есть некорректные HTML теги.', reply_markup=types.InlineKeyboardMarkup( @@ -112,7 +112,7 @@ async def start_edit_rules(callback: types.CallbackQuery, db_user: User, state: await callback.answer() except Exception as e: - logger.error(f'Ошибка при начале редактирования правил: {e}') + logger.error('Ошибка при начале редактирования правил', error=e) await callback.answer('❌ Ошибка при загрузке правил для редактирования', show_alert=True) @@ -169,7 +169,7 @@ async def process_rules_edit(message: types.Message, db_user: User, state: FSMCo await state.update_data(new_rules=new_rules) except Exception as e: - logger.error(f'Ошибка при показе превью правил: {e}') + logger.error('Ошибка при показе превью правил', error=e) await message.answer( '⚠️ Подтверждение сохранения правил\n\n' f'Новые правила готовы к сохранению ({len(new_rules)} символов).\n' @@ -240,11 +240,11 @@ async def save_rules(callback: types.CallbackQuery, db_user: User, state: FSMCon ) await state.clear() - logger.info(f'Правила сервиса обновлены администратором {db_user.telegram_id}') + logger.info('Правила сервиса обновлены администратором', telegram_id=db_user.telegram_id) await callback.answer() except Exception as e: - logger.error(f'Ошибка сохранения правил: {e}') + logger.error('Ошибка сохранения правил', error=e) await callback.message.edit_text( '❌ Ошибка при сохранении правил\n\nПроизошла ошибка при записи в базу данных. Попробуйте еще раз.', reply_markup=types.InlineKeyboardMarkup( @@ -302,11 +302,11 @@ async def confirm_clear_rules(callback: types.CallbackQuery, db_user: User, db: ), ) - logger.info(f'Правила очищены администратором {db_user.telegram_id}') + logger.info('Правила очищены администратором', telegram_id=db_user.telegram_id) await callback.answer() except Exception as e: - logger.error(f'Ошибка при очистке правил: {e}') + logger.error('Ошибка при очистке правил', error=e) await callback.answer('❌ Ошибка при очистке правил', show_alert=True) diff --git a/app/handlers/admin/servers.py b/app/handlers/admin/servers.py index 67ceb231..148b4e0d 100644 --- a/app/handlers/admin/servers.py +++ b/app/handlers/admin/servers.py @@ -1,6 +1,6 @@ import html -import logging +import structlog from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -24,7 +24,7 @@ from app.utils.cache import cache from app.utils.decorators import admin_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _build_server_edit_view(server): @@ -262,7 +262,7 @@ async def sync_servers_with_remnawave(callback: types.CallbackQuery, db_user: Us await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) except Exception as e: - logger.error(f'Ошибка синхронизации серверов: {e}') + logger.error('Ошибка синхронизации серверов', error=e) await callback.message.edit_text( f'❌ Ошибка синхронизации: {e!s}', reply_markup=types.InlineKeyboardMarkup( @@ -1092,7 +1092,7 @@ async def sync_server_user_counts_handler(callback: types.CallbackQuery, db_user await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) except Exception as e: - logger.error(f'Ошибка синхронизации счетчиков: {e}') + logger.error('Ошибка синхронизации счетчиков', error=e) await callback.message.edit_text( f'❌ Ошибка синхронизации: {e!s}', reply_markup=types.InlineKeyboardMarkup( diff --git a/app/handlers/admin/statistics.py b/app/handlers/admin/statistics.py index 906ee8e2..4d61c167 100644 --- a/app/handlers/admin/statistics.py +++ b/app/handlers/admin/statistics.py @@ -1,6 +1,6 @@ -import logging from datetime import datetime, timedelta +import structlog from aiogram import Dispatcher, F, types from sqlalchemy.ext.asyncio import AsyncSession @@ -15,7 +15,7 @@ from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_datetime, format_percentage -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @admin_required @@ -75,7 +75,7 @@ async def show_users_statistics(callback: types.CallbackQuery, db_user: User, db if 'message is not modified' in str(e): await callback.answer('📊 Данные актуальны', show_alert=False) else: - logger.error(f'Ошибка обновления статистики пользователей: {e}') + logger.error('Ошибка обновления статистики пользователей', error=e) await callback.answer('❌ Ошибка обновления данных', show_alert=True) return @@ -126,7 +126,7 @@ async def show_subscriptions_statistics(callback: types.CallbackQuery, db_user: if 'message is not modified' in str(e): await callback.answer('📊 Данные актуальны', show_alert=False) else: - logger.error(f'Ошибка обновления статистики подписок: {e}') + logger.error('Ошибка обновления статистики подписок', error=e) await callback.answer('❌ Ошибка обновления данных', show_alert=True) @@ -181,7 +181,7 @@ async def show_revenue_statistics(callback: types.CallbackQuery, db_user: User, if 'message is not modified' in str(e): await callback.answer('📊 Данные актуальны', show_alert=False) else: - logger.error(f'Ошибка обновления статистики доходов: {e}') + logger.error('Ошибка обновления статистики доходов', error=e) await callback.answer('❌ Ошибка обновления данных', show_alert=True) @@ -239,7 +239,7 @@ async def show_referral_statistics(callback: types.CallbackQuery, db_user: User, if 'message is not modified' in str(e): await callback.answer('📊 Данные актуальны', show_alert=False) else: - logger.error(f'Ошибка обновления реферальной статистики: {e}') + logger.error('Ошибка обновления реферальной статистики', error=e) await callback.answer('❌ Ошибка обновления данных', show_alert=True) @@ -302,7 +302,7 @@ async def show_summary_statistics(callback: types.CallbackQuery, db_user: User, if 'message is not modified' in str(e): await callback.answer('📊 Данные актуальны', show_alert=False) else: - logger.error(f'Ошибка обновления общей статистики: {e}') + logger.error('Ошибка обновления общей статистики', error=e) await callback.answer('❌ Ошибка обновления данных', show_alert=True) diff --git a/app/handlers/admin/subscriptions.py b/app/handlers/admin/subscriptions.py index b88692f2..892e8181 100644 --- a/app/handlers/admin/subscriptions.py +++ b/app/handlers/admin/subscriptions.py @@ -1,5 +1,4 @@ -import logging - +import structlog from aiogram import Dispatcher, F, types from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -58,11 +57,11 @@ async def get_users_by_countries(db: AsyncSession) -> dict: return stats except Exception as e: - logger.error(f'Ошибка получения статистики по странам: {e}') + logger.error('Ошибка получения статистики по странам', error=e) return {} -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @admin_required @@ -319,7 +318,7 @@ async def show_countries_management(callback: types.CallbackQuery, db_user: User text += f'{country_flag} {country}: {count} пользователей\n' except Exception as e: - logger.error(f'Ошибка получения данных о странах: {e}') + logger.error('Ошибка получения данных о странах', error=e) text = f""" 🌍 Управление странами @@ -360,7 +359,7 @@ async def send_expiry_reminders(callback: types.CallbackQuery, db_user: User, db user = subscription.user # Skip email-only users (no telegram_id) if not user.telegram_id: - logger.debug(f'Пропуск email-пользователя {user.id} при отправке напоминания') + logger.debug('Пропуск email-пользователя при отправке напоминания', user_id=user.id) continue days_left = max(1, subscription.days_left) @@ -379,7 +378,7 @@ async def send_expiry_reminders(callback: types.CallbackQuery, db_user: User, db sent_count += 1 except Exception as e: - logger.error(f'Ошибка отправки напоминания пользователю {subscription.user_id}: {e}') + logger.error('Ошибка отправки напоминания пользователю', user_id=subscription.user_id, error=e) await callback.message.edit_text( f'✅ Напоминания отправлены: {sent_count} из {len(expiring_subs)}', diff --git a/app/handlers/admin/support_settings.py b/app/handlers/admin/support_settings.py index e8d3cd56..38ab2407 100644 --- a/app/handlers/admin/support_settings.py +++ b/app/handlers/admin/support_settings.py @@ -1,8 +1,8 @@ import contextlib import html -import logging import re +import structlog from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup @@ -16,7 +16,7 @@ from app.states import SupportSettingsStates from app.utils.decorators import admin_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup: diff --git a/app/handlers/admin/system_logs.py b/app/handlers/admin/system_logs.py index 305c141a..0eb24ce1 100644 --- a/app/handlers/admin/system_logs.py +++ b/app/handlers/admin/system_logs.py @@ -1,8 +1,8 @@ -import logging from datetime import datetime from html import escape from pathlib import Path +import structlog from aiogram import Dispatcher, F, types from aiogram.types import FSInputFile, InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession @@ -12,7 +12,7 @@ from app.database.models import User from app.utils.decorators import admin_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) LOG_PREVIEW_LIMIT = 2300 @@ -41,7 +41,7 @@ def _build_logs_message(log_path: Path) -> str: try: content = log_path.read_text(encoding='utf-8', errors='ignore') except Exception as error: # pragma: no cover - защита от проблем чтения - logger.error('Ошибка чтения лог-файла %s: %s', log_path, error) + logger.error('Ошибка чтения лог-файла', log_path=log_path, error=error) message = f'❌ Ошибка чтения логов\n\nНе удалось прочитать файл {log_path}.' return message @@ -134,7 +134,7 @@ async def download_system_logs( ) await callback.message.answer_document(document=document, caption=caption, parse_mode='HTML') except Exception as error: # pragma: no cover - защита от ошибок отправки - logger.error('Ошибка отправки лог-файла %s: %s', log_path, error) + logger.error('Ошибка отправки лог-файла', log_path=log_path, error=error) await callback.message.answer( '❌ Не удалось отправить лог-файл\n\nПроверьте журналы приложения или повторите попытку позже.', parse_mode='HTML', diff --git a/app/handlers/admin/tariffs.py b/app/handlers/admin/tariffs.py index 7c23dd5f..4e4db9d2 100644 --- a/app/handlers/admin/tariffs.py +++ b/app/handlers/admin/tariffs.py @@ -1,7 +1,6 @@ """Управление тарифами в админ-панели.""" -import logging - +import structlog from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext @@ -25,7 +24,7 @@ from app.states import AdminStates from app.utils.decorators import admin_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) ITEMS_PER_PAGE = 10 diff --git a/app/handlers/admin/tickets.py b/app/handlers/admin/tickets.py index b5fea0e3..03c99aa1 100644 --- a/app/handlers/admin/tickets.py +++ b/app/handlers/admin/tickets.py @@ -1,8 +1,8 @@ import html -import logging import time from datetime import datetime, timedelta +import structlog from aiogram import Bot, Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext @@ -22,7 +22,7 @@ from app.states import AdminTicketStates from app.utils.cache import RateLimitCache -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # Максимальная длина сообщения Telegram (с запасом) MAX_MESSAGE_LEN = 3500 @@ -506,7 +506,7 @@ async def handle_admin_ticket_reply(message: types.Message, state: FSMContext, d # Админ-уведомления о ответе в тикет отключены по требованию except Exception as e: - logger.error(f'Error adding admin ticket reply: {e}') + logger.error('Error adding admin ticket reply', error=e) texts = get_texts(db_user.language) await message.answer( texts.t('TICKET_REPLY_ERROR', '❌ Произошла ошибка при отправке ответа. Попробуйте позже.') @@ -533,7 +533,7 @@ async def mark_ticket_as_answered(callback: types.CallbackQuery, db_user: User, await callback.answer(texts.t('TICKET_UPDATE_ERROR', '❌ Ошибка при обновлении тикета.'), show_alert=True) except Exception as e: - logger.error(f'Error marking ticket as answered: {e}') + logger.error('Error marking ticket as answered', error=e) texts = get_texts(db_user.language) await callback.answer(texts.t('TICKET_UPDATE_ERROR', '❌ Ошибка при обновлении тикета.'), show_alert=True) @@ -550,7 +550,7 @@ async def close_all_open_admin_tickets(callback: types.CallbackQuery, db_user: U try: closed_ticket_ids = await TicketCRUD.close_all_open_tickets(db) except Exception as error: - logger.error('Error closing all open tickets: %s', error) + logger.error('Error closing all open tickets', error=error) await callback.answer(texts.t('TICKET_UPDATE_ERROR', '❌ Ошибка при обновлении тикета.'), show_alert=True) return @@ -580,7 +580,7 @@ async def close_all_open_admin_tickets(callback: types.CallbackQuery, db_user: U }, ) except Exception as audit_error: - logger.warning('Failed to add support audit for bulk close: %s', audit_error) + logger.warning('Failed to add support audit for bulk close', audit_error=audit_error) # Обновляем список тикетов await show_admin_tickets(callback, db_user, db) @@ -668,7 +668,7 @@ async def close_admin_ticket(callback: types.CallbackQuery, db_user: User, db: A await callback.answer(texts.t('TICKET_CLOSE_ERROR', '❌ Ошибка при закрытии тикета.'), show_alert=True) except Exception as e: - logger.error(f'Error closing admin ticket: {e}') + logger.error('Error closing admin ticket', error=e) texts = get_texts(db_user.language) await callback.answer(texts.t('TICKET_CLOSE_ERROR', '❌ Ошибка при закрытии тикета.'), show_alert=True) @@ -911,7 +911,7 @@ async def handle_admin_block_duration_input(message: types.Message, state: FSMCo finally: await state.clear() except Exception as e: - logger.error(f'Error setting block duration: {e}') + logger.error('Error setting block duration', error=e) texts = get_texts(db_user.language) await message.answer(texts.t('TICKET_REPLY_ERROR', '❌ Произошла ошибка. Попробуйте позже.')) @@ -1041,15 +1041,15 @@ async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: s user = getattr(ticket_with_user, 'user', None) if not user: - logger.error(f'User not found for ticket #{ticket.id}') + logger.error('User not found for ticket #', ticket_id=ticket.id) return if not getattr(user, 'telegram_id', None): logger.warning( - 'Cannot notify ticket #%s user without telegram_id (username=%s, auth_type=%s)', - ticket.id, - getattr(user, 'username', None), - getattr(user, 'auth_type', None), + 'Cannot notify ticket # user without telegram_id (username auth_type=)', + ticket_id=ticket.id, + getattr=getattr(user, 'username', None), + getattr_2=getattr(user, 'auth_type', None), ) return @@ -1096,13 +1096,13 @@ async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: s return except TelegramBadRequest as photo_error: logger.error( - 'Не удалось отправить фото-уведомление пользователю %s для тикета %s: %s', - chat_id, - ticket.id, - photo_error, + 'Не удалось отправить фото-уведомление пользователю для тикета', + chat_id=chat_id, + ticket_id=ticket.id, + photo_error=photo_error, ) except Exception as e: - logger.error(f'Не удалось отправить фото-уведомление: {e}') + logger.error('Не удалось отправить фото-уведомление', error=e) # Фоллбек: текстовое уведомление await bot.send_message( chat_id=chat_id, @@ -1110,10 +1110,10 @@ async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: s reply_markup=keyboard, ) - logger.info(f'Ticket #{ticket.id} reply notification sent to user {chat_id}') + logger.info('Ticket # reply notification sent to user', ticket_id=ticket.id, chat_id=chat_id) except Exception as e: - logger.error(f'Error notifying user about ticket reply: {e}') + logger.error('Error notifying user about ticket reply', error=e) def register_handlers(dp: Dispatcher): diff --git a/app/handlers/admin/trials.py b/app/handlers/admin/trials.py index ea5278a9..5ef8378b 100644 --- a/app/handlers/admin/trials.py +++ b/app/handlers/admin/trials.py @@ -1,5 +1,4 @@ -import logging - +import structlog from aiogram import Dispatcher, F, types from sqlalchemy.ext.asyncio import AsyncSession @@ -13,7 +12,7 @@ from app.localization.texts import get_texts from app.utils.decorators import admin_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @admin_required diff --git a/app/handlers/admin/updates.py b/app/handlers/admin/updates.py index 62637e1b..cf2fd0e1 100644 --- a/app/handlers/admin/updates.py +++ b/app/handlers/admin/updates.py @@ -1,5 +1,4 @@ -import logging - +import structlog from aiogram import Dispatcher, F, types from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession @@ -9,7 +8,7 @@ from app.services.version_service import version_service from app.utils.decorators import admin_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def get_updates_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: @@ -73,7 +72,7 @@ async def show_updates_menu(callback: types.CallbackQuery, db_user: User, db: As logger.debug('📝 Сообщение не изменено в show_updates_menu') await callback.answer() return - logger.error(f'Ошибка показа меню обновлений: {e}') + logger.error('Ошибка показа меню обновлений', error=e) await callback.answer('❌ Ошибка загрузки меню обновлений', show_alert=True) @@ -125,7 +124,7 @@ async def check_updates(callback: types.CallbackQuery, db_user: User, db: AsyncS if 'message is not modified' in str(e).lower(): logger.debug('📝 Сообщение не изменено в check_updates') return - logger.error(f'Ошибка проверки обновлений: {e}') + logger.error('Ошибка проверки обновлений', error=e) await callback.message.edit_text( f'❌ ОШИБКА ПРОВЕРКИ ОБНОВЛЕНИЙ\n\n' f'Не удалось связаться с сервером GitHub.\n' @@ -207,7 +206,7 @@ async def show_version_info(callback: types.CallbackQuery, db_user: User, db: As if 'message is not modified' in str(e).lower(): logger.debug('📝 Сообщение не изменено в show_version_info') return - logger.error(f'Ошибка получения информации о версиях: {e}') + logger.error('Ошибка получения информации о версиях', error=e) await callback.message.edit_text( f'❌ ОШИБКА ЗАГРУЗКИ\n\n' f'Не удалось получить информацию о версиях.\n\n' diff --git a/app/handlers/admin/user_messages.py b/app/handlers/admin/user_messages.py index 6fb89e39..7ae5f4cc 100644 --- a/app/handlers/admin/user_messages.py +++ b/app/handlers/admin/user_messages.py @@ -1,5 +1,4 @@ -import logging - +import structlog from aiogram import Dispatcher, F, types from aiogram.filters import StateFilter from aiogram.fsm.context import FSMContext @@ -25,7 +24,7 @@ from app.utils.validators import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class UserMessageStates(StatesGroup): @@ -140,7 +139,7 @@ async def process_new_message_text(message: types.Message, state: FSMContext, db ) except Exception as e: - logger.error(f'Ошибка создания сообщения: {e}') + logger.error('Ошибка создания сообщения', error=e) await state.clear() await message.answer( '❌ Произошла ошибка при создании сообщения. Попробуйте еще раз.', @@ -405,7 +404,7 @@ async def process_edit_message_text(message: types.Message, state: FSMContext, d ) except Exception as e: - logger.error(f'Ошибка обновления сообщения: {e}') + logger.error('Ошибка обновления сообщения', error=e) await state.clear() await message.answer( '❌ Произошла ошибка при обновлении сообщения.', reply_markup=get_user_messages_keyboard(db_user.language) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index cfc29f0d..ead1e744 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -1,10 +1,10 @@ -import logging import re from dataclasses import dataclass from datetime import datetime, timedelta from enum import Enum from typing import Any +import structlog from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.fsm.context import FSMContext @@ -53,7 +53,7 @@ from app.utils.subscription_utils import ( from app.utils.user_utils import get_effective_referral_commission_percent -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # ============================================================================= @@ -645,7 +645,7 @@ async def handle_users_list_pagination_fixed( page = int(callback_parts[-1]) await show_users_list(callback, db_user, db, state, page) except (ValueError, IndexError) as e: - logger.error(f'Ошибка парсинга номера страницы: {e}') + logger.error('Ошибка парсинга номера страницы', error=e) await show_users_list(callback, db_user, db, state, 1) @@ -659,7 +659,7 @@ async def handle_users_balance_list_pagination( page = int(callback_parts[-1]) await show_users_list_by_balance(callback, db_user, db, state, page) except (ValueError, IndexError) as e: - logger.error(f'Ошибка парсинга номера страницы: {e}') + logger.error('Ошибка парсинга номера страницы', error=e) await show_users_list_by_balance(callback, db_user, db, state, 1) @@ -672,7 +672,7 @@ async def handle_users_ready_to_renew_pagination( page = int(callback.data.split('_')[-1]) await show_users_ready_to_renew(callback, db_user, db, state, page) except (ValueError, IndexError) as e: - logger.error(f'Ошибка парсинга номера страницы: {e}') + logger.error('Ошибка парсинга номера страницы', error=e) await show_users_ready_to_renew(callback, db_user, db, state, 1) @@ -685,7 +685,7 @@ async def handle_potential_customers_pagination( page = int(callback.data.split('_')[-1]) await show_potential_customers(callback, db_user, db, state, page) except (ValueError, IndexError) as e: - logger.error(f'Ошибка парсинга номера страницы: {e}') + logger.error('Ошибка парсинга номера страницы', error=e) await show_potential_customers(callback, db_user, db, state, 1) @@ -699,7 +699,7 @@ async def handle_users_campaign_list_pagination( page = int(callback_parts[-1]) await show_users_list_by_campaign(callback, db_user, db, state, page) except (ValueError, IndexError) as e: - logger.error(f'Ошибка парсинга номера страницы: {e}') + logger.error('Ошибка парсинга номера страницы', error=e) await show_users_list_by_campaign(callback, db_user, db, state, 1) @@ -873,7 +873,7 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db: else: text += f'• {squad_uuid[:8]}... (неизвестный)\n' except Exception as e: - logger.error(f'Ошибка получения сервера {squad_uuid}: {e}') + logger.error('Ошибка получения сервера', squad_uuid=squad_uuid, error=e) text += f'• {squad_uuid[:8]}... (ошибка загрузки)\n' else: text += '\nПодключенные серверы: отсутствуют\n' @@ -1565,23 +1565,16 @@ async def _update_referral_commission_percent( effective = get_effective_referral_commission_percent(user) logger.info( - 'Админ %s обновил реферальный процент пользователя %s: %s', - admin_id, - user_id, - percent, + 'Админ обновил реферальный процент пользователя', admin_id=admin_id, user_id=user_id, percent=percent ) return True, effective except Exception as e: - logger.error( - 'Ошибка обновления реферального процента пользователя %s: %s', - user_id, - e, - ) + logger.error('Ошибка обновления реферального процента пользователя', user_id=user_id, e=e) try: await db.rollback() except Exception as rollback_error: - logger.error('Ошибка отката транзакции: %s', rollback_error) + logger.error('Ошибка отката транзакции', rollback_error=rollback_error) return False, None @@ -2249,7 +2242,7 @@ async def process_send_user_message( reply_markup=confirmation_keyboard, ) except TelegramBadRequest as err: - logger.error('Ошибка отправки сообщения пользователю %s: %s', target_user.telegram_id, err) + logger.error('Ошибка отправки сообщения пользователю', telegram_id=target_user.telegram_id, err=err) await message.answer( texts.t( 'ADMIN_USER_SEND_MESSAGE_BAD_REQUEST', @@ -2260,7 +2253,7 @@ async def process_send_user_message( await state.clear() return except Exception as err: - logger.error('Неожиданная ошибка отправки сообщения пользователю %s: %s', target_user.telegram_id, err) + logger.error('Неожиданная ошибка отправки сообщения пользователю', telegram_id=target_user.telegram_id, err=err) await message.answer( texts.t('ADMIN_USER_SEND_MESSAGE_ERROR', '❌ Не удалось отправить сообщение. Попробуйте позже.'), reply_markup=confirmation_keyboard, @@ -3444,7 +3437,7 @@ async def _show_servers_for_user(callback: types.CallbackQuery, user_id: int, db await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) except Exception as e: - logger.error(f'Ошибка показа серверов: {e}') + logger.error('Ошибка показа серверов', error=e) @admin_required @@ -3491,16 +3484,22 @@ async def toggle_user_server(callback: types.CallbackQuery, db_user: User, db: A full_name=user.full_name, username=user.username, telegram_id=user.telegram_id ), ) - logger.info(f'✅ Обновлены серверы в RemnaWave для пользователя {user.telegram_id}') + logger.info('✅ Обновлены серверы в RemnaWave для пользователя', telegram_id=user.telegram_id) except Exception as rw_error: - logger.error(f'❌ Ошибка обновления RemnaWave: {rw_error}') + logger.error('❌ Ошибка обновления RemnaWave', rw_error=rw_error) - logger.info(f'Админ {db_user.id}: сервер {server.display_name} {action_text} для пользователя {user_id}') + logger.info( + 'Админ сервер для пользователя', + db_user_id=db_user.id, + display_name=server.display_name, + action_text=action_text, + user_id=user_id, + ) await refresh_server_selection_screen(callback, user_id, db_user, db) except Exception as e: - logger.error(f'Ошибка переключения сервера: {e}') + logger.error('Ошибка переключения сервера', error=e) await callback.answer('❌ Ошибка изменения сервера', show_alert=True) @@ -3554,7 +3553,7 @@ async def refresh_server_selection_screen(callback: types.CallbackQuery, user_id await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) except Exception as e: - logger.error(f'Ошибка обновления экрана серверов: {e}') + logger.error('Ошибка обновления экрана серверов', error=e) @admin_required @@ -3628,7 +3627,9 @@ async def set_user_devices_button(callback: types.CallbackQuery, db_user: User, await callback.answer() - logger.info(f'Админ {db_user.telegram_id} {action_text} модем для пользователя {user_id}') + logger.info( + 'Админ модем для пользователя', telegram_id=db_user.telegram_id, action_text=action_text, user_id=user_id + ) await callback.answer() @@ -3842,7 +3843,7 @@ async def reset_user_devices(callback: types.CallbackQuery, db_user: User, db: A ] ), ) - logger.info(f'Админ {db_user.id} сбросил устройства пользователя {user_id}') + logger.info('Админ сбросил устройства пользователя', db_user_id=db_user.id, user_id=user_id) else: await callback.message.edit_text( '❌ Ошибка сброса устройств', @@ -3858,7 +3859,7 @@ async def reset_user_devices(callback: types.CallbackQuery, db_user: User, db: A ) except Exception as e: - logger.error(f'Ошибка сброса устройств: {e}') + logger.error('Ошибка сброса устройств', error=e) await callback.answer('❌ Ошибка сброса устройств', show_alert=True) @@ -3866,7 +3867,7 @@ async def _update_user_devices(db: AsyncSession, user_id: int, devices: int, adm try: user = await get_user_by_id(db, user_id) if not user or not user.subscription: - logger.error(f'Пользователь {user_id} или подписка не найдены') + logger.error('Пользователь или подписка не найдены', user_id=user_id) return False subscription = user.subscription @@ -3887,15 +3888,21 @@ async def _update_user_devices(db: AsyncSession, user_id: int, devices: int, adm full_name=user.full_name, username=user.username, telegram_id=user.telegram_id ), ) - logger.info(f'✅ Обновлен лимит устройств в RemnaWave для пользователя {user.telegram_id}') + logger.info('✅ Обновлен лимит устройств в RemnaWave для пользователя', telegram_id=user.telegram_id) except Exception as rw_error: - logger.error(f'❌ Ошибка обновления лимита устройств в RemnaWave: {rw_error}') + logger.error('❌ Ошибка обновления лимита устройств в RemnaWave', rw_error=rw_error) - logger.info(f'Админ {admin_id} изменил лимит устройств пользователя {user_id}: {old_devices} -> {devices}') + logger.info( + 'Админ изменил лимит устройств пользователя', + admin_id=admin_id, + user_id=user_id, + old_devices=old_devices, + devices=devices, + ) return True except Exception as e: - logger.error(f'Ошибка обновления лимита устройств: {e}') + logger.error('Ошибка обновления лимита устройств', error=e) await db.rollback() return False @@ -3904,7 +3911,7 @@ async def _update_user_traffic(db: AsyncSession, user_id: int, traffic_gb: int, try: user = await get_user_by_id(db, user_id) if not user or not user.subscription: - logger.error(f'Пользователь {user_id} или подписка не найдены') + logger.error('Пользователь или подписка не найдены', user_id=user_id) return False subscription = user.subscription @@ -3928,19 +3935,23 @@ async def _update_user_traffic(db: AsyncSession, user_id: int, traffic_gb: int, full_name=user.full_name, username=user.username, telegram_id=user.telegram_id ), ) - logger.info(f'✅ Обновлен лимит трафика в RemnaWave для пользователя {user.telegram_id}') + logger.info('✅ Обновлен лимит трафика в RemnaWave для пользователя', telegram_id=user.telegram_id) except Exception as rw_error: - logger.error(f'❌ Ошибка обновления лимита трафика в RemnaWave: {rw_error}') + logger.error('❌ Ошибка обновления лимита трафика в RemnaWave', rw_error=rw_error) traffic_text_old = 'безлимитный' if old_traffic == 0 else f'{old_traffic} ГБ' traffic_text_new = 'безлимитный' if traffic_gb == 0 else f'{traffic_gb} ГБ' logger.info( - f'Админ {admin_id} изменил лимит трафика пользователя {user_id}: {traffic_text_old} -> {traffic_text_new}' + 'Админ изменил лимит трафика пользователя', + admin_id=admin_id, + user_id=user_id, + traffic_text_old=traffic_text_old, + traffic_text_new=traffic_text_new, ) return True except Exception as e: - logger.error(f'Ошибка обновления лимита трафика: {e}') + logger.error('Ошибка обновления лимита трафика', error=e) await db.rollback() return False @@ -3952,7 +3963,7 @@ async def _extend_subscription_by_days(db: AsyncSession, user_id: int, days: int subscription = await get_subscription_by_user_id(db, user_id) if not subscription: - logger.error(f'Подписка не найдена для пользователя {user_id}') + logger.error('Подписка не найдена для пользователя', user_id=user_id) return False await extend_subscription(db, subscription, days) @@ -3961,13 +3972,15 @@ async def _extend_subscription_by_days(db: AsyncSession, user_id: int, days: int await subscription_service.update_remnawave_user(db, subscription) if days > 0: - logger.info(f'Админ {admin_id} продлил подписку пользователя {user_id} на {days} дней') + logger.info('Админ продлил подписку пользователя на дней', admin_id=admin_id, user_id=user_id, days=days) else: - logger.info(f'Админ {admin_id} сократил подписку пользователя {user_id} на {abs(days)} дней') + logger.info( + 'Админ сократил подписку пользователя на дней', admin_id=admin_id, user_id=user_id, value=abs(days) + ) return True except Exception as e: - logger.error(f'Ошибка продления подписки: {e}') + logger.error('Ошибка продления подписки', error=e) return False @@ -3978,7 +3991,7 @@ async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, adm subscription = await get_subscription_by_user_id(db, user_id) if not subscription: - logger.error(f'Подписка не найдена для пользователя {user_id}') + logger.error('Подписка не найдена для пользователя', user_id=user_id) return False if gb == 0: @@ -3991,11 +4004,11 @@ async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, adm await subscription_service.update_remnawave_user(db, subscription) traffic_text = 'безлимитный' if gb == 0 else f'{gb} ГБ' - logger.info(f'Админ {admin_id} добавил трафик {traffic_text} пользователю {user_id}') + logger.info('Админ добавил трафик пользователю', admin_id=admin_id, traffic_text=traffic_text, user_id=user_id) return True except Exception as e: - logger.error(f'Ошибка добавления трафика: {e}') + logger.error('Ошибка добавления трафика', error=e) return False @@ -4006,7 +4019,7 @@ async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id subscription = await get_subscription_by_user_id(db, user_id) if not subscription: - logger.error(f'Подписка не найдена для пользователя {user_id}') + logger.error('Подписка не найдена для пользователя', user_id=user_id) return False await deactivate_subscription(db, subscription) @@ -4016,11 +4029,11 @@ async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id subscription_service = SubscriptionService() await subscription_service.disable_remnawave_user(user.remnawave_uuid) - logger.info(f'Админ {admin_id} деактивировал подписку пользователя {user_id}') + logger.info('Админ деактивировал подписку пользователя', admin_id=admin_id, user_id=user_id) return True except Exception as e: - logger.error(f'Ошибка деактивации подписки: {e}') + logger.error('Ошибка деактивации подписки', error=e) return False @@ -4034,7 +4047,7 @@ async def _activate_user_subscription(db: AsyncSession, user_id: int, admin_id: subscription = await get_subscription_by_user_id(db, user_id) if not subscription: - logger.error(f'Подписка не найдена для пользователя {user_id}') + logger.error('Подписка не найдена для пользователя', user_id=user_id) return False subscription.status = SubscriptionStatus.ACTIVE.value @@ -4047,11 +4060,11 @@ async def _activate_user_subscription(db: AsyncSession, user_id: int, admin_id: subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - logger.info(f'Админ {admin_id} активировал подписку пользователя {user_id}') + logger.info('Админ активировал подписку пользователя', admin_id=admin_id, user_id=user_id) return True except Exception as e: - logger.error(f'Ошибка активации подписки: {e}') + logger.error('Ошибка активации подписки', error=e) return False @@ -4062,7 +4075,7 @@ async def _grant_trial_subscription(db: AsyncSession, user_id: int, admin_id: in existing_subscription = await get_subscription_by_user_id(db, user_id) if existing_subscription: - logger.error(f'У пользователя {user_id} уже есть подписка') + logger.error('У пользователя уже есть подписка', user_id=user_id) return False forced_devices = None @@ -4078,11 +4091,11 @@ async def _grant_trial_subscription(db: AsyncSession, user_id: int, admin_id: in subscription_service = SubscriptionService() await subscription_service.create_remnawave_user(db, subscription) - logger.info(f'Админ {admin_id} выдал триальную подписку пользователю {user_id}') + logger.info('Админ выдал триальную подписку пользователю', admin_id=admin_id, user_id=user_id) return True except Exception as e: - logger.error(f'Ошибка выдачи триальной подписки: {e}') + logger.error('Ошибка выдачи триальной подписки', error=e) return False @@ -4094,7 +4107,7 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad existing_subscription = await get_subscription_by_user_id(db, user_id) if existing_subscription: - logger.error(f'У пользователя {user_id} уже есть подписка') + logger.error('У пользователя уже есть подписка', user_id=user_id) return False trial_squads: list[str] = [] @@ -4106,11 +4119,7 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad if trial_uuid: trial_squads = [trial_uuid] except Exception as error: - logger.error( - 'Не удалось подобрать сквад при выдаче подписки админом %s: %s', - admin_id, - error, - ) + logger.error('Не удалось подобрать сквад при выдаче подписки админом', admin_id=admin_id, error=error) forced_devices = None if not settings.is_devices_selection_enabled(): @@ -4133,11 +4142,11 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad subscription_service = SubscriptionService() await subscription_service.create_remnawave_user(db, subscription) - logger.info(f'Админ {admin_id} выдал платную подписку на {days} дней пользователю {user_id}') + logger.info('Админ выдал платную подписку на дней пользователю', admin_id=admin_id, days=days, user_id=user_id) return True except Exception as e: - logger.error(f'Ошибка выдачи платной подписки: {e}') + logger.error('Ошибка выдачи платной подписки', error=e) return False @@ -4160,14 +4169,14 @@ async def _calculate_subscription_period_price( server_ids = await get_server_ids_by_uuids(db, connected_squads) if len(server_ids) != len(connected_squads): logger.warning( - 'Не удалось сопоставить все сервера подписки пользователя %s для расчёта цены', - target_user.telegram_id, + 'Не удалось сопоставить все сервера подписки пользователя для расчёта цены', + telegram_id=target_user.telegram_id, ) except Exception as e: logger.error( - 'Не удалось получить идентификаторы серверов для расчёта цены подписки пользователя %s: %s', - target_user.telegram_id, - e, + 'Не удалось получить идентификаторы серверов для расчёта цены подписки пользователя', + telegram_id=target_user.telegram_id, + e=e, ) server_ids = [] traffic_limit_gb = subscription.traffic_limit_gb @@ -4282,10 +4291,10 @@ async def admin_buy_subscription(callback: types.CallbackQuery, db_user: User, d ) except Exception as e: logger.error( - 'Ошибка расчёта стоимости подписки для пользователя %s и периода %s дней: %s', - target_user.telegram_id, - period, - e, + 'Ошибка расчёта стоимости подписки для пользователя и периода дней', + telegram_id=target_user.telegram_id, + period=period, + e=e, ) continue @@ -4363,19 +4372,19 @@ async def admin_buy_subscription_confirm(callback: types.CallbackQuery, db_user: ) except Exception as e: logger.error( - 'Ошибка расчёта стоимости подписки при подтверждении админом для пользователя %s: %s', - target_user.telegram_id, - e, + 'Ошибка расчёта стоимости подписки при подтверждении админом для пользователя', + telegram_id=target_user.telegram_id, + e=e, ) await callback.answer('❌ Не удалось рассчитать стоимость подписки', show_alert=True) return if price_kopeks_from_callback is not None and price_kopeks_from_callback != price_kopeks: logger.info( - 'Стоимость подписки для пользователя %s изменилась с %s до %s при подтверждении', - target_user.telegram_id, - price_kopeks_from_callback, - price_kopeks, + 'Стоимость подписки для пользователя изменилась с до при подтверждении', + telegram_id=target_user.telegram_id, + price_kopeks_from_callback=price_kopeks_from_callback, + price_kopeks=price_kopeks, ) if target_user.balance_kopeks < price_kopeks: @@ -4467,19 +4476,19 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user: ) except Exception as e: logger.error( - 'Ошибка расчёта стоимости подписки при списании средств админом для пользователя %s: %s', - target_user.telegram_id, - e, + 'Ошибка расчёта стоимости подписки при списании средств админом для пользователя', + telegram_id=target_user.telegram_id, + e=e, ) await callback.answer('❌ Не удалось рассчитать стоимость подписки', show_alert=True) return if price_kopeks_from_callback is not None and price_kopeks_from_callback != price_kopeks: logger.info( - 'Стоимость подписки для пользователя %s изменилась с %s до %s перед списанием', - target_user.telegram_id, - price_kopeks_from_callback, - price_kopeks, + 'Стоимость подписки для пользователя изменилась с до перед списанием', + telegram_id=target_user.telegram_id, + price_kopeks_from_callback=price_kopeks_from_callback, + price_kopeks=price_kopeks, ) if target_user.balance_kopeks < price_kopeks: @@ -4506,9 +4515,9 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user: if remaining_trial_delta.total_seconds() > 0: bonus_period = remaining_trial_delta logger.info( - 'Админ продлевает подписку: добавляем оставшееся время триала (%s) пользователю %s', - bonus_period, - target_user.telegram_id, + 'Админ продлевает подписку: добавляем оставшееся время триала пользователю', + bonus_period=bonus_period, + telegram_id=target_user.telegram_id, ) extension_base_date = current_time @@ -4613,11 +4622,11 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user: await db.commit() if remnawave_user: - logger.info(f'Пользователь {target_user.telegram_id} успешно обновлен в RemnaWave') + logger.info('Пользователь успешно обновлен в RemnaWave', telegram_id=target_user.telegram_id) else: - logger.error(f'Ошибка обновления пользователя {target_user.telegram_id} в RemnaWave') + logger.error('Ошибка обновления пользователя в RemnaWave', telegram_id=target_user.telegram_id) except Exception as e: - logger.error(f'Ошибка работы с RemnaWave для пользователя {target_user.telegram_id}: {e}') + logger.error('Ошибка работы с RemnaWave для пользователя', telegram_id=target_user.telegram_id, error=e) message = f'✅ Подписка пользователя продлена на {period_days} дней' else: @@ -4658,12 +4667,12 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user: ) except Exception as e: user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}' - logger.error(f'Ошибка отправки уведомления пользователю {user_id_display}: {e}') + logger.error('Ошибка отправки уведомления пользователю', user_id_display=user_id_display, error=e) await callback.answer() except Exception as e: - logger.error(f'Ошибка покупки подписки администратором: {e}') + logger.error('Ошибка покупки подписки администратором', error=e) await callback.answer('❌ Ошибка при покупке подписки', show_alert=True) await db.rollback() @@ -4977,7 +4986,7 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User, reset_reason='покупка тарифа (администратор)', ) except Exception as e: - logger.error(f'Ошибка обновления Remnawave: {e}') + logger.error('Ошибка обновления Remnawave', error=e) # Создаем транзакцию await create_transaction( @@ -5032,12 +5041,12 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User, parse_mode='HTML', ) except Exception as e: - logger.error(f'Ошибка отправки уведомления пользователю: {e}') + logger.error('Ошибка отправки уведомления пользователю', error=e) await callback.answer('✅ Тариф куплен!', show_alert=True) except Exception as e: - logger.error(f'Ошибка покупки тарифа администратором: {e}', exc_info=True) + logger.error('Ошибка покупки тарифа администратором', error=e, exc_info=True) await callback.answer('❌ Ошибка при покупке тарифа', show_alert=True) await db.rollback() @@ -5081,13 +5090,13 @@ async def _change_subscription_type(db: AsyncSession, user_id: int, new_type: st subscription = await get_subscription_by_user_id(db, user_id) if not subscription: - logger.error(f'Подписка не найдена для пользователя {user_id}') + logger.error('Подписка не найдена для пользователя', user_id=user_id) return False new_is_trial = new_type == 'trial' if subscription.is_trial == new_is_trial: - logger.info(f'Тип подписки уже установлен корректно для пользователя {user_id}') + logger.info('Тип подписки уже установлен корректно для пользователя', user_id=user_id) return True old_type = 'триальной' if subscription.is_trial else 'платной' @@ -5106,11 +5115,17 @@ async def _change_subscription_type(db: AsyncSession, user_id: int, new_type: st subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - logger.info(f'Админ {admin_id} изменил тип подписки пользователя {user_id}: {old_type} -> {new_type_text}') + logger.info( + 'Админ изменил тип подписки пользователя', + admin_id=admin_id, + user_id=user_id, + old_type=old_type, + new_type_text=new_type_text, + ) return True except Exception as e: - logger.error(f'Ошибка изменения типа подписки: {e}') + logger.error('Ошибка изменения типа подписки', error=e) await db.rollback() return False @@ -5310,7 +5325,12 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us await subscription_service.update_remnawave_user(db, subscription) logger.info( - f'Админ {db_user.id} изменил тариф пользователя {user_id}: {old_tariff_id} -> {tariff_id} ({tariff.name})' + 'Админ изменил тариф пользователя', + db_user_id=db_user.id, + user_id=user_id, + old_tariff_id=old_tariff_id, + tariff_id=tariff_id, + tariff_name=tariff.name, ) await callback.message.edit_text( @@ -5331,7 +5351,7 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us ) except Exception as e: - logger.error(f'Ошибка смены тарифа: {e}') + logger.error('Ошибка смены тарифа', error=e) await db.rollback() await callback.message.edit_text( diff --git a/app/handlers/admin/welcome_text.py b/app/handlers/admin/welcome_text.py index 2d371716..8c9c9f79 100644 --- a/app/handlers/admin/welcome_text.py +++ b/app/handlers/admin/welcome_text.py @@ -1,6 +1,6 @@ -import logging import re +import structlog from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -18,7 +18,7 @@ from app.states import AdminStates from app.utils.decorators import admin_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def validate_html_tags(text: str) -> tuple[bool, str]: diff --git a/app/handlers/balance/cloudpayments.py b/app/handlers/balance/cloudpayments.py index d49b06a1..e24c93cf 100644 --- a/app/handlers/balance/cloudpayments.py +++ b/app/handlers/balance/cloudpayments.py @@ -1,7 +1,6 @@ """Handler for CloudPayments balance top-up.""" -import logging - +import structlog from aiogram import types from aiogram.fsm.context import FSMContext from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup @@ -16,7 +15,7 @@ from app.states import BalanceStates from app.utils.decorators import error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def _create_cloudpayments_payment_and_respond( @@ -118,11 +117,7 @@ async def _create_cloudpayments_payment_and_respond( parse_mode='HTML', ) - logger.info( - 'CloudPayments payment created: user=%s, amount=%s₽', - db_user.telegram_id, - amount_rub, - ) + logger.info('CloudPayments payment created: user amount=₽', telegram_id=db_user.telegram_id, amount_rub=amount_rub) @error_handler @@ -375,11 +370,7 @@ async def process_cloudpayments_amount( parse_mode='HTML', ) - logger.info( - 'CloudPayments payment created: user=%s, amount=%s₽', - db_user.telegram_id, - amount_rub, - ) + logger.info('CloudPayments payment created: user amount=₽', telegram_id=db_user.telegram_id, amount_rub=amount_rub) @error_handler @@ -498,7 +489,5 @@ async def handle_cloudpayments_quick_amount( ) logger.info( - 'CloudPayments payment created (quick): user=%s, amount=%s₽', - db_user.telegram_id, - amount_rub, + 'CloudPayments payment created (quick): user amount=₽', telegram_id=db_user.telegram_id, amount_rub=amount_rub ) diff --git a/app/handlers/balance/cryptobot.py b/app/handlers/balance/cryptobot.py index 6e7b4dab..3d9208db 100644 --- a/app/handlers/balance/cryptobot.py +++ b/app/handlers/balance/cryptobot.py @@ -1,5 +1,4 @@ -import logging - +import structlog from aiogram import types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -13,7 +12,7 @@ from app.states import BalanceStates from app.utils.decorators import error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @error_handler @@ -47,7 +46,7 @@ async def start_cryptobot_payment(callback: types.CallbackQuery, db_user: User, current_rate = await currency_converter.get_usd_to_rub_rate() rate_text = f'💱 Текущий курс: 1 USD = {current_rate:.2f} ₽' except Exception as e: - logger.warning(f'Не удалось получить курс валют: {e}') + logger.warning('Не удалось получить курс валют', error=e) current_rate = 95.0 rate_text = f'💱 Курс: 1 USD ≈ {current_rate:.0f} ₽' @@ -208,19 +207,13 @@ async def process_cryptobot_payment_amount( try: await message.delete() except Exception as delete_error: # pragma: no cover - depends on bot rights - logger.warning( - 'Не удалось удалить сообщение с суммой CryptoBot: %s', - delete_error, - ) + logger.warning('Не удалось удалить сообщение с суммой CryptoBot', delete_error=delete_error) if prompt_message_id: try: await message.bot.delete_message(prompt_chat_id, prompt_message_id) except Exception as delete_error: # pragma: no cover - diagnostics - logger.warning( - 'Не удалось удалить сообщение с запросом суммы CryptoBot: %s', - delete_error, - ) + logger.warning('Не удалось удалить сообщение с запросом суммы CryptoBot', delete_error=delete_error) invoice_message = await message.answer( f'🪙 Оплата криптовалютой\n\n' @@ -249,12 +242,15 @@ async def process_cryptobot_payment_amount( await state.clear() logger.info( - f'Создан CryptoBot платеж для пользователя {db_user.telegram_id}: ' - f'{amount_rubles:.0f} ₽ ({amount_usd:.2f} USD), ID: {payment_result["invoice_id"]}' + 'Создан CryptoBot платеж для пользователя ₽ ( USD), ID', + telegram_id=db_user.telegram_id, + amount_rubles=round(amount_rubles, 0), + amount_usd=round(amount_usd, 2), + payment_result=payment_result['invoice_id'], ) except Exception as e: - logger.error(f'Ошибка создания CryptoBot платежа: {e}') + logger.error('Ошибка создания CryptoBot платежа', error=e) await message.answer('❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.') await state.clear() @@ -297,5 +293,5 @@ async def check_cryptobot_payment_status(callback: types.CallbackQuery, db: Asyn await callback.answer(message_text, show_alert=True) except Exception as e: - logger.error(f'Ошибка проверки статуса CryptoBot платежа: {e}') + logger.error('Ошибка проверки статуса CryptoBot платежа', error=e) await callback.answer('❌ Ошибка проверки статуса', show_alert=True) diff --git a/app/handlers/balance/freekassa.py b/app/handlers/balance/freekassa.py index 5506f061..40180d0b 100644 --- a/app/handlers/balance/freekassa.py +++ b/app/handlers/balance/freekassa.py @@ -1,7 +1,6 @@ """Handler for Freekassa balance top-up.""" -import logging - +import structlog from aiogram import types from aiogram.fsm.context import FSMContext from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup @@ -16,7 +15,7 @@ from app.states import BalanceStates from app.utils.decorators import error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def _create_freekassa_payment_and_respond( @@ -119,11 +118,7 @@ async def _create_freekassa_payment_and_respond( parse_mode='HTML', ) - logger.info( - 'Freekassa payment created: user=%s, amount=%s₽', - db_user.telegram_id, - amount_rub, - ) + logger.info('Freekassa payment created: user amount=₽', telegram_id=db_user.telegram_id, amount_rub=amount_rub) @error_handler diff --git a/app/handlers/balance/heleket.py b/app/handlers/balance/heleket.py index 7cf5512f..d716580b 100644 --- a/app/handlers/balance/heleket.py +++ b/app/handlers/balance/heleket.py @@ -1,6 +1,6 @@ -import logging from datetime import datetime +import structlog from aiogram import types from aiogram.fsm.context import FSMContext from sqlalchemy import update @@ -15,7 +15,7 @@ from app.states import BalanceStates from app.utils.decorators import error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @error_handler @@ -228,16 +228,13 @@ async def process_heleket_payment_amount( try: await message.delete() except Exception as delete_error: # pragma: no cover - depends on bot rights - logger.warning('Не удалось удалить сообщение с суммой Heleket: %s', delete_error) + logger.warning('Не удалось удалить сообщение с суммой Heleket', delete_error=delete_error) if prompt_message_id: try: await message.bot.delete_message(prompt_chat_id, prompt_message_id) except Exception as delete_error: # pragma: no cover - diagnostic - logger.warning( - 'Не удалось удалить сообщение с запросом суммы Heleket: %s', - delete_error, - ) + logger.warning('Не удалось удалить сообщение с запросом суммы Heleket', delete_error=delete_error) invoice_message = await message.answer('\n'.join(details), parse_mode='HTML', reply_markup=keyboard) @@ -258,7 +255,7 @@ async def process_heleket_payment_amount( ) await db.commit() except Exception as error: # pragma: no cover - diagnostics - logger.warning('Не удалось сохранить сообщение Heleket: %s', error) + logger.warning('Не удалось сохранить сообщение Heleket', error=error) await state.update_data( heleket_invoice_message_id=invoice_message.message_id, diff --git a/app/handlers/balance/kassa_ai.py b/app/handlers/balance/kassa_ai.py index c75b16ef..55c449af 100644 --- a/app/handlers/balance/kassa_ai.py +++ b/app/handlers/balance/kassa_ai.py @@ -1,7 +1,6 @@ """Handler for KassaAI balance top-up.""" -import logging - +import structlog from aiogram import types from aiogram.fsm.context import FSMContext from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup @@ -16,7 +15,7 @@ from app.states import BalanceStates from app.utils.decorators import error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def _create_kassa_ai_payment_and_respond( @@ -119,11 +118,7 @@ async def _create_kassa_ai_payment_and_respond( parse_mode='HTML', ) - logger.info( - 'KassaAI payment created: user=%s, amount=%s₽', - db_user.telegram_id, - amount_rub, - ) + logger.info('KassaAI payment created: user amount=₽', telegram_id=db_user.telegram_id, amount_rub=amount_rub) @error_handler diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index 7cb6fc00..71e6e41a 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -1,5 +1,4 @@ -import logging - +import structlog from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext @@ -21,7 +20,7 @@ from app.utils.decorators import error_handler from app.utils.price_display import calculate_user_price -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) TRANSACTIONS_PER_PAGE = 10 @@ -230,10 +229,7 @@ async def show_balance_menu(callback: types.CallbackQuery, db_user: User, db: As else: await callback.message.answer(balance_text, reply_markup=reply_markup) except TelegramBadRequest as error: - logger.warning( - 'Failed to edit balance message, sending a new one instead: %s', - error, - ) + logger.warning('Failed to edit balance message, sending a new one instead', error=error) await callback.message.answer(balance_text, reply_markup=reply_markup) await callback.answer() @@ -435,7 +431,9 @@ async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db: tariff_info = f'\n\n📋 Ваш текущий тариф:\n{current_tariff_desc}\n{estimated_price_info}' except Exception as e: - logger.warning(f'Не удалось рассчитать стоимость текущей подписки для пользователя {db_user.id}: {e}') + logger.warning( + 'Не удалось рассчитать стоимость текущей подписки для пользователя', db_user_id=db_user.id, error=e + ) tariff_info = '' full_text = payment_text + tariff_info @@ -489,7 +487,7 @@ async def handle_successful_topup_with_cart(user_id: int, amount_kopeks: int, bo # Email-only users don't have telegram_id - skip Telegram notification if not user.telegram_id: - logger.info(f'Skipping cart notification for email-only user {user_id}') + logger.info('Skipping cart notification for email-only user', user_id=user_id) return storage = dp.storage @@ -532,7 +530,7 @@ async def handle_successful_topup_with_cart(user_id: int, amount_kopeks: int, bo ) except Exception as e: - logger.error(f'Ошибка обработки успешного пополнения с корзиной: {e}') + logger.error('Ошибка обработки успешного пополнения с корзиной', error=e) @error_handler @@ -674,7 +672,7 @@ async def handle_sbp_payment(callback: types.CallbackQuery, db: AsyncSession): await callback.answer('Информация об оплате отправлена', show_alert=True) except Exception as e: - logger.error(f'Ошибка обработки embedded платежа СБП: {e}') + logger.error('Ошибка обработки embedded платежа СБП', error=e) await callback.answer('❌ Ошибка обработки платежа', show_alert=True) @@ -705,7 +703,7 @@ async def handle_quick_amount_selection(callback: types.CallbackQuery, db_user: except ValueError: await callback.answer('❌ Ошибка обработки суммы', show_alert=True) except Exception as e: - logger.error(f'Ошибка обработки быстрого выбора суммы: {e}') + logger.error('Ошибка обработки быстрого выбора суммы', error=e) await callback.answer('❌ Ошибка обработки запроса', show_alert=True) @@ -755,7 +753,7 @@ async def handle_topup_amount_callback( await callback.answer() except Exception as error: - logger.error(f'Ошибка быстрого пополнения: {error}') + logger.error('Ошибка быстрого пополнения', error=error) await callback.answer('❌ Ошибка обработки запроса', show_alert=True) diff --git a/app/handlers/balance/mulenpay.py b/app/handlers/balance/mulenpay.py index c6470bdb..34d470b8 100644 --- a/app/handlers/balance/mulenpay.py +++ b/app/handlers/balance/mulenpay.py @@ -1,5 +1,4 @@ -import logging - +import structlog from aiogram import types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -13,7 +12,7 @@ from app.states import BalanceStates from app.utils.decorators import error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @error_handler @@ -144,16 +143,13 @@ async def process_mulenpay_payment_amount( try: await message.delete() except Exception as delete_error: # pragma: no cover - depends on bot permissions - logger.warning('Не удалось удалить сообщение с суммой MulenPay: %s', delete_error) + logger.warning('Не удалось удалить сообщение с суммой MulenPay', delete_error=delete_error) if prompt_message_id: try: await message.bot.delete_message(prompt_chat_id, prompt_message_id) except Exception as delete_error: # pragma: no cover - diagnostic - logger.warning( - 'Не удалось удалить сообщение с запросом суммы MulenPay: %s', - delete_error, - ) + logger.warning('Не удалось удалить сообщение с запросом суммы MulenPay', delete_error=delete_error) try: payment_service = PaymentService(message.bot) @@ -247,7 +243,7 @@ async def process_mulenpay_payment_amount( metadata=payment_metadata, ) except Exception as error: # pragma: no cover - diagnostic logging only - logger.warning('Не удалось сохранить данные сообщения MulenPay: %s', error) + logger.warning('Не удалось сохранить данные сообщения MulenPay', error=error) await state.update_data( mulenpay_invoice_message_id=invoice_message.message_id, @@ -257,15 +253,15 @@ async def process_mulenpay_payment_amount( await state.clear() logger.info( - 'Создан %s платеж для пользователя %s: %s₽, ID: %s', - mulenpay_name, - db_user.telegram_id, - amount_rubles, - payment_id_display, + 'Создан платеж для пользователя : ₽, ID', + mulenpay_name=mulenpay_name, + telegram_id=db_user.telegram_id, + amount_rubles=amount_rubles, + payment_id_display=payment_id_display, ) except Exception as e: - logger.error(f'Ошибка создания {mulenpay_name} платежа: {e}') + logger.error('Ошибка создания платежа', mulenpay_name=mulenpay_name, error=e) await message.answer( texts.t( 'MULENPAY_PAYMENT_ERROR', @@ -329,5 +325,5 @@ async def check_mulenpay_payment_status(callback: types.CallbackQuery, db: Async await callback.answer(message_text, show_alert=True) except Exception as e: - logger.error(f'Ошибка проверки статуса {settings.get_mulenpay_display_name()}: {e}') + logger.error('Ошибка проверки статуса', get_mulenpay_display_name=settings.get_mulenpay_display_name(), error=e) await callback.answer('❌ Ошибка проверки статуса', show_alert=True) diff --git a/app/handlers/balance/pal24.py b/app/handlers/balance/pal24.py index 39b1eb3b..5fcbf6ca 100644 --- a/app/handlers/balance/pal24.py +++ b/app/handlers/balance/pal24.py @@ -1,8 +1,8 @@ import html -import logging from datetime import datetime from typing import Any +import structlog from aiogram import types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext @@ -19,7 +19,7 @@ from app.states import BalanceStates from app.utils.decorators import error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _get_available_pal24_methods() -> list[str]: @@ -224,7 +224,7 @@ async def _send_pal24_payment_message( ) await db.commit() except Exception as error: # pragma: no cover - diagnostics - logger.warning('Не удалось сохранить сообщение PayPalych: %s', error) + logger.warning('Не удалось сохранить сообщение PayPalych', error=error) await state.update_data( pal24_invoice_message_id=invoice_message.message_id, @@ -234,15 +234,15 @@ async def _send_pal24_payment_message( await state.clear() logger.info( - 'Создан PayPalych счет для пользователя %s: %s₽, ID: %s, метод: %s', - db_user.telegram_id, - amount_kopeks / 100, - bill_id, - payment_method, + 'Создан PayPalych счет для пользователя : ₽, ID метод', + telegram_id=db_user.telegram_id, + amount_kopeks=amount_kopeks / 100, + bill_id=bill_id, + payment_method=payment_method, ) except Exception as error: - logger.error(f'Ошибка создания PayPalych платежа: {error}') + logger.error('Ошибка создания PayPalych платежа', error=error) await message.answer( texts.t( 'PAL24_PAYMENT_ERROR', @@ -376,16 +376,13 @@ async def process_pal24_payment_amount( try: await message.delete() except Exception as delete_error: # pragma: no cover - depends on bot rights - logger.warning('Не удалось удалить сообщение с суммой PayPalych: %s', delete_error) + logger.warning('Не удалось удалить сообщение с суммой PayPalych', delete_error=delete_error) if prompt_message_id: try: await message.bot.delete_message(prompt_chat_id, prompt_message_id) except Exception as delete_error: # pragma: no cover - diagnostic - logger.warning( - 'Не удалось удалить сообщение с запросом суммы PayPalych: %s', - delete_error, - ) + logger.warning('Не удалось удалить сообщение с запросом суммы PayPalych', delete_error=delete_error) if len(available_methods) == 1: await _send_pal24_payment_message( @@ -658,5 +655,5 @@ async def check_pal24_payment_status( raise except Exception as e: - logger.error(f'Ошибка проверки статуса PayPalych: {e}') + logger.error('Ошибка проверки статуса PayPalych', error=e) await callback.answer('❌ Ошибка проверки статуса', show_alert=True) diff --git a/app/handlers/balance/platega.py b/app/handlers/balance/platega.py index 79041ace..bad82ae0 100644 --- a/app/handlers/balance/platega.py +++ b/app/handlers/balance/platega.py @@ -1,7 +1,6 @@ """Handlers for Platega balance interactions.""" -import logging - +import structlog from aiogram import types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -15,7 +14,7 @@ from app.states import BalanceStates from app.utils.decorators import error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _get_active_methods() -> list[int]: @@ -275,7 +274,7 @@ async def process_platega_payment_amount( payment_method_code=method_code, ) except Exception as error: - logger.exception('Ошибка создания платежа Platega: %s', error) + logger.exception('Ошибка создания платежа Platega', error=error) payment_result = None if not payment_result or not payment_result.get('redirect_url'): @@ -336,16 +335,13 @@ async def process_platega_payment_amount( try: await message.delete() except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning('Не удалось удалить сообщение с суммой Platega: %s', delete_error) + logger.warning('Не удалось удалить сообщение с суммой Platega', delete_error=delete_error) if prompt_message_id: try: await message.bot.delete_message(prompt_chat_id, prompt_message_id) except Exception as delete_error: # pragma: no cover - диагностический лог - logger.warning( - 'Не удалось удалить сообщение с запросом суммы Platega: %s', - delete_error, - ) + logger.warning('Не удалось удалить сообщение с запросом суммы Platega', delete_error=delete_error) invoice_message = await message.answer( instructions_template.format( @@ -374,7 +370,7 @@ async def process_platega_payment_amount( metadata=payment_metadata, ) except Exception as error: # pragma: no cover - диагностический лог - logger.warning('Не удалось сохранить данные сообщения Platega: %s', error) + logger.warning('Не удалось сохранить данные сообщения Platega', error=error) await state.update_data( platega_invoice_message_id=invoice_message.message_id, @@ -400,7 +396,7 @@ async def check_platega_payment_status( try: status_info = await payment_service.get_platega_payment_status(db, local_payment_id) except Exception as error: - logger.exception('Ошибка проверки статуса Platega: %s', error) + logger.exception('Ошибка проверки статуса Platega', error=error) await callback.answer('⚠️ Ошибка проверки статуса', show_alert=True) return diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index 6d0135f0..13ef433c 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -1,5 +1,4 @@ -import logging - +import structlog from aiogram import types from aiogram.fsm.context import FSMContext @@ -13,7 +12,7 @@ from app.states import BalanceStates from app.utils.decorators import error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @error_handler @@ -126,16 +125,13 @@ async def process_stars_payment_amount(message: types.Message, db_user: User, am try: await message.delete() except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning('Не удалось удалить сообщение с суммой Stars: %s', delete_error) + logger.warning('Не удалось удалить сообщение с суммой Stars', delete_error=delete_error) if prompt_message_id: try: await message.bot.delete_message(prompt_chat_id, prompt_message_id) except Exception as delete_error: # pragma: no cover - диагностический лог - logger.warning( - 'Не удалось удалить сообщение с запросом суммы Stars: %s', - delete_error, - ) + logger.warning('Не удалось удалить сообщение с запросом суммы Stars', delete_error=delete_error) invoice_message = await message.answer( f'⭐ Оплата через Telegram Stars\n\n' @@ -155,5 +151,5 @@ async def process_stars_payment_amount(message: types.Message, db_user: User, am await state.set_state(None) except Exception as e: - logger.error(f'Ошибка создания Stars invoice: {e}') + logger.error('Ошибка создания Stars invoice', error=e) await message.answer('⚠️ Ошибка создания платежа') diff --git a/app/handlers/balance/tribute.py b/app/handlers/balance/tribute.py index 425fd764..973eb2f3 100644 --- a/app/handlers/balance/tribute.py +++ b/app/handlers/balance/tribute.py @@ -1,5 +1,4 @@ -import logging - +import structlog from aiogram import types from app.config import settings @@ -8,7 +7,7 @@ from app.localization.texts import get_texts from app.utils.decorators import error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @error_handler @@ -83,7 +82,7 @@ async def start_tribute_payment( ) except Exception as e: - logger.error(f'Ошибка создания Tribute платежа: {e}') + logger.error('Ошибка создания Tribute платежа', error=e) await callback.answer('❌ Ошибка создания платежа', show_alert=True) await callback.answer() diff --git a/app/handlers/balance/wata.py b/app/handlers/balance/wata.py index bfce4090..4682747d 100644 --- a/app/handlers/balance/wata.py +++ b/app/handlers/balance/wata.py @@ -1,6 +1,6 @@ -import logging from datetime import datetime +import structlog from aiogram import types from aiogram.fsm.context import FSMContext from sqlalchemy import update @@ -15,7 +15,7 @@ from app.states import BalanceStates from app.utils.decorators import error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @error_handler @@ -144,7 +144,7 @@ async def process_wata_payment_amount( language=db_user.language, ) except Exception as error: # pragma: no cover - handled by decorator logs - logger.exception('Ошибка создания WATA платежа: %s', error) + logger.exception('Ошибка создания WATA платежа', error=error) result = None if not result or not result.get('payment_url'): @@ -207,16 +207,13 @@ async def process_wata_payment_amount( try: await message.delete() except Exception as delete_error: # pragma: no cover - depends on bot rights - logger.warning('Не удалось удалить сообщение с суммой WATA: %s', delete_error) + logger.warning('Не удалось удалить сообщение с суммой WATA', delete_error=delete_error) if prompt_message_id: try: await message.bot.delete_message(prompt_chat_id, prompt_message_id) except Exception as delete_error: # pragma: no cover - diagnostic - logger.warning( - 'Не удалось удалить сообщение с запросом суммы WATA: %s', - delete_error, - ) + logger.warning('Не удалось удалить сообщение с запросом суммы WATA', delete_error=delete_error) invoice_message = await message.answer( message_text, @@ -241,7 +238,7 @@ async def process_wata_payment_amount( ) await db.commit() except Exception as error: # pragma: no cover - diagnostics - logger.warning('Не удалось сохранить сообщение WATA: %s', error) + logger.warning('Не удалось сохранить сообщение WATA', error=error) await state.update_data( wata_invoice_message_id=invoice_message.message_id, @@ -251,10 +248,10 @@ async def process_wata_payment_amount( await state.clear() logger.info( - 'Создан WATA платеж для пользователя %s: %s₽, ссылка: %s', - db_user.telegram_id, - amount_kopeks / 100, - payment_link_id, + 'Создан WATA платеж для пользователя : ₽, ссылка', + telegram_id=db_user.telegram_id, + amount_kopeks=amount_kopeks / 100, + payment_link_id=payment_link_id, ) @@ -284,7 +281,7 @@ async def check_wata_payment_status( if user and getattr(user, 'language', None): user_language = user.language except Exception as error: - logger.debug('Не удалось получить пользователя для WATA статуса: %s', error) + logger.debug('Не удалось получить пользователя для WATA статуса', error=error) texts = get_texts(user_language) diff --git a/app/handlers/balance/yookassa.py b/app/handlers/balance/yookassa.py index 7ae59386..74e65c83 100644 --- a/app/handlers/balance/yookassa.py +++ b/app/handlers/balance/yookassa.py @@ -1,6 +1,6 @@ -import logging from datetime import datetime +import structlog from aiogram import types from aiogram.fsm.context import FSMContext from sqlalchemy import update @@ -15,7 +15,7 @@ from app.states import BalanceStates from app.utils.decorators import error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @error_handler @@ -233,16 +233,13 @@ async def process_yookassa_payment_amount( try: await message.delete() except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning('Не удалось удалить сообщение с суммой YooKassa: %s', delete_error) + logger.warning('Не удалось удалить сообщение с суммой YooKassa', delete_error=delete_error) if prompt_message_id: try: await message.bot.delete_message(prompt_chat_id, prompt_message_id) except Exception as delete_error: # pragma: no cover - диагностический лог - logger.warning( - 'Не удалось удалить сообщение с запросом суммы YooKassa: %s', - delete_error, - ) + logger.warning('Не удалось удалить сообщение с запросом суммы YooKassa', delete_error=delete_error) invoice_message = await message.answer( f'💳 Оплата банковской картой\n\n' @@ -277,7 +274,7 @@ async def process_yookassa_payment_amount( ) await db.commit() except Exception as error: # pragma: no cover - диагностический лог - logger.warning('Не удалось сохранить сообщение YooKassa: %s', error) + logger.warning('Не удалось сохранить сообщение YooKassa', error=error) await state.update_data( yookassa_invoice_message_id=invoice_message.message_id, @@ -286,12 +283,14 @@ async def process_yookassa_payment_amount( await state.clear() logger.info( - f'Создан платеж YooKassa для пользователя {db_user.telegram_id}: ' - f'{amount_kopeks // 100}₽, ID: {payment_result["yookassa_payment_id"]}' + 'Создан платеж YooKassa для пользователя ₽, ID', + telegram_id=db_user.telegram_id, + value=amount_kopeks // 100, + payment_result=payment_result['yookassa_payment_id'], ) except Exception as e: - logger.error(f'Ошибка создания YooKassa платежа: {e}') + logger.error('Ошибка создания YooKassa платежа', error=e) await message.answer('❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.') await state.clear() @@ -392,7 +391,7 @@ async def process_yookassa_sbp_payment_amount( except ImportError: logger.warning('qrcode библиотека не установлена, QR-код не будет сгенерирован') except Exception as e: - logger.error(f'Ошибка генерации QR-кода: {e}') + logger.error('Ошибка генерации QR-кода', error=e) # Если нет QR-данных из YooKassa, но есть URL, генерируем QR-код из URL if not qr_photo and confirmation_url: @@ -419,7 +418,7 @@ async def process_yookassa_sbp_payment_amount( except ImportError: logger.warning('qrcode библиотека не установлена, QR-код не будет сгенерирован') except Exception as e: - logger.error(f'Ошибка генерации QR-кода из URL: {e}') + logger.error('Ошибка генерации QR-кода из URL', error=e) # Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса keyboard_buttons = [] @@ -452,15 +451,14 @@ async def process_yookassa_sbp_payment_amount( try: await message.delete() except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning('Не удалось удалить сообщение с суммой YooKassa (СБП): %s', delete_error) + logger.warning('Не удалось удалить сообщение с суммой YooKassa (СБП)', delete_error=delete_error) if prompt_message_id: try: await message.bot.delete_message(prompt_chat_id, prompt_message_id) except Exception as delete_error: # pragma: no cover - диагностический лог logger.warning( - 'Не удалось удалить сообщение с запросом суммы YooKassa (СБП): %s', - delete_error, + 'Не удалось удалить сообщение с запросом суммы YooKassa (СБП)', delete_error=delete_error ) # Подготавливаем текст сообщения @@ -515,7 +513,7 @@ async def process_yookassa_sbp_payment_amount( ) await db.commit() except Exception as error: # pragma: no cover - диагностический лог - logger.warning('Не удалось сохранить сообщение YooKassa (СБП): %s', error) + logger.warning('Не удалось сохранить сообщение YooKassa (СБП)', error=error) await state.update_data( yookassa_invoice_message_id=invoice_message.message_id, @@ -524,12 +522,14 @@ async def process_yookassa_sbp_payment_amount( await state.clear() logger.info( - f'Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: ' - f'{amount_kopeks // 100}₽, ID: {payment_result["yookassa_payment_id"]}' + 'Создан платеж YooKassa СБП для пользователя ₽, ID', + telegram_id=db_user.telegram_id, + value=amount_kopeks // 100, + payment_result=payment_result['yookassa_payment_id'], ) except Exception as e: - logger.error(f'Ошибка создания YooKassa СБП платежа: {e}') + logger.error('Ошибка создания YooKassa СБП платежа', error=e) await message.answer('❌ Ошибка создания платежа через СБП. Попробуйте позже или обратитесь в поддержку.') await state.clear() @@ -584,5 +584,5 @@ async def check_yookassa_payment_status(callback: types.CallbackQuery, db: Async await callback.answer(message_text, show_alert=True) except Exception as e: - logger.error(f'Ошибка проверки статуса платежа: {e}') + logger.error('Ошибка проверки статуса платежа', error=e) await callback.answer('❌ Ошибка проверки статуса', show_alert=True) diff --git a/app/handlers/common.py b/app/handlers/common.py index e35b68a7..4ee419ce 100644 --- a/app/handlers/common.py +++ b/app/handlers/common.py @@ -1,5 +1,4 @@ -import logging - +import structlog from aiogram import Dispatcher, F, types from aiogram.filters import StateFilter from aiogram.fsm.context import FSMContext @@ -10,7 +9,7 @@ from app.keyboards.inline import get_back_keyboard from app.localization.texts import get_rules, get_texts -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def handle_delete_ban_notification( @@ -21,7 +20,7 @@ async def handle_delete_ban_notification( await callback.message.delete() await callback.answer('Уведомление удалено') except Exception as e: - logger.warning(f'Не удалось удалить уведомление: {e}') + logger.warning('Не удалось удалить уведомление', error=e) await callback.answer('Не удалось удалить', show_alert=False) @@ -36,7 +35,7 @@ async def handle_webhook_notification_close( try: await callback.message.delete() except Exception as e: - logger.warning('Не удалось удалить webhook-уведомление: %s', e) + logger.warning('Не удалось удалить webhook-уведомление', e=e) try: await callback.message.edit_reply_markup(reply_markup=None) except Exception: @@ -54,7 +53,9 @@ async def handle_unknown_callback(callback: types.CallbackQuery, db_user: User): show_alert=True, ) - logger.warning(f'Неизвестный callback: {callback.data} от пользователя {callback.from_user.id}') + logger.warning( + 'Неизвестный callback: от пользователя', callback_data=callback.data, from_user_id=callback.from_user.id + ) async def handle_noop(callback: types.CallbackQuery, db_user: User): diff --git a/app/handlers/contests.py b/app/handlers/contests.py index 752be504..11d58e20 100644 --- a/app/handlers/contests.py +++ b/app/handlers/contests.py @@ -1,8 +1,8 @@ """Contest handlers for daily games.""" -import logging from datetime import datetime +import structlog from aiogram import Dispatcher, F, types from aiogram.filters import Command from aiogram.fsm.context import FSMContext @@ -22,7 +22,7 @@ from app.states import ContestStates from app.utils.decorators import auth_required, error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # Rate limiting storage _rate_limits: dict = {} diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 830e882a..1512891c 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -1,8 +1,8 @@ import html -import logging from datetime import datetime from decimal import Decimal +import structlog from aiogram import Dispatcher, F, types from aiogram.filters import StateFilter from aiogram.fsm.context import FSMContext @@ -43,7 +43,7 @@ from app.utils.promo_offer import ( from app.utils.timezone import format_local_datetime -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _format_rubles(amount_kopeks: int) -> str: @@ -183,7 +183,7 @@ async def show_main_menu( try: has_saved_cart = await user_cart_service.has_user_cart(db_user.id) except Exception as e: - logger.error(f'Ошибка проверки сохраненной корзины для пользователя {db_user.id}: {e}') + logger.error('Ошибка проверки сохраненной корзины для пользователя', db_user_id=db_user.id, error=e) has_saved_cart = False is_admin = settings.is_admin(db_user.telegram_id) @@ -1029,7 +1029,7 @@ async def handle_back_to_menu(callback: types.CallbackQuery, state: FSMContext, try: has_saved_cart = await user_cart_service.has_user_cart(db_user.id) except Exception as e: - logger.error(f'Ошибка проверки сохраненной корзины для пользователя {db_user.id}: {e}') + logger.error('Ошибка проверки сохраненной корзины для пользователя', db_user_id=db_user.id, error=e) has_saved_cart = False is_admin = settings.is_admin(db_user.telegram_id) @@ -1182,7 +1182,7 @@ async def get_main_menu_text(user, texts, db: AsyncSession): # Формируем краткий блок информации о тарифе для главного меню tariff_info_block = f'\n📦 Тариф: {tariff.name}' except Exception as e: - logger.debug(f'Не удалось загрузить тариф для главного меню: {e}') + logger.debug('Не удалось загрузить тариф для главного меню', error=e) base_text = texts.MAIN_MENU.format( user_name=user.full_name, subscription_status=_get_subscription_status(user, texts, is_daily_tariff) @@ -1204,9 +1204,9 @@ async def get_main_menu_text(user, texts, db: AsyncSession): info_sections.append(promo_hint.strip()) except Exception as hint_error: logger.debug( - 'Не удалось построить подсказку промо-предложения для пользователя %s: %s', - getattr(user, 'id', None), - hint_error, + 'Не удалось построить подсказку промо-предложения для пользователя', + getattr=getattr(user, 'id', None), + hint_error=hint_error, ) try: @@ -1215,9 +1215,9 @@ async def get_main_menu_text(user, texts, db: AsyncSession): info_sections.append(test_access_hint.strip()) except Exception as test_error: logger.debug( - 'Не удалось построить подсказку тестового доступа для пользователя %s: %s', - getattr(user, 'id', None), - test_error, + 'Не удалось построить подсказку тестового доступа для пользователя', + getattr=getattr(user, 'id', None), + test_error=test_error, ) if info_sections: @@ -1231,7 +1231,7 @@ async def get_main_menu_text(user, texts, db: AsyncSession): return _insert_random_message(base_text, random_message, action_prompt) except Exception as e: - logger.error(f'Ошибка получения случайного сообщения: {e}') + logger.error('Ошибка получения случайного сообщения', error=e) return base_text @@ -1373,7 +1373,7 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d except Exception as e: user_id_display = db_user.telegram_id or db_user.email or f'#{db_user.id}' - logger.error(f'Ошибка автоматической активации для {user_id_display}: {e}') + logger.error('Ошибка автоматической активации для', user_id_display=user_id_display, error=e) await db.rollback() await callback.answer( texts.t('ACTIVATION_ERROR', '❌ Ошибка активации. Попробуйте позже.'), diff --git a/app/handlers/polls.py b/app/handlers/polls.py index dfb35e44..fef6f778 100644 --- a/app/handlers/polls.py +++ b/app/handlers/polls.py @@ -1,7 +1,7 @@ import asyncio -import logging from datetime import datetime +import structlog from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest from sqlalchemy.ext.asyncio import AsyncSession @@ -16,7 +16,7 @@ from app.localization.texts import get_texts from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def _delete_message_later(bot, chat_id: int, message_id: int, delay: int = 10) -> None: @@ -24,7 +24,7 @@ async def _delete_message_later(bot, chat_id: int, message_id: int, delay: int = await asyncio.sleep(delay) await bot.delete_message(chat_id, message_id) except Exception as error: # pragma: no cover - cleanup best effort - logger.debug('Не удалось удалить сообщение опроса %s: %s', message_id, error) + logger.debug('Не удалось удалить сообщение опроса', message_id=message_id, error=error) async def _render_question_text( @@ -60,22 +60,13 @@ async def _update_poll_message( except TelegramBadRequest as error: error_text = str(error).lower() if 'message is not modified' in error_text: - logger.debug( - 'Опросное сообщение уже актуально, пропускаем обновление: %s', - error, - ) + logger.debug('Опросное сообщение уже актуально, пропускаем обновление', error=error) return True - logger.warning( - 'Не удалось обновить сообщение опроса %s: %s', - message.message_id, - error, - ) + logger.warning('Не удалось обновить сообщение опроса', message_id=message.message_id, error=error) except Exception as error: # pragma: no cover - defensive logging logger.exception( - 'Непредвиденная ошибка при обновлении сообщения опроса %s: %s', - message.message_id, - error, + 'Непредвиденная ошибка при обновлении сообщения опроса', message_id=message.message_id, error=error ) return False @@ -197,11 +188,7 @@ async def handle_poll_answer( try: await db.refresh(response, attribute_names=['answers']) except Exception as error: # pragma: no cover - defensive cache busting - logger.debug( - 'Не удалось обновить локальные ответы опроса %s: %s', - response.id, - error, - ) + logger.debug('Не удалось обновить локальные ответы опроса', response_id=response.id, error=error) response = await get_poll_response_by_id(db, response.id) if not response: await callback.answer(texts.t('POLL_ERROR', 'Опрос недоступен.'), show_alert=True) diff --git a/app/handlers/promocode.py b/app/handlers/promocode.py index 3beefc43..a57c85f3 100644 --- a/app/handlers/promocode.py +++ b/app/handlers/promocode.py @@ -1,5 +1,4 @@ -import logging - +import structlog from aiogram import Bot, Dispatcher, F, types from aiogram.fsm.context import FSMContext from aiogram.types import InaccessibleMessage @@ -14,7 +13,7 @@ from app.states import PromoCodeStates from app.utils.decorators import error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @error_handler @@ -40,7 +39,7 @@ async def activate_promocode_for_registration(db: AsyncSession, user_id: int, co result = await promocode_service.activate_promocode(db, user_id, code) if result['success']: - logger.info(f'✅ Пользователь {user_id} активировал промокод {code} при регистрации') + logger.info('✅ Пользователь активировал промокод при регистрации', user_id=user_id, code=code) # Отправляем уведомление админу, если бот доступен if bot: @@ -60,9 +59,7 @@ async def activate_promocode_for_registration(db: AsyncSession, user_id: int, co ) except Exception as notify_error: logger.error( - 'Ошибка отправки админ уведомления об активации промокода %s: %s', - code, - notify_error, + 'Ошибка отправки админ уведомления об активации промокода', code=code, notify_error=notify_error ) return result diff --git a/app/handlers/referral.py b/app/handlers/referral.py index 58d3e6b7..50701f7c 100644 --- a/app/handlers/referral.py +++ b/app/handlers/referral.py @@ -1,8 +1,8 @@ import json -import logging from pathlib import Path import qrcode +import structlog from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext @@ -25,7 +25,7 @@ from app.utils.user_utils import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: AsyncSession): @@ -795,7 +795,7 @@ async def confirm_withdrawal_request(callback: types.CallbackQuery, db_user: Use notification_service = AdminNotificationService(callback.bot) await notification_service.send_to_admins(admin_text, keyboard=admin_keyboard) except Exception as e: - logger.error(f'Ошибка отправки уведомления админам о заявке на вывод: {e}') + logger.error('Ошибка отправки уведомления админам о заявке на вывод', error=e) # Уведомление в топик, если настроено topic_id = settings.REFERRAL_WITHDRAWAL_NOTIFICATIONS_TOPIC_ID @@ -809,7 +809,7 @@ async def confirm_withdrawal_request(callback: types.CallbackQuery, db_user: Use parse_mode='HTML', ) except Exception as e: - logger.error(f'Ошибка отправки уведомления в топик о заявке на вывод: {e}') + logger.error('Ошибка отправки уведомления в топик о заявке на вывод', error=e) # Отвечаем пользователю text = texts.t( diff --git a/app/handlers/server_status.py b/app/handlers/server_status.py index 9f527a23..858c8337 100644 --- a/app/handlers/server_status.py +++ b/app/handlers/server_status.py @@ -1,6 +1,6 @@ -import logging from datetime import datetime +import structlog from aiogram import Dispatcher, F, types from app.config import settings @@ -14,7 +14,7 @@ from app.services.server_status_service import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) _status_service = ServerStatusService() @@ -47,14 +47,14 @@ async def _render_server_status( try: servers = await _status_service.get_servers() except ServerStatusError as error: - logger.warning('Server status error: %s', error) + logger.warning('Server status error', error=error) await callback.answer( texts.t('SERVER_STATUS_ERROR_SHORT', 'Не удалось получить данные'), show_alert=True, ) return except Exception as error: # pragma: no cover - defensive logging - logger.error('Unexpected server status error: %s', error) + logger.error('Unexpected server status error', error=error) await callback.answer( texts.t('SERVER_STATUS_ERROR_SHORT', 'Не удалось получить данные'), show_alert=True, diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index 095fd2c8..11f5f7cb 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -1,10 +1,10 @@ """Обработчики для простой покупки подписки.""" import html -import logging from datetime import datetime from typing import Any +import structlog from aiogram import F, types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext @@ -25,7 +25,7 @@ from app.utils.subscription_utils import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @error_handler @@ -120,11 +120,11 @@ async def start_simple_subscription_purchase( can_pay_from_balance = user_balance_kopeks >= price_kopeks logger.warning( - 'SIMPLE_SUBSCRIPTION_DEBUG_START_BALANCE | user=%s | balance=%s | min_required=%s | can_pay=%s', - db_user.id, - user_balance_kopeks, - price_kopeks, - can_pay_from_balance, + 'SIMPLE_SUBSCRIPTION_DEBUG_START_BALANCE | user= | balance= | min_required= | can_pay', + db_user_id=db_user.id, + user_balance_kopeks=user_balance_kopeks, + price_kopeks=price_kopeks, + can_pay_from_balance=can_pay_from_balance, ) # Проверяем, является ли у пользователя текущая подписка активной платной подпиской @@ -352,19 +352,13 @@ async def _ensure_simple_subscription_squad_uuid( resolved_uuid = await get_random_active_squad_uuid(db) except Exception as error: # pragma: no cover - defensive logging - logger.error( - 'SIMPLE_SUBSCRIPTION_RANDOM_SQUAD_ERROR | user=%s | error=%s', - user_id, - error, - ) + logger.error('SIMPLE_SUBSCRIPTION_RANDOM_SQUAD_ERROR | user= | error', user_id=user_id, error=error) return None if resolved_uuid: await state.update_data(resolved_squad_uuid=resolved_uuid) logger.info( - 'SIMPLE_SUBSCRIPTION_RANDOM_SQUAD_ASSIGNED | user=%s | squad=%s', - user_id, - resolved_uuid, + 'SIMPLE_SUBSCRIPTION_RANDOM_SQUAD_ASSIGNED | user= | squad', user_id=user_id, resolved_uuid=resolved_uuid ) return resolved_uuid @@ -416,16 +410,16 @@ async def handle_simple_subscription_pay_with_balance( ) total_required = price_kopeks logger.warning( - 'SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | balance=%s', - db_user.id, - subscription_params['period_days'], - price_breakdown.get('base_price', 0), - price_breakdown.get('traffic_price', 0), - price_breakdown.get('devices_price', 0), - price_breakdown.get('servers_price', 0), - price_breakdown.get('total_discount', 0), - total_required, - getattr(db_user, 'balance_kopeks', 0), + 'SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user= | period= | base= | traffic= | devices= | servers= | discount= | total_required= | balance', + db_user_id=db_user.id, + subscription_params=subscription_params['period_days'], + price_breakdown=price_breakdown.get('base_price', 0), + price_breakdown_2=price_breakdown.get('traffic_price', 0), + price_breakdown_3=price_breakdown.get('devices_price', 0), + price_breakdown_4=price_breakdown.get('servers_price', 0), + price_breakdown_5=price_breakdown.get('total_discount', 0), + total_required=total_required, + getattr=getattr(db_user, 'balance_kopeks', 0), ) # Проверяем баланс пользователя @@ -523,7 +517,10 @@ async def handle_simple_subscription_pay_with_balance( await db.refresh(subscription) except Exception as sync_error: logger.error( - f'Ошибка синхронизации подписки с RemnaWave для пользователя {db_user.id}: {sync_error}', exc_info=True + 'Ошибка синхронизации подписки с RemnaWave для пользователя', + db_user_id=db_user.id, + sync_error=sync_error, + exc_info=True, ) # Отправляем уведомление об успешной покупке @@ -641,18 +638,22 @@ async def handle_simple_subscription_pay_with_balance( amount_kopeks=price_kopeks, ) except Exception as e: - logger.error(f'Ошибка отправки уведомления админам о покупке: {e}') + logger.error('Ошибка отправки уведомления админам о покупке', error=e) await state.clear() await callback.answer() - logger.info(f'Пользователь {db_user.telegram_id} успешно купил подписку с баланса на {price_kopeks / 100}₽') + logger.info( + 'Пользователь успешно купил подписку с баланса на ₽', + telegram_id=db_user.telegram_id, + price_kopeks=price_kopeks / 100, + ) except Exception as error: logger.error( - 'Ошибка оплаты простой подписки с баланса для пользователя %s: %s', - db_user.id, - error, + 'Ошибка оплаты простой подписки с баланса для пользователя', + db_user_id=db_user.id, + error=error, exc_info=True, ) await callback.answer( @@ -712,16 +713,16 @@ async def handle_simple_subscription_other_payment_methods( user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0) can_pay_from_balance = user_balance_kopeks >= price_kopeks logger.warning( - 'SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | can_pay=%s', - db_user.id, - user_balance_kopeks, - price_breakdown.get('base_price', 0), - price_breakdown.get('traffic_price', 0), - price_breakdown.get('devices_price', 0), - price_breakdown.get('servers_price', 0), - price_breakdown.get('total_discount', 0), - price_kopeks, - can_pay_from_balance, + 'SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user= | balance= | base= | traffic= | devices= | servers= | discount= | total_required= | can_pay', + db_user_id=db_user.id, + user_balance_kopeks=user_balance_kopeks, + price_breakdown=price_breakdown.get('base_price', 0), + price_breakdown_2=price_breakdown.get('traffic_price', 0), + price_breakdown_3=price_breakdown.get('devices_price', 0), + price_breakdown_4=price_breakdown.get('servers_price', 0), + price_breakdown_5=price_breakdown.get('total_discount', 0), + price_kopeks=price_kopeks, + can_pay_from_balance=can_pay_from_balance, ) # Отображаем доступные методы оплаты @@ -974,7 +975,7 @@ async def handle_simple_subscription_payment_method( except ImportError: logger.warning('qrcode библиотека не установлена, QR-код не будет сгенерирован') except Exception as e: - logger.error(f'Ошибка генерации QR-кода: {e}') + logger.error('Ошибка генерации QR-кода', error=e) # Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса keyboard_buttons = [] @@ -1079,7 +1080,7 @@ async def handle_simple_subscription_payment_method( usd_rate = await currency_converter.get_usd_to_rub_rate() except Exception as rate_error: - logger.warning('Не удалось получить курс USD: %s', rate_error) + logger.warning('Не удалось получить курс USD', rate_error=rate_error) usd_rate = 95.0 amount_usd = round(amount_rubles / usd_rate, 2) @@ -1576,7 +1577,7 @@ async def handle_simple_subscription_payment_method( language=db_user.language, ) except Exception as error: - logger.error('Ошибка создания WATA платежа: %s', error) + logger.error('Ошибка создания WATA платежа', error=error) wata_result = None if not wata_result or not wata_result.get('payment_url'): @@ -1644,7 +1645,7 @@ async def handle_simple_subscription_payment_method( await callback.answer('❌ Неизвестный способ оплаты', show_alert=True) except Exception as e: - logger.error(f'Ошибка обработки метода оплаты простой подписки: {e}') + logger.error('Ошибка обработки метода оплаты простой подписки', error=e) await callback.answer( '❌ Ошибка обработки запроса. Попробуйте позже или обратитесь в поддержку.', show_alert=True ) @@ -1781,7 +1782,7 @@ async def check_simple_pal24_payment_status( raise except Exception as error: - logger.error(f'Ошибка проверки статуса PayPalych для простой подписки: {error}') + logger.error('Ошибка проверки статуса PayPalych для простой подписки', error=error) await callback.answer('❌ Ошибка проверки статуса', show_alert=True) @@ -1813,7 +1814,7 @@ async def check_simple_mulenpay_payment_status( if user and getattr(user, 'language', None): user_language = user.language except Exception as error: - logger.debug('Не удалось получить пользователя для MulenPay статуса: %s', error) + logger.debug('Не удалось получить пользователя для MulenPay статуса', error=error) texts = get_texts(user_language) status_labels = { @@ -1895,7 +1896,7 @@ async def check_simple_cryptobot_payment_status( if user and getattr(user, 'language', None): language = user.language except Exception as error: - logger.debug('Не удалось получить пользователя для CryptoBot статуса: %s', error) + logger.debug('Не удалось получить пользователя для CryptoBot статуса', error=error) texts = get_texts(language) message_lines = [ @@ -1971,7 +1972,7 @@ async def check_simple_heleket_payment_status( if user and getattr(user, 'language', None): language = user.language except Exception as error: - logger.debug('Не удалось получить пользователя для Heleket статуса: %s', error) + logger.debug('Не удалось получить пользователя для Heleket статуса', error=error) texts = get_texts(language) @@ -2115,16 +2116,16 @@ async def confirm_simple_subscription_purchase( ) total_required = price_kopeks logger.warning( - 'SIMPLE_SUBSCRIPTION_DEBUG_CONFIRM | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | balance=%s', - db_user.id, - subscription_params['period_days'], - price_breakdown.get('base_price', 0), - price_breakdown.get('traffic_price', 0), - price_breakdown.get('devices_price', 0), - price_breakdown.get('servers_price', 0), - price_breakdown.get('total_discount', 0), - total_required, - getattr(db_user, 'balance_kopeks', 0), + 'SIMPLE_SUBSCRIPTION_DEBUG_CONFIRM | user= | period= | base= | traffic= | devices= | servers= | discount= | total_required= | balance', + db_user_id=db_user.id, + subscription_params=subscription_params['period_days'], + price_breakdown=price_breakdown.get('base_price', 0), + price_breakdown_2=price_breakdown.get('traffic_price', 0), + price_breakdown_3=price_breakdown.get('devices_price', 0), + price_breakdown_4=price_breakdown.get('servers_price', 0), + price_breakdown_5=price_breakdown.get('total_discount', 0), + total_required=total_required, + getattr=getattr(db_user, 'balance_kopeks', 0), ) # Проверяем баланс пользователя @@ -2222,7 +2223,10 @@ async def confirm_simple_subscription_purchase( await db.refresh(subscription) except Exception as sync_error: logger.error( - f'Ошибка синхронизации подписки с RemnaWave для пользователя {db_user.id}: {sync_error}', exc_info=True + 'Ошибка синхронизации подписки с RemnaWave для пользователя', + db_user_id=db_user.id, + sync_error=sync_error, + exc_info=True, ) # Отправляем уведомление об успешной покупке @@ -2340,18 +2344,22 @@ async def confirm_simple_subscription_purchase( amount_kopeks=price_kopeks, ) except Exception as e: - logger.error(f'Ошибка отправки уведомления админам о покупке: {e}') + logger.error('Ошибка отправки уведомления админам о покупке', error=e) await state.clear() await callback.answer() - logger.info(f'Пользователь {db_user.telegram_id} успешно купил подписку с баланса на {price_kopeks / 100}₽') + logger.info( + 'Пользователь успешно купил подписку с баланса на ₽', + telegram_id=db_user.telegram_id, + price_kopeks=price_kopeks / 100, + ) except Exception as error: logger.error( - 'Ошибка подтверждения простой подписки с баланса для пользователя %s: %s', - db_user.id, - error, + 'Ошибка подтверждения простой подписки с баланса для пользователя', + db_user_id=db_user.id, + error=error, exc_info=True, ) await callback.answer( diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index a55d4efe..5373e713 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -1,6 +1,6 @@ -import logging from decimal import ROUND_HALF_UP, Decimal +import structlog from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -13,7 +13,7 @@ from app.localization.texts import get_texts from app.services.payment_service import PaymentService -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def _handle_wheel_spin_payment( @@ -70,8 +70,10 @@ async def _handle_wheel_spin_payment( promocode_id = row[0] logger.info( - f'🎰 Creating wheel spin: user.id={user.id}, user.telegram_id={user.telegram_id}, ' - f'prize={selected_prize.display_name}' + '🎰 Creating wheel spin: user.id=, user.telegram_id=, prize', + user_id=user.id, + telegram_id=user.telegram_id, + display_name=selected_prize.display_name, ) spin = await create_wheel_spin( @@ -89,7 +91,7 @@ async def _handle_wheel_spin_payment( is_applied=True, ) - logger.info(f'🎰 Wheel spin created: spin.id={spin.id}, spin.user_id={spin.user_id}') + logger.info('🎰 Wheel spin created: spin.id=, spin.user_id', spin_id=spin.id, user_id=spin.user_id) # Ensure all changes are committed (subscription days, traffic GB, etc.) await db.commit() @@ -107,12 +109,15 @@ async def _handle_wheel_spin_payment( ) logger.info( - f'🎰 Wheel spin via Stars: user={user.id}, prize={selected_prize.display_name}, stars={stars_amount}' + '🎰 Wheel spin via Stars: user=, prize=, stars', + user_id=user.id, + display_name=selected_prize.display_name, + stars_amount=stars_amount, ) return True except Exception as e: - logger.error(f'Ошибка обработки wheel spin payment: {e}', exc_info=True) + logger.error('Ошибка обработки wheel spin payment', error=e, exc_info=True) await message.answer( '❌ Произошла ошибка при обработке спина. Обратитесь в поддержку.', ) @@ -138,7 +143,7 @@ async def _handle_trial_payment( # Парсим payload: trial_{subscription_id} parts = payload.split('_') if len(parts) < 2: - logger.error(f'Невалидный trial payload: {payload}') + logger.error('Невалидный trial payload', payload=payload) await message.answer( '❌ Ошибка: неверный формат платежа. Обратитесь в поддержку.', ) @@ -147,7 +152,7 @@ async def _handle_trial_payment( try: subscription_id = int(parts[1]) except ValueError: - logger.error(f'Невалидный subscription_id в trial payload: {payload}') + logger.error('Невалидный subscription_id в trial payload', payload=payload) await message.answer( '❌ Ошибка: неверный ID подписки. Обратитесь в поддержку.', ) @@ -177,7 +182,11 @@ async def _handle_trial_payment( ) if not subscription: - logger.error(f'Не удалось активировать триальную подписку {subscription_id} для пользователя {user.id}') + logger.error( + 'Не удалось активировать триальную подписку для пользователя', + subscription_id=subscription_id, + user_id=user.id, + ) # Возвращаем деньги на баланс from app.database.crud.user import add_user_balance @@ -198,7 +207,7 @@ async def _handle_trial_payment( try: await subscription_service.create_remnawave_user(db, subscription) except Exception as rw_error: - logger.error(f'Ошибка создания пользователя RemnaWave для триала: {rw_error}') + logger.error('Ошибка создания пользователя RemnaWave для триала', rw_error=rw_error) # Не откатываем подписку, просто логируем - RemnaWave может быть временно недоступен await db.commit() @@ -214,7 +223,7 @@ async def _handle_trial_payment( payment_method='Telegram Stars', ) except Exception as admin_error: - logger.warning(f'Ошибка отправки уведомления админам о триале: {admin_error}') + logger.warning('Ошибка отправки уведомления админам о триале', admin_error=admin_error) # Отправляем сообщение пользователю await message.answer( @@ -227,13 +236,15 @@ async def _handle_trial_payment( ) logger.info( - f'✅ Платный триал активирован через Stars: user={user.id}, ' - f'subscription={subscription.id}, stars={stars_amount}' + '✅ Платный триал активирован через Stars: user=, subscription=, stars', + user_id=user.id, + subscription_id=subscription.id, + stars_amount=stars_amount, ) return True except Exception as e: - logger.error(f'Ошибка обработки trial payment: {e}', exc_info=True) + logger.error('Ошибка обработки trial payment', error=e, exc_info=True) await message.answer( '❌ Произошла ошибка при активации пробной подписки. Обратитесь в поддержку.', ) @@ -245,13 +256,16 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): try: logger.info( - f'📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}' + '📋 Pre-checkout query от XTR, payload', + from_user_id=query.from_user.id, + total_amount=query.total_amount, + invoice_payload=query.invoice_payload, ) allowed_prefixes = ('balance_', 'admin_stars_test_', 'simple_sub_', 'wheel_spin_', 'trial_') if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes): - logger.warning(f'Невалидный payload: {query.invoice_payload}') + logger.warning('Невалидный payload', invoice_payload=query.invoice_payload) await query.answer( ok=False, error_message=texts.t( @@ -267,7 +281,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): async with AsyncSessionLocal() as db: user = await get_user_by_telegram_id(db, query.from_user.id) if not user: - logger.warning(f'Пользователь {query.from_user.id} не найден в БД') + logger.warning('Пользователь не найден в БД', from_user_id=query.from_user.id) await query.answer( ok=False, error_message=texts.t( @@ -278,7 +292,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): return texts = get_texts(user.language or DEFAULT_LANGUAGE) except Exception as db_error: - logger.error(f'Ошибка подключения к БД в pre_checkout_query: {db_error}') + logger.error('Ошибка подключения к БД в pre_checkout_query', db_error=db_error) await query.answer( ok=False, error_message=texts.t( @@ -289,10 +303,10 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): return await query.answer(ok=True) - logger.info(f'✅ Pre-checkout одобрен для пользователя {query.from_user.id}') + logger.info('✅ Pre-checkout одобрен для пользователя', from_user_id=query.from_user.id) except Exception as e: - logger.error(f'Ошибка в pre_checkout_query: {e}', exc_info=True) + logger.error('Ошибка в pre_checkout_query', error=e, exc_info=True) await query.answer( ok=False, error_message=texts.t( @@ -310,17 +324,18 @@ async def handle_successful_payment(message: types.Message, db: AsyncSession, st user_id = message.from_user.id logger.info( - f'💳 Успешный Stars платеж от {user_id}: ' - f'{payment.total_amount} XTR, ' - f'payload: {payment.invoice_payload}, ' - f'charge_id: {payment.telegram_payment_charge_id}' + '💳 Успешный Stars платеж от XTR, payload: charge_id', + user_id=user_id, + total_amount=payment.total_amount, + invoice_payload=payment.invoice_payload, + telegram_payment_charge_id=payment.telegram_payment_charge_id, ) user = await get_user_by_telegram_id(db, user_id) texts = get_texts(user.language if user and user.language else DEFAULT_LANGUAGE) if not user: - logger.error(f'Пользователь {user_id} не найден при обработке Stars платежа') + logger.error('Пользователь не найден при обработке Stars платежа', user_id=user_id) await message.answer( texts.t( 'STARS_PAYMENT_USER_NOT_FOUND', @@ -370,9 +385,7 @@ async def handle_successful_payment(message: types.Message, db: AsyncSession, st await message.bot.delete_message(chat_id, message_id) except Exception as delete_error: # pragma: no cover - зависит от прав бота logger.warning( - 'Не удалось удалить сообщение %s после оплаты Stars: %s', - label, - delete_error, + 'Не удалось удалить сообщение после оплаты Stars', label=label, delete_error=delete_error ) success = await payment_service.process_stars_payment( @@ -421,13 +434,13 @@ async def handle_successful_payment(message: types.Message, db: AsyncSession, st ) logger.info( - '✅ Stars платеж успешно обработан: пользователь %s, %s звезд → %s', - user.id, - payment.total_amount, - settings.format_price(amount_kopeks), + '✅ Stars платеж успешно обработан: пользователь , звезд →', + user_id=user.id, + total_amount=payment.total_amount, + format_price=settings.format_price(amount_kopeks), ) else: - logger.error(f'Ошибка обработки Stars платежа для пользователя {user.id}') + logger.error('Ошибка обработки Stars платежа для пользователя', user_id=user.id) await message.answer( texts.t( 'STARS_PAYMENT_ENROLLMENT_ERROR', @@ -437,7 +450,7 @@ async def handle_successful_payment(message: types.Message, db: AsyncSession, st ) except Exception as e: - logger.error(f'Ошибка в successful_payment: {e}', exc_info=True) + logger.error('Ошибка в successful_payment', error=e, exc_info=True) await message.answer( texts.t( 'STARS_PAYMENT_PROCESSING_ERROR', diff --git a/app/handlers/start.py b/app/handlers/start.py index 73542b45..7a42b6d7 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -1,6 +1,6 @@ -import logging from datetime import datetime +import structlog from aiogram import Bot, Dispatcher, F, types from aiogram.enums import ChatMemberStatus from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError @@ -55,7 +55,7 @@ from app.utils.timezone import format_local_datetime from app.utils.user_utils import generate_unique_referral_code -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _calculate_subscription_flags(subscription): @@ -79,9 +79,9 @@ async def _send_pinned_message( await deliver_pinned_message_to_user(bot, db, user, pinned_message) except Exception as error: logger.error( - 'Не удалось отправить закрепленное сообщение пользователю %s: %s', - getattr(user, 'telegram_id', 'unknown'), - error, + 'Не удалось отправить закрепленное сообщение пользователю', + getattr=getattr(user, 'telegram_id', 'unknown'), + error=error, ) @@ -141,7 +141,11 @@ async def _apply_campaign_bonus_if_needed( async def handle_potential_referral_code(message: types.Message, state: FSMContext, db: AsyncSession): current_state = await state.get_state() - logger.info(f"🔍 REFERRAL/PROMO CHECK: Проверка сообщения '{message.text}' в состоянии {current_state}") + logger.info( + '🔍 REFERRAL/PROMO CHECK: Проверка сообщения в состоянии', + message_text=message.text, + current_state=current_state, + ) if current_state not in [ RegistrationStates.waiting_for_rules_accept.state, @@ -188,7 +192,11 @@ async def handle_potential_referral_code(message: types.Message, state: FSMConte await state.set_data(data) await message.answer(texts.t('REFERRAL_CODE_ACCEPTED', '✅ Реферальный код принят!')) - logger.info(f'✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}') + logger.info( + '✅ Реферальный код применен для пользователя', + potential_code=potential_code, + from_user_id=message.from_user.id, + ) if current_state != RegistrationStates.waiting_for_referral_code.state: language = data.get('language', DEFAULT_LANGUAGE) @@ -219,7 +227,11 @@ async def handle_potential_referral_code(message: types.Message, state: FSMConte '✅ Промокод принят! Он будет активирован после завершения регистрации.', ) ) - logger.info(f'✅ Промокод {potential_code} сохранен для активации для пользователя {message.from_user.id}') + logger.info( + '✅ Промокод сохранен для активации для пользователя', + potential_code=potential_code, + from_user_id=message.from_user.id, + ) if current_state != RegistrationStates.waiting_for_referral_code.state: language = data.get('language', DEFAULT_LANGUAGE) @@ -254,7 +266,7 @@ def _get_language_prompt_text() -> str: async def _prompt_language_selection(message: types.Message, state: FSMContext) -> None: - logger.info(f'🌐 LANGUAGE: Запрос выбора языка для пользователя {message.from_user.id}') + logger.info('🌐 LANGUAGE: Запрос выбора языка для пользователя', from_user_id=message.from_user.id) await state.set_state(RegistrationStates.waiting_for_language) await message.answer( @@ -293,7 +305,7 @@ async def _continue_registration_after_language( if referrer: data['referrer_id'] = referrer.id await state.set_data(data) - logger.info(f'✅ LANGUAGE: Реферер найден: {referrer.id}') + logger.info('✅ LANGUAGE: Реферер найден', referrer_id=referrer.id) if settings.SKIP_REFERRAL_CODE or data.get('referral_code'): await _complete_registration_wrapper() @@ -309,7 +321,7 @@ async def _continue_registration_after_language( await state.set_state(RegistrationStates.waiting_for_referral_code) logger.info('🔍 LANGUAGE: Ожидание ввода реферального кода') except Exception as error: - logger.error(f'Ошибка при показе вопроса о реферальном коде после выбора языка: {error}') + logger.error('Ошибка при показе вопроса о реферальном коде после выбора языка', error=error) await _complete_registration_wrapper() return @@ -318,7 +330,8 @@ async def _continue_registration_after_language( await target_message.answer(rules_text, reply_markup=get_rules_keyboard(language)) except TelegramForbiddenError: logger.warning( - f'⚠️ Пользователь {callback.from_user.id if callback else message.from_user.id} заблокировал бота, пропускаем отправку правил' + '⚠️ Пользователь заблокировал бота, пропускаем отправку правил', + from_user_id=callback.from_user.id if callback else message.from_user.id, ) return await state.set_state(RegistrationStates.waiting_for_rules_accept) @@ -326,7 +339,7 @@ async def _continue_registration_after_language( async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, db_user=None): - logger.info(f'🚀 START: Обработка /start от {message.from_user.id}') + logger.info('🚀 START: Обработка /start от', from_user_id=message.from_user.id) data = await state.get_data() or {} @@ -346,8 +359,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, data['pending_start_payload'] = redis_payload state_needs_update = True logger.info( - "📦 START: Payload '%s' восстановлен из Redis (fallback)", - pending_start_payload, + "📦 START: Payload '' восстановлен из Redis (fallback)", pending_start_payload=pending_start_payload ) # НЕ удаляем Redis payload здесь - удаление только после успешной регистрации @@ -360,10 +372,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, start_parameter = start_args[1] elif pending_start_payload: start_parameter = pending_start_payload - logger.info( - "📦 START: Используем сохраненный payload '%s'", - pending_start_payload, - ) + logger.info("📦 START: Используем сохраненный payload ''", pending_start_payload=pending_start_payload) if state_needs_update: await state.set_data(data) @@ -377,14 +386,14 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, if campaign: logger.info( - '📣 Найдена рекламная кампания %s (start=%s)', - campaign.id, - campaign.start_parameter, + '📣 Найдена рекламная кампания (start=)', + campaign_id=campaign.id, + start_parameter=campaign.start_parameter, ) await state.update_data(campaign_id=campaign.id) else: referral_code = start_parameter - logger.info(f'🔎 Найден реферальный код: {referral_code}') + logger.info('🔎 Найден реферальный код', referral_code=referral_code) if referral_code: await state.update_data(referral_code=referral_code) @@ -402,32 +411,32 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, ) except Exception as notify_error: logger.error( - 'Ошибка отправки админ уведомления о переходе по кампании %s: %s', - campaign.id, - notify_error, + 'Ошибка отправки админ уведомления о переходе по кампании', + campaign_id=campaign.id, + notify_error=notify_error, ) if user and user.status != UserStatus.DELETED.value: - logger.info(f'✅ Активный пользователь найден: {user.telegram_id}') + logger.info('✅ Активный пользователь найден', telegram_id=user.telegram_id) profile_updated = False if user.username != message.from_user.username: old_username = user.username user.username = message.from_user.username - logger.info(f"📝 Username обновлен: '{old_username}' → '{user.username}'") + logger.info('📝 Username обновлен', old_username=old_username, username=user.username) profile_updated = True if user.first_name != message.from_user.first_name: old_first_name = user.first_name user.first_name = message.from_user.first_name - logger.info(f"📝 Имя обновлено: '{old_first_name}' → '{user.first_name}'") + logger.info('📝 Имя обновлено', old_first_name=old_first_name, first_name=user.first_name) profile_updated = True if user.last_name != message.from_user.last_name: old_last_name = user.last_name user.last_name = message.from_user.last_name - logger.info(f"📝 Фамилия обновлена: '{old_last_name}' → '{user.last_name}'") + logger.info('📝 Фамилия обновлена', old_last_name=old_last_name, last_name=user.last_name) profile_updated = True user.last_activity = datetime.utcnow() @@ -436,7 +445,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, user.updated_at = datetime.utcnow() await db.commit() await db.refresh(user) - logger.info(f'💾 Профиль пользователя {user.telegram_id} обновлен') + logger.info('💾 Профиль пользователя обновлен', telegram_id=user.telegram_id) else: await db.commit() @@ -459,7 +468,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, ) ) except Exception as e: - logger.error(f'Ошибка отправки уведомления о рекламной кампании: {e}') + logger.error('Ошибка отправки уведомления о рекламной кампании', error=e) has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription) @@ -503,7 +512,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, return if user and user.status == UserStatus.DELETED.value: - logger.info(f'🔄 Удаленный пользователь {user.telegram_id} начинает повторную регистрацию') + logger.info('🔄 Удаленный пользователь начинает повторную регистрацию', telegram_id=user.telegram_id) try: from sqlalchemy import delete, update as sa_update @@ -589,10 +598,10 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, await db.commit() - logger.info(f'✅ Пользователь {user.telegram_id} подготовлен к восстановлению') + logger.info('✅ Пользователь подготовлен к восстановлению', telegram_id=user.telegram_id) except Exception as e: - logger.error(f'❌ Ошибка подготовки к восстановлению: {e}') + logger.error('❌ Ошибка подготовки к восстановлению', error=e) await db.rollback() else: logger.info('🆕 Новый пользователь, начинаем регистрацию') @@ -612,8 +621,8 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, data['language'] = normalized_default await state.set_data(data) logger.info( - "🌐 LANGUAGE: выбор языка отключен, устанавливаем язык по умолчанию '%s'", - normalized_default, + "🌐 LANGUAGE: выбор языка отключен, устанавливаем язык по умолчанию ''", + normalized_default=normalized_default, ) await _continue_registration_after_language( @@ -629,7 +638,9 @@ async def process_language_selection( state: FSMContext, db: AsyncSession, ): - logger.info(f'🌐 LANGUAGE: Пользователь {callback.from_user.id} выбрал язык ({callback.data})') + logger.info( + '🌐 LANGUAGE: Пользователь выбрал язык', from_user_id=callback.from_user.id, callback_data=callback.data + ) if not settings.is_language_selection_enabled(): data = await state.get_data() or {} @@ -680,7 +691,9 @@ async def process_language_selection( if normalized_selected not in available_map: logger.warning( - f"⚠️ LANGUAGE: Выбран недоступный язык '{normalized_selected}' пользователем {callback.from_user.id}" + '⚠️ LANGUAGE: Выбран недоступный язык пользователем', + normalized_selected=normalized_selected, + from_user_id=callback.from_user.id, ) await callback.answer('❌ Unsupported language', show_alert=True) return @@ -698,7 +711,7 @@ async def process_language_selection( texts.t('LANGUAGE_SELECTED', '🌐 Язык интерфейса обновлен.'), ) except Exception as error: - logger.warning(f'⚠️ LANGUAGE: Не удалось обновить сообщение выбора языка: {error}') + logger.warning('⚠️ LANGUAGE: Не удалось обновить сообщение выбора языка', error=error) await callback.message.answer( texts.t('LANGUAGE_SELECTED', '🌐 Язык интерфейса обновлен.'), ) @@ -734,27 +747,30 @@ async def _show_privacy_policy_after_rules( if not privacy_policy_text or not privacy_policy_text.strip(): logger.info('⚠️ Политика конфиденциальности включена, но дефолтный текст пустой, пропускаем показ') return False - logger.info(f'🔒 Используется дефолтный текст политики конфиденциальности из локализации для языка {language}') + logger.info( + '🔒 Используется дефолтный текст политики конфиденциальности из локализации для языка', language=language + ) else: privacy_policy_text = policy.content - logger.info(f'🔒 Используется политика конфиденциальности из БД для языка {language}') + logger.info('🔒 Используется политика конфиденциальности из БД для языка', language=language) try: await callback.message.edit_text(privacy_policy_text, reply_markup=get_privacy_policy_keyboard(language)) await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept) - logger.info(f'🔒 Политика конфиденциальности отправлена пользователю {callback.from_user.id}') + logger.info('🔒 Политика конфиденциальности отправлена пользователю', from_user_id=callback.from_user.id) return True except Exception as e: - logger.error(f'Ошибка при показе политики конфиденциальности: {e}', exc_info=True) + logger.error('Ошибка при показе политики конфиденциальности', error=e, exc_info=True) try: await callback.message.answer(privacy_policy_text, reply_markup=get_privacy_policy_keyboard(language)) await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept) logger.info( - f'🔒 Политика конфиденциальности отправлена новым сообщением пользователю {callback.from_user.id}' + '🔒 Политика конфиденциальности отправлена новым сообщением пользователю', + from_user_id=callback.from_user.id, ) return True except Exception as e2: - logger.error(f'Критическая ошибка при отправке политики конфиденциальности: {e2}', exc_info=True) + logger.error('Критическая ошибка при отправке политики конфиденциальности', e2=e2, exc_info=True) return False @@ -771,13 +787,13 @@ async def _continue_registration_after_rules( texts = get_texts(language) if data.get('referral_code'): - logger.info(f'🎫 Найден реферальный код из deep link: {data["referral_code"]}') + logger.info('🎫 Найден реферальный код из deep link', data=data['referral_code']) referrer = await get_user_by_referral_code(db, data['referral_code']) if referrer: data['referrer_id'] = referrer.id await state.set_data(data) - logger.info(f'✅ Реферер найден: {referrer.id}') + logger.info('✅ Реферер найден', referrer_id=referrer.id) await complete_registration_from_callback(callback, state, db) elif settings.SKIP_REFERRAL_CODE: @@ -795,7 +811,7 @@ async def _continue_registration_after_rules( await state.set_state(RegistrationStates.waiting_for_referral_code) logger.info('🔍 Ожидание ввода реферального кода') except Exception as e: - logger.error(f'Ошибка при показе вопроса о реферальном коде: {e}') + logger.error('Ошибка при показе вопроса о реферальном коде', error=e) await complete_registration_from_callback(callback, state, db) @@ -804,11 +820,11 @@ async def process_rules_accept(callback: types.CallbackQuery, state: FSMContext, Обрабатывает принятие или отклонение правил пользователем. """ logger.info('📋 RULES: Начало обработки правил') - logger.info(f'📊 Callback data: {callback.data}') - logger.info(f'👤 User: {callback.from_user.id}') + logger.info('📊 Callback data', callback_data=callback.data) + logger.info('👤 User', from_user_id=callback.from_user.id) current_state = await state.get_state() - logger.info(f'📊 Текущее состояние: {current_state}') + logger.info('📊 Текущее состояние', current_state=current_state) language = DEFAULT_LANGUAGE texts = get_texts(language) @@ -821,7 +837,7 @@ async def process_rules_accept(callback: types.CallbackQuery, state: FSMContext, texts = get_texts(language) if callback.data == 'rules_accept': - logger.info(f'✅ Правила приняты пользователем {callback.from_user.id}') + logger.info('✅ Правила приняты пользователем', from_user_id=callback.from_user.id) # Пытаемся показать политику конфиденциальности policy_shown = await _show_privacy_policy_after_rules(callback, state, db, language) @@ -831,7 +847,7 @@ async def process_rules_accept(callback: types.CallbackQuery, state: FSMContext, await _continue_registration_after_rules(callback, state, db, language) else: - logger.info(f'❌ Правила отклонены пользователем {callback.from_user.id}') + logger.info('❌ Правила отклонены пользователем', from_user_id=callback.from_user.id) rules_required_text = texts.t( 'RULES_REQUIRED', @@ -844,12 +860,12 @@ async def process_rules_accept(callback: types.CallbackQuery, state: FSMContext, if 'message is not modified' in str(e): pass # Сообщение уже содержит нужный текст else: - logger.error(f'Ошибка при показе сообщения об отклонении правил: {e}') + logger.error('Ошибка при показе сообщения об отклонении правил', error=e) - logger.info(f'✅ Правила обработаны для пользователя {callback.from_user.id}') + logger.info('✅ Правила обработаны для пользователя', from_user_id=callback.from_user.id) except Exception as e: - logger.error(f'❌ Ошибка обработки правил: {e}', exc_info=True) + logger.error('❌ Ошибка обработки правил', error=e, exc_info=True) await callback.answer( texts.t('ERROR_TRY_AGAIN', '❌ Произошла ошибка. Попробуйте еще раз.'), show_alert=True, @@ -873,11 +889,11 @@ async def process_rules_accept(callback: types.CallbackQuery, state: FSMContext, async def process_privacy_policy_accept(callback: types.CallbackQuery, state: FSMContext, db: AsyncSession): logger.info('🔒 PRIVACY POLICY: Начало обработки политики конфиденциальности') - logger.info(f'📊 Callback data: {callback.data}') - logger.info(f'👤 User: {callback.from_user.id}') + logger.info('📊 Callback data', callback_data=callback.data) + logger.info('👤 User', from_user_id=callback.from_user.id) current_state = await state.get_state() - logger.info(f'📊 Текущее состояние: {current_state}') + logger.info('📊 Текущее состояние', current_state=current_state) language = DEFAULT_LANGUAGE texts = get_texts(language) @@ -890,13 +906,13 @@ async def process_privacy_policy_accept(callback: types.CallbackQuery, state: FS texts = get_texts(language) if callback.data == 'privacy_policy_accept': - logger.info(f'✅ Политика конфиденциальности принята пользователем {callback.from_user.id}') + logger.info('✅ Политика конфиденциальности принята пользователем', from_user_id=callback.from_user.id) try: await callback.message.delete() logger.info('🗑️ Сообщение с политикой конфиденциальности удалено') except Exception as e: - logger.warning(f'⚠️ Не удалось удалить сообщение с политикой конфиденциальности: {e}') + logger.warning('⚠️ Не удалось удалить сообщение с политикой конфиденциальности', error=e) try: await callback.message.edit_text( texts.t( @@ -909,13 +925,13 @@ async def process_privacy_policy_accept(callback: types.CallbackQuery, state: FS pass if data.get('referral_code'): - logger.info(f'🎫 Найден реферальный код из deep link: {data["referral_code"]}') + logger.info('🎫 Найден реферальный код из deep link', data=data['referral_code']) referrer = await get_user_by_referral_code(db, data['referral_code']) if referrer: data['referrer_id'] = referrer.id await state.set_data(data) - logger.info(f'✅ Реферер найден: {referrer.id}') + logger.info('✅ Реферер найден', referrer_id=referrer.id) await complete_registration_from_callback(callback, state, db) elif settings.SKIP_REFERRAL_CODE: @@ -936,11 +952,11 @@ async def process_privacy_policy_accept(callback: types.CallbackQuery, state: FS ) logger.info('🔍 Ожидание ввода реферального кода') except Exception as e: - logger.error(f'Ошибка при показе вопроса о реферальном коде: {e}') + logger.error('Ошибка при показе вопроса о реферальном коде', error=e) await complete_registration_from_callback(callback, state, db) else: - logger.info(f'❌ Политика конфиденциальности отклонена пользователем {callback.from_user.id}') + logger.info('❌ Политика конфиденциальности отклонена пользователем', from_user_id=callback.from_user.id) privacy_policy_required_text = texts.t( 'PRIVACY_POLICY_REQUIRED', @@ -953,14 +969,14 @@ async def process_privacy_policy_accept(callback: types.CallbackQuery, state: FS ) except TelegramBadRequest as e: if 'message is not modified' not in str(e): - logger.warning(f'Ошибка при показе сообщения об отклонении политики: {e}') + logger.warning('Ошибка при показе сообщения об отклонении политики', error=e) except Exception as e: - logger.warning(f'Ошибка при показе сообщения об отклонении политики: {e}') + logger.warning('Ошибка при показе сообщения об отклонении политики', error=e) - logger.info(f'✅ Политика конфиденциальности обработана для пользователя {callback.from_user.id}') + logger.info('✅ Политика конфиденциальности обработана для пользователя', from_user_id=callback.from_user.id) except Exception as e: - logger.error(f'❌ Ошибка обработки политики конфиденциальности: {e}', exc_info=True) + logger.error('❌ Ошибка обработки политики конфиденциальности', error=e, exc_info=True) await callback.answer( texts.t('ERROR_TRY_AGAIN', '❌ Произошла ошибка. Попробуйте еще раз.'), show_alert=True, @@ -983,7 +999,7 @@ async def process_privacy_policy_accept(callback: types.CallbackQuery, state: FS async def process_referral_code_input(message: types.Message, state: FSMContext, db: AsyncSession): - logger.info(f'🎫 REFERRAL/PROMO: Обработка кода: {message.text}') + logger.info('🎫 REFERRAL/PROMO: Обработка кода', message_text=message.text) data = await state.get_data() or {} language = data.get('language', DEFAULT_LANGUAGE) @@ -1015,7 +1031,7 @@ async def process_referral_code_input(message: types.Message, state: FSMContext, data['referrer_id'] = referrer.id await state.set_data(data) await message.answer(texts.t('REFERRAL_CODE_ACCEPTED', '✅ Реферальный код принят!')) - logger.info(f'✅ Реферальный код применен: {code}') + logger.info('✅ Реферальный код применен', code=code) await complete_registration(message, state, db) return @@ -1034,7 +1050,7 @@ async def process_referral_code_input(message: types.Message, state: FSMContext, '✅ Промокод принят! Он будет активирован после завершения регистрации.', ) ) - logger.info(f'✅ Промокод сохранен для активации: {code}') + logger.info('✅ Промокод сохранен для активации', code=code) await complete_registration(message, state, db) return @@ -1043,12 +1059,12 @@ async def process_referral_code_input(message: types.Message, state: FSMContext, promo_limiter.cleanup() await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод')) - logger.info(f'❌ Неверный код (ни реферальный, ни промокод): {code}') + logger.info('❌ Неверный код (ни реферальный, ни промокод)', code=code) return async def process_referral_code_skip(callback: types.CallbackQuery, state: FSMContext, db: AsyncSession): - logger.info(f'⭐️ SKIP: Пропуск реферального кода от пользователя {callback.from_user.id}') + logger.info('⭐️ SKIP: Пропуск реферального кода от пользователя', from_user_id=callback.from_user.id) await callback.answer() data = await state.get_data() or {} @@ -1059,7 +1075,7 @@ async def process_referral_code_skip(callback: types.CallbackQuery, state: FSMCo await callback.message.delete() logger.info('🗑️ Сообщение с вопросом о реферальном коде удалено') except Exception as e: - logger.warning(f'⚠️ Не удалось удалить сообщение с вопросом о реферальном коде: {e}') + logger.warning('⚠️ Не удалось удалить сообщение с вопросом о реферальном коде', error=e) try: await callback.message.edit_text( texts.t('REGISTRATION_COMPLETING', '✅ Завершаем регистрацию...'), reply_markup=None @@ -1071,12 +1087,12 @@ async def process_referral_code_skip(callback: types.CallbackQuery, state: FSMCo async def complete_registration_from_callback(callback: types.CallbackQuery, state: FSMContext, db: AsyncSession): - logger.info(f'🎯 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}') + logger.info('🎯 COMPLETE: Завершение регистрации для пользователя', from_user_id=callback.from_user.id) existing_user = await get_user_by_telegram_id(db, callback.from_user.id) if existing_user and existing_user.status == UserStatus.ACTIVE.value: - logger.warning(f'⚠️ Пользователь {callback.from_user.id} уже активен! Показываем главное меню.') + logger.warning('⚠️ Пользователь уже активен! Показываем главное меню.', from_user_id=callback.from_user.id) texts = get_texts(existing_user.language) data = await state.get_data() or {} @@ -1123,7 +1139,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta await callback.message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML') await _send_pinned_message(callback.bot, db, existing_user) except Exception as e: - logger.error(f'Ошибка при показе главного меню существующему пользователю: {e}') + logger.error('Ошибка при показе главного меню существующему пользователю', error=e) await callback.message.answer( texts.t( 'WELCOME_FALLBACK', @@ -1150,7 +1166,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta referrer_id = referrer.id if existing_user and existing_user.status == UserStatus.DELETED.value: - logger.info(f'🔄 Восстанавливаем удаленного пользователя {callback.from_user.id}') + logger.info('🔄 Восстанавливаем удаленного пользователя', from_user_id=callback.from_user.id) existing_user.username = callback.from_user.username existing_user.first_name = callback.from_user.first_name @@ -1170,10 +1186,10 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta await db.refresh(existing_user, ['subscription']) user = existing_user - logger.info(f'✅ Пользователь {callback.from_user.id} восстановлен') + logger.info('✅ Пользователь восстановлен', from_user_id=callback.from_user.id) elif not existing_user: - logger.info(f'🆕 Создаем нового пользователя {callback.from_user.id}') + logger.info('🆕 Создаем нового пользователя', from_user_id=callback.from_user.id) referral_code = await generate_unique_referral_code(db, callback.from_user.id) @@ -1189,7 +1205,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta ) await db.refresh(user, ['subscription']) else: - logger.info(f'🔄 Обновляем существующего пользователя {callback.from_user.id}') + logger.info('🔄 Обновляем существующего пользователя', from_user_id=callback.from_user.id) existing_user.status = UserStatus.ACTIVE.value existing_user.language = language if referrer_id and not existing_user.referred_by_id: @@ -1207,9 +1223,9 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta if referrer_id: try: await process_referral_registration(db, user.id, referrer_id, callback.bot) - logger.info(f'✅ Реферальная регистрация обработана для {user.id}') + logger.info('✅ Реферальная регистрация обработана для', user_id=user.id) except Exception as e: - logger.error(f'Ошибка при обработке реферальной регистрации: {e}') + logger.error('Ошибка при обработке реферальной регистрации', error=e) campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts) @@ -1217,24 +1233,25 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta await db.refresh(user) except Exception as refresh_error: logger.error( - 'Ошибка обновления данных пользователя %s после бонуса кампании: %s', - user.telegram_id, - refresh_error, + 'Ошибка обновления данных пользователя после бонуса кампании', + telegram_id=user.telegram_id, + refresh_error=refresh_error, ) try: await db.refresh(user, ['subscription']) except Exception as refresh_subscription_error: logger.error( - 'Ошибка обновления подписки пользователя %s после бонуса кампании: %s', - user.telegram_id, - refresh_subscription_error, + 'Ошибка обновления подписки пользователя после бонуса кампании', + telegram_id=user.telegram_id, + refresh_subscription_error=refresh_subscription_error, ) # ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload после успешной регистрации await delete_pending_payload_from_redis(callback.from_user.id) logger.info( - '🗑️ COMPLETE_FROM_CALLBACK: Redis payload удален после успешной регистрации пользователя %s', user.telegram_id + '🗑️ COMPLETE_FROM_CALLBACK: Redis payload удален после успешной регистрации пользователя', + telegram_id=user.telegram_id, ) await state.clear() @@ -1243,7 +1260,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta try: await callback.message.answer(campaign_message) except Exception as e: - logger.error(f'Ошибка отправки сообщения о бонусе кампании: {e}') + logger.error('Ошибка отправки сообщения о бонусе кампании', error=e) from app.database.crud.welcome_text import get_welcome_text_for_user @@ -1253,9 +1270,9 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta if skip_welcome_offer: logger.info( - 'ℹ️ Пропускаем приветственное предложение для нового пользователя %s из рекламной кампании %s', - user.telegram_id, - campaign_id, + 'ℹ️ Пропускаем приветственное предложение для нового пользователя из рекламной кампании', + telegram_id=user.telegram_id, + campaign_id=campaign_id, ) if offer_text and not skip_welcome_offer: @@ -1264,11 +1281,11 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta offer_text, reply_markup=get_post_registration_keyboard(user.language), ) - logger.info(f'✅ Приветственное сообщение отправлено пользователю {user.telegram_id}') + logger.info('✅ Приветственное сообщение отправлено пользователю', telegram_id=user.telegram_id) await _send_pinned_message(callback.bot, db, user) except TelegramBadRequest as e: if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower(): - logger.warning(f'HTML parse error в приветственном сообщении, повтор без parse_mode: {e}') + logger.warning('HTML parse error в приветственном сообщении, повтор без parse_mode', error=e) try: await callback.message.answer( offer_text, @@ -1277,14 +1294,15 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta ) await _send_pinned_message(callback.bot, db, user) except Exception as fallback_err: - logger.error(f'Ошибка при повторной отправке приветственного сообщения: {fallback_err}') + logger.error('Ошибка при повторной отправке приветственного сообщения', fallback_err=fallback_err) else: - logger.error(f'Ошибка при отправке приветственного сообщения: {e}') + logger.error('Ошибка при отправке приветственного сообщения', error=e) except Exception as e: - logger.error(f'Ошибка при отправке приветственного сообщения: {e}') + logger.error('Ошибка при отправке приветственного сообщения', error=e) else: logger.info( - f'ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}' + 'ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя', + telegram_id=user.telegram_id, ) has_active_subscription, subscription_is_active = _calculate_subscription_flags( @@ -1321,9 +1339,9 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta ) await callback.message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML') await _send_pinned_message(callback.bot, db, user) - logger.info(f'✅ Главное меню показано пользователю {user.telegram_id}') + logger.info('✅ Главное меню показано пользователю', telegram_id=user.telegram_id) except Exception as e: - logger.error(f'Ошибка при показе главного меню: {e}') + logger.error('Ошибка при показе главного меню', error=e) await callback.message.answer( texts.t( 'WELCOME_FALLBACK', @@ -1331,16 +1349,16 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta ).format(user_name=user.full_name) ) - logger.info(f'✅ Регистрация завершена для пользователя: {user.telegram_id}') + logger.info('✅ Регистрация завершена для пользователя', telegram_id=user.telegram_id) async def complete_registration(message: types.Message, state: FSMContext, db: AsyncSession): - logger.info(f'🎯 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}') + logger.info('🎯 COMPLETE: Завершение регистрации для пользователя', from_user_id=message.from_user.id) existing_user = await get_user_by_telegram_id(db, message.from_user.id) if existing_user and existing_user.status == UserStatus.ACTIVE.value: - logger.warning(f'⚠️ Пользователь {message.from_user.id} уже активен! Показываем главное меню.') + logger.warning('⚠️ Пользователь уже активен! Показываем главное меню.', from_user_id=message.from_user.id) texts = get_texts(existing_user.language) data = await state.get_data() or {} @@ -1387,7 +1405,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A await message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML') await _send_pinned_message(message.bot, db, existing_user) except Exception as e: - logger.error(f'Ошибка при показе главного меню существующему пользователю: {e}') + logger.error('Ошибка при показе главного меню существующему пользователю', error=e) await message.answer( texts.t( 'WELCOME_FALLBACK', @@ -1414,7 +1432,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A referrer_id = referrer.id if existing_user and existing_user.status == UserStatus.DELETED.value: - logger.info(f'🔄 Восстанавливаем удаленного пользователя {message.from_user.id}') + logger.info('🔄 Восстанавливаем удаленного пользователя', from_user_id=message.from_user.id) existing_user.username = message.from_user.username existing_user.first_name = message.from_user.first_name @@ -1434,10 +1452,10 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A await db.refresh(existing_user, ['subscription']) user = existing_user - logger.info(f'✅ Пользователь {message.from_user.id} восстановлен') + logger.info('✅ Пользователь восстановлен', from_user_id=message.from_user.id) elif not existing_user: - logger.info(f'🆕 Создаем нового пользователя {message.from_user.id}') + logger.info('🆕 Создаем нового пользователя', from_user_id=message.from_user.id) referral_code = await generate_unique_referral_code(db, message.from_user.id) @@ -1453,7 +1471,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A ) await db.refresh(user, ['subscription']) else: - logger.info(f'🔄 Обновляем существующего пользователя {message.from_user.id}') + logger.info('🔄 Обновляем существующего пользователя', from_user_id=message.from_user.id) existing_user.status = UserStatus.ACTIVE.value existing_user.language = language if referrer_id and not existing_user.referred_by_id: @@ -1471,9 +1489,9 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A if referrer_id: try: await process_referral_registration(db, user.id, referrer_id, message.bot) - logger.info(f'✅ Реферальная регистрация обработана для {user.id}') + logger.info('✅ Реферальная регистрация обработана для', user_id=user.id) except Exception as e: - logger.error(f'Ошибка при обработке реферальной регистрации: {e}') + logger.error('Ошибка при обработке реферальной регистрации', error=e) # Активируем промокод если был сохранен в state promocode_to_activate = data.get('promocode') @@ -1491,13 +1509,19 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A description=promocode_result['description'] ) ) - logger.info(f'✅ Промокод {promocode_to_activate} активирован для пользователя {user.id}') + logger.info( + '✅ Промокод активирован для пользователя', + promocode_to_activate=promocode_to_activate, + user_id=user.id, + ) else: logger.warning( - f'⚠️ Не удалось активировать промокод {promocode_to_activate}: {promocode_result.get("error")}' + '⚠️ Не удалось активировать промокод', + promocode_to_activate=promocode_to_activate, + get=promocode_result.get('error'), ) except Exception as e: - logger.error(f'❌ Ошибка при активации промокода {promocode_to_activate}: {e}') + logger.error('❌ Ошибка при активации промокода', promocode_to_activate=promocode_to_activate, error=e) campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts) @@ -1505,23 +1529,25 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A await db.refresh(user) except Exception as refresh_error: logger.error( - 'Ошибка обновления данных пользователя %s после бонуса кампании: %s', - user.telegram_id, - refresh_error, + 'Ошибка обновления данных пользователя после бонуса кампании', + telegram_id=user.telegram_id, + refresh_error=refresh_error, ) try: await db.refresh(user, ['subscription']) except Exception as refresh_subscription_error: logger.error( - 'Ошибка обновления подписки пользователя %s после бонуса кампании: %s', - user.telegram_id, - refresh_subscription_error, + 'Ошибка обновления подписки пользователя после бонуса кампании', + telegram_id=user.telegram_id, + refresh_subscription_error=refresh_subscription_error, ) # ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload после успешной регистрации await delete_pending_payload_from_redis(message.from_user.id) - logger.info('🗑️ COMPLETE: Redis payload удален после успешной регистрации пользователя %s', user.telegram_id) + logger.info( + '🗑️ COMPLETE: Redis payload удален после успешной регистрации пользователя', telegram_id=user.telegram_id + ) await state.clear() @@ -1529,7 +1555,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A try: await message.answer(campaign_message) except Exception as e: - logger.error(f'Ошибка отправки сообщения о бонусе кампании: {e}') + logger.error('Ошибка отправки сообщения о бонусе кампании', error=e) from app.database.crud.welcome_text import get_welcome_text_for_user @@ -1539,9 +1565,9 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A if skip_welcome_offer: logger.info( - 'ℹ️ Пропускаем приветственное предложение для нового пользователя %s из рекламной кампании %s', - user.telegram_id, - campaign_id, + 'ℹ️ Пропускаем приветственное предложение для нового пользователя из рекламной кампании', + telegram_id=user.telegram_id, + campaign_id=campaign_id, ) if offer_text and not skip_welcome_offer: @@ -1557,11 +1583,11 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A offer_text, reply_markup=keyboard, ) - logger.info(f'✅ Приветственное сообщение отправлено пользователю {user.telegram_id}') + logger.info('✅ Приветственное сообщение отправлено пользователю', telegram_id=user.telegram_id) await _send_pinned_message(message.bot, db, user) except TelegramBadRequest as e: if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower(): - logger.warning(f'HTML parse error в приветственном сообщении, повтор без parse_mode: {e}') + logger.warning('HTML parse error в приветственном сообщении, повтор без parse_mode', error=e) try: await message.answer( offer_text, @@ -1570,14 +1596,15 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A ) await _send_pinned_message(message.bot, db, user) except Exception as fallback_err: - logger.error(f'Ошибка при повторной отправке приветственного сообщения: {fallback_err}') + logger.error('Ошибка при повторной отправке приветственного сообщения', fallback_err=fallback_err) else: - logger.error(f'Ошибка при отправке приветственного сообщения: {e}') + logger.error('Ошибка при отправке приветственного сообщения', error=e) except Exception as e: - logger.error(f'Ошибка при отправке приветственного сообщения: {e}') + logger.error('Ошибка при отправке приветственного сообщения', error=e) else: logger.info( - f'ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}' + 'ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя', + telegram_id=user.telegram_id, ) has_active_subscription, subscription_is_active = _calculate_subscription_flags( @@ -1613,10 +1640,10 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A custom_buttons=custom_buttons, ) await message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML') - logger.info(f'✅ Главное меню показано пользователю {user.telegram_id}') + logger.info('✅ Главное меню показано пользователю', telegram_id=user.telegram_id) await _send_pinned_message(message.bot, db, user) except Exception as e: - logger.error(f'Ошибка при показе главного меню: {e}') + logger.error('Ошибка при показе главного меню', error=e) await message.answer( texts.t( 'WELCOME_FALLBACK', @@ -1624,7 +1651,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A ).format(user_name=user.full_name) ) - logger.info(f'✅ Регистрация завершена для пользователя: {user.telegram_id}') + logger.info('✅ Регистрация завершена для пользователя', telegram_id=user.telegram_id) def _get_subscription_status(user, texts): @@ -1746,9 +1773,9 @@ async def get_main_menu_text(user, texts, db: AsyncSession): info_sections.append(promo_hint.strip()) except Exception as hint_error: logger.debug( - 'Не удалось построить подсказку промо-предложения для пользователя %s: %s', - getattr(user, 'id', None), - hint_error, + 'Не удалось построить подсказку промо-предложения для пользователя', + getattr=getattr(user, 'id', None), + hint_error=hint_error, ) try: @@ -1757,9 +1784,9 @@ async def get_main_menu_text(user, texts, db: AsyncSession): info_sections.append(test_access_hint.strip()) except Exception as test_error: logger.debug( - 'Не удалось построить подсказку тестового доступа для пользователя %s: %s', - getattr(user, 'id', None), - test_error, + 'Не удалось построить подсказку тестового доступа для пользователя', + getattr=getattr(user, 'id', None), + test_error=test_error, ) if info_sections: @@ -1773,7 +1800,7 @@ async def get_main_menu_text(user, texts, db: AsyncSession): return _insert_random_message(base_text, random_message, action_prompt) except Exception as e: - logger.error(f'Ошибка получения случайного сообщения: {e}') + logger.error('Ошибка получения случайного сообщения', error=e) return base_text @@ -1793,7 +1820,7 @@ async def get_main_menu_text_simple(user_name, texts, db: AsyncSession): return _insert_random_message(base_text, random_message, action_prompt) except Exception as e: - logger.error(f'Ошибка получения случайного сообщения: {e}') + logger.error('Ошибка получения случайного сообщения', error=e) return base_text @@ -1819,15 +1846,12 @@ async def required_sub_channel_check( pending_start_payload = redis_payload state_data['pending_start_payload'] = redis_payload logger.info( - "📦 CHANNEL CHECK: Payload '%s' восстановлен из Redis (fallback)", - pending_start_payload, + "📦 CHANNEL CHECK: Payload '' восстановлен из Redis (fallback)", + pending_start_payload=pending_start_payload, ) if pending_start_payload: - logger.info( - "📦 CHANNEL CHECK: Найден сохраненный payload '%s'", - pending_start_payload, - ) + logger.info("📦 CHANNEL CHECK: Найден сохраненный payload ''", pending_start_payload=pending_start_payload) user = db_user if not user: @@ -1849,8 +1873,8 @@ async def required_sub_channel_check( ]: # НЕ удаляем payload - пользователь может попробовать снова после подписки logger.info( - "📦 CHANNEL CHECK: Подписка не подтверждена, payload '%s' сохранён для следующей попытки", - pending_start_payload, + "📦 CHANNEL CHECK: Подписка не подтверждена, payload '' сохранён для следующей попытки", + pending_start_payload=pending_start_payload, ) return await query.answer( texts.t('CHANNEL_SUBSCRIBE_REQUIRED_ALERT', '❌ Вы не подписались на канал!'), @@ -1884,20 +1908,17 @@ async def required_sub_channel_check( if campaign: state_data['campaign_id'] = campaign.id - logger.info( - '📣 CHANNEL CHECK: Кампания %s восстановлена из payload', - campaign.id, - ) + logger.info('📣 CHANNEL CHECK: Кампания восстановлена из payload', campaign_id=campaign.id) else: state_data['referral_code'] = pending_start_payload logger.info( - '🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код: %s', - pending_start_payload, + '🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код', + pending_start_payload=pending_start_payload, ) else: logger.info( - '✅ CHANNEL CHECK: Реферальный код уже сохранен в state: %s', - state_data.get('referral_code') or f'campaign_id={state_data.get("campaign_id")}', + '✅ CHANNEL CHECK: Реферальный код уже сохранен в state', + state_data=state_data.get('referral_code') or f'campaign_id={state_data.get("campaign_id")}', ) await state.set_data(state_data) @@ -1910,8 +1931,8 @@ async def required_sub_channel_check( await db.commit() await db.refresh(subscription) logger.info( - '✅ Триальная подписка пользователя %s восстановлена после подтверждения подписки на канал', - user.telegram_id, + '✅ Триальная подписка пользователя восстановлена после подтверждения подписки на канал', + telegram_id=user.telegram_id, ) try: @@ -1922,9 +1943,9 @@ async def required_sub_channel_check( await subscription_service.create_remnawave_user(db, subscription) except Exception as api_error: logger.error( - '❌ Ошибка обновления RemnaWave при восстановлении подписки пользователя %s: %s', - user.telegram_id if user else query.from_user.id, - api_error, + '❌ Ошибка обновления RemnaWave при восстановлении подписки пользователя', + telegram_id=user.telegram_id if user else query.from_user.id, + api_error=api_error, ) await query.answer( @@ -1935,7 +1956,7 @@ async def required_sub_channel_check( try: await query.message.delete() except Exception as e: - logger.warning(f'Не удалось удалить сообщение: {e}') + logger.warning('Не удалось удалить сообщение', error=e) # ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload ТОЛЬКО после успешной проверки подписки # и перед показом главного меню или завершением регистрации @@ -2006,7 +2027,7 @@ async def required_sub_channel_check( referrer = await get_user_by_referral_code(db, ref_code_from_link) if referrer: referrer_id = referrer.id - logger.info(f'✅ CHANNEL CHECK: Реферер найден из ссылки: {referrer.id}') + logger.info('✅ CHANNEL CHECK: Реферер найден из ссылки', referrer_id=referrer.id) referral_code = await generate_unique_referral_code(db, query.from_user.id) @@ -2031,9 +2052,9 @@ async def required_sub_channel_check( if referrer_id: try: await process_referral_registration(db, user.id, referrer_id, bot) - logger.info(f'✅ CHANNEL CHECK: Реферальная регистрация обработана для {user.id}') + logger.info('✅ CHANNEL CHECK: Реферальная регистрация обработана для', user_id=user.id) except Exception as e: - logger.error(f'Ошибка при обработке реферальной регистрации: {e}') + logger.error('Ошибка при обработке реферальной регистрации', error=e) # Показываем главное меню после создания пользователя has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription) @@ -2115,13 +2136,13 @@ async def required_sub_channel_check( if 'query is too old' in error_msg or 'query id is invalid' in error_msg: logger.debug('Устаревший callback в required_sub_channel_check, игнорируем') else: - logger.error(f'Ошибка Telegram API в required_sub_channel_check: {e}') + logger.error('Ошибка Telegram API в required_sub_channel_check', error=e) try: await query.answer(f'{texts.ERROR}!', show_alert=True) except Exception: pass except Exception as e: - logger.error(f'Ошибка в required_sub_channel_check: {e}') + logger.error('Ошибка в required_sub_channel_check', error=e) try: await query.answer(f'{texts.ERROR}!', show_alert=True) except Exception: @@ -2129,47 +2150,47 @@ async def required_sub_channel_check( def register_handlers(dp: Dispatcher): - logger.info('🔧 === НАЧАЛО регистрации обработчиков start.py ===') + logger.debug('=== НАЧАЛО регистрации обработчиков start.py ===') dp.message.register(cmd_start, Command('start')) - logger.info('✅ Зарегистрирован cmd_start') + logger.debug('Зарегистрирован cmd_start') dp.callback_query.register( process_rules_accept, F.data.in_(['rules_accept', 'rules_decline']), StateFilter(RegistrationStates.waiting_for_rules_accept), ) - logger.info('✅ Зарегистрирован process_rules_accept') + logger.debug('Зарегистрирован process_rules_accept') dp.callback_query.register( process_privacy_policy_accept, F.data.in_(['privacy_policy_accept', 'privacy_policy_decline']), StateFilter(RegistrationStates.waiting_for_privacy_policy_accept), ) - logger.info('✅ Зарегистрирован process_privacy_policy_accept') + logger.debug('Зарегистрирован process_privacy_policy_accept') dp.callback_query.register( process_language_selection, F.data.startswith('language_select:'), StateFilter(RegistrationStates.waiting_for_language), ) - logger.info('✅ Зарегистрирован process_language_selection') + logger.debug('Зарегистрирован process_language_selection') dp.callback_query.register( process_referral_code_skip, F.data == 'referral_skip', StateFilter(RegistrationStates.waiting_for_referral_code) ) - logger.info('✅ Зарегистрирован process_referral_code_skip') + logger.debug('Зарегистрирован process_referral_code_skip') dp.message.register(process_referral_code_input, StateFilter(RegistrationStates.waiting_for_referral_code)) - logger.info('✅ Зарегистрирован process_referral_code_input') + logger.debug('Зарегистрирован process_referral_code_input') dp.message.register( handle_potential_referral_code, StateFilter(RegistrationStates.waiting_for_rules_accept, RegistrationStates.waiting_for_referral_code), ) - logger.info('✅ Зарегистрирован handle_potential_referral_code') + logger.debug('Зарегистрирован handle_potential_referral_code') dp.callback_query.register(required_sub_channel_check, F.data.in_(['sub_channel_check'])) - logger.info('✅ Зарегистрирован required_sub_channel_check') + logger.debug('Зарегистрирован required_sub_channel_check') - logger.info('🔧 === КОНЕЦ регистрации обработчиков start.py ===') + logger.debug('=== КОНЕЦ регистрации обработчиков start.py ===') diff --git a/app/handlers/subscription/common.py b/app/handlers/subscription/common.py index c5800be0..7dd99d68 100644 --- a/app/handlers/subscription/common.py +++ b/app/handlers/subscription/common.py @@ -1,10 +1,10 @@ import base64 import json -import logging from datetime import datetime from typing import Any from urllib.parse import quote +import structlog from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from app.config import get_traffic_prices, settings @@ -19,7 +19,7 @@ from app.utils.promo_offer import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) TRAFFIC_PRICES = get_traffic_prices() @@ -39,7 +39,7 @@ def _format_text_with_placeholders(template: str, values: dict[str, Any]) -> str try: return template.format_map(safe_values) except Exception: # pragma: no cover - defensive logging - logger.warning("Failed to format template '%s' with values %s", template, values) + logger.warning("Failed to format template '' with values", template=template, values=values) return template @@ -164,7 +164,7 @@ def load_app_config() -> dict[str, Any]: return data logger.error('Некорректный формат app-config.json: ожидается объект') except Exception as e: - logger.error(f'Ошибка загрузки конфига приложений: {e}') + logger.error('Ошибка загрузки конфига приложений', error=e) return {} @@ -317,9 +317,7 @@ def create_deep_link(app: dict[str, Any], subscription_url: str) -> str | None: payload = base64.b64encode(subscription_url.encode('utf-8')).decode('utf-8') except Exception as exc: logger.warning( - 'Не удалось закодировать ссылку подписки в base64 для приложения %s: %s', - app.get('id'), - exc, + 'Не удалось закодировать ссылку подписки в base64 для приложения', app=app.get('id'), exc=exc ) payload = subscription_url diff --git a/app/handlers/subscription/countries.py b/app/handlers/subscription/countries.py index bddd1bbf..3e892cad 100644 --- a/app/handlers/subscription/countries.py +++ b/app/handlers/subscription/countries.py @@ -146,13 +146,13 @@ async def get_countries_price_by_uuids_fallback( return total_price, prices_list except Exception as e: - logger.error(f'Ошибка fallback функции: {e}') + logger.error('Ошибка fallback функции', error=e) default_prices = [0] * len(country_uuids) return sum(default_prices), default_prices async def handle_manage_country(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): - logger.info(f'🔍 Управление страной: {callback.data}') + logger.info('🔍 Управление страной', callback_data=callback.data) country_uuid = callback.data.split('_')[2] @@ -189,7 +189,7 @@ async def handle_manage_country(callback: types.CallbackQuery, db_user: User, db current_selected.append(country_uuid) action = 'added' - logger.info(f'🔍 Страна {country_uuid} {action}') + logger.info('🔍 Страна', country_uuid=country_uuid, action=action) await state.update_data(countries=current_selected) @@ -214,7 +214,7 @@ async def handle_manage_country(callback: types.CallbackQuery, db_user: User, db logger.info('✅ Клавиатура обновлена') except Exception as e: - logger.error(f'⚠ Ошибка обновления клавиатуры: {e}') + logger.error('⚠ Ошибка обновления клавиатуры', error=e) await callback.answer() @@ -251,7 +251,7 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User, ) return - logger.info(f'🔧 Добавлено: {added}, Удалено: {removed}') + logger.info('🔧 Добавлено: Удалено', added=added, removed=removed) months_to_pay = get_remaining_months(subscription.end_date) @@ -298,11 +298,11 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User, if added_names: logger.info( - 'Стоимость новых серверов: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)', - cost_per_month / 100, - charged_months, - total_cost / 100, - total_discount / 100, + 'Стоимость новых серверов: ₽/мес × мес = ₽ (скидка ₽)', + cost_per_month=cost_per_month / 100, + charged_months=charged_months, + total_cost=total_cost / 100, + total_discount=total_discount / 100, ) if total_cost > 0 and db_user.balance_kopeks < total_cost: @@ -377,7 +377,9 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User, await add_user_to_servers(db, added_server_ids) logger.info( - f'📊 Добавлены серверы с ценами за {charged_months} мес: {list(zip(added_server_ids, added_server_prices, strict=False))}' + '📊 Добавлены серверы с ценами за мес', + charged_months=charged_months, + value=list(zip(added_server_ids, added_server_prices, strict=False)), ) subscription.connected_squads = selected_countries @@ -397,7 +399,7 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User, db, db_user, subscription, 'servers', current_countries, selected_countries, total_cost ) except Exception as e: - logger.error(f'Ошибка отправки уведомления об изменении серверов: {e}') + logger.error('Ошибка отправки уведомления об изменении серверов', error=e) success_text = texts.t( 'COUNTRY_CHANGES_SUCCESS_HEADER', @@ -454,11 +456,15 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User, await state.clear() logger.info( - f'✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}, заплатил: {total_cost / 100}₽' + '✅ Пользователь обновил страны. Добавлено: удалено: заплатил: ₽', + telegram_id=db_user.telegram_id, + added_count=len(added), + removed_count=len(removed), + total_cost=total_cost / 100, ) except Exception as e: - logger.error(f'⚠️ Ошибка применения изменений: {e}') + logger.error('⚠️ Ошибка применения изменений', error=e) await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() @@ -557,8 +563,7 @@ async def _get_available_countries(promo_group_id: int | None = None): if promo_group_id is not None and not available_servers: logger.info( - 'Промогруппа %s не имеет доступных серверов, возврат пустого списка', - promo_group_id, + 'Промогруппа не имеет доступных серверов, возврат пустого списка', promo_group_id=promo_group_id ) await cache.set(cache_key_value, [], 60) return [] @@ -613,7 +618,7 @@ async def _get_available_countries(promo_group_id: int | None = None): return countries except Exception as e: - logger.error(f'Ошибка получения списка стран: {e}') + logger.error('Ошибка получения списка стран', error=e) fallback_countries = [ { 'uuid': 'default-free', @@ -662,15 +667,15 @@ def _build_countries_selection_text(countries: list[dict], base_text: str) -> st async def handle_add_country_to_subscription( callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext ): - logger.info(f'🔍 handle_add_country_to_subscription вызван для {db_user.telegram_id}') - logger.info(f'🔍 Callback data: {callback.data}') + logger.info('🔍 handle_add_country_to_subscription вызван для', telegram_id=db_user.telegram_id) + logger.info('🔍 Callback data', callback_data=callback.data) current_state = await state.get_state() - logger.info(f'🔍 Текущее состояние: {current_state}') + logger.info('🔍 Текущее состояние', current_state=current_state) country_uuid = callback.data.split('_')[1] data = await state.get_data() - logger.info(f'🔍 Данные состояния: {data}') + logger.info('🔍 Данные состояния', data=data) selected_countries = data.get('countries', []) countries = await _get_available_countries(db_user.promo_group_id) @@ -682,10 +687,10 @@ async def handle_add_country_to_subscription( if country_uuid in selected_countries: selected_countries.remove(country_uuid) - logger.info(f'🔍 Удалена страна: {country_uuid}') + logger.info('🔍 Удалена страна', country_uuid=country_uuid) else: selected_countries.append(country_uuid) - logger.info(f'🔍 Добавлена страна: {country_uuid}') + logger.info('🔍 Добавлена страна', country_uuid=country_uuid) total_price = 0 subscription = db_user.subscription @@ -715,8 +720,8 @@ async def handle_add_country_to_subscription( data['total_price'] = total_price await state.set_data(data) - logger.info(f'🔍 Новые выбранные страны: {selected_countries}') - logger.info(f'🔍 Общая стоимость: {total_price}') + logger.info('🔍 Новые выбранные страны', selected_countries=selected_countries) + logger.info('🔍 Общая стоимость', total_price=total_price) try: from app.keyboards.inline import get_manage_countries_keyboard @@ -733,7 +738,7 @@ async def handle_add_country_to_subscription( ) logger.info('✅ Клавиатура обновлена') except Exception as e: - logger.error(f'❌ Ошибка обновления клавиатуры: {e}') + logger.error('❌ Ошибка обновления клавиатуры', error=e) await callback.answer() @@ -751,16 +756,16 @@ async def _should_show_countries_management(user: User | None = None) -> bool: if allowed_servers: if len(allowed_servers) > 1: logger.debug( - 'Промогруппа %s имеет %s доступных серверов, показываем управление странами', - promo_group.id, - len(allowed_servers), + 'Промогруппа имеет доступных серверов, показываем управление странами', + promo_group_id=promo_group.id, + allowed_servers_count=len(allowed_servers), ) return True logger.debug( - 'Промогруппа %s имеет всего %s доступный сервер, пропускаем шаг выбора стран', - promo_group.id, - len(allowed_servers), + 'Промогруппа имеет всего доступный сервер, пропускаем шаг выбора стран', + promo_group_id=promo_group.id, + allowed_servers_count=len(allowed_servers), ) return False @@ -768,7 +773,7 @@ async def _should_show_countries_management(user: User | None = None) -> bool: available_countries = [c for c in countries if c.get('is_available', True)] return len(available_countries) > 1 except Exception as e: - logger.error(f'Ошибка проверки доступных серверов: {e}') + logger.error('Ошибка проверки доступных серверов', error=e) return True @@ -925,11 +930,14 @@ async def confirm_add_countries_to_subscription( await callback.message.edit_text(success_text, reply_markup=get_back_keyboard(db_user.language)) logger.info( - f'✅ Пользователь {db_user.telegram_id} обновил страны подписки. Добавлено: {len(new_countries)}, убрано: {len(removed_countries)}' + '✅ Пользователь обновил страны подписки. Добавлено: убрано', + telegram_id=db_user.telegram_id, + new_countries_count=len(new_countries), + removed_countries_count=len(removed_countries), ) except Exception as e: - logger.error(f'Ошибка обновления стран подписки: {e}') + logger.error('Ошибка обновления стран подписки', error=e) await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) await state.clear() diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py index 396a8b11..df12930a 100644 --- a/app/handlers/subscription/devices.py +++ b/app/handlers/subscription/devices.py @@ -66,7 +66,7 @@ async def get_current_devices_detailed(db_user: User) -> dict: return {'count': 0, 'devices': []} except Exception as e: - logger.error(f'Ошибка получения детальной информации об устройствах: {e}') + logger.error('Ошибка получения детальной информации об устройствах', error=e) return {'count': 0, 'devices': []} @@ -85,9 +85,9 @@ async def get_servers_display_names(squad_uuids: list[str]) -> str: server = await get_server_squad_by_uuid(db, uuid) if server: server_names.append(server.display_name) - logger.debug(f'Найден сервер в БД: {uuid} -> {server.display_name}') + logger.debug('Найден сервер в БД', uuid=uuid, display_name=server.display_name) else: - logger.warning(f'Сервер с UUID {uuid} не найден в БД') + logger.warning('Сервер с UUID не найден в БД', uuid=uuid) if not server_names: countries = await _get_available_countries() @@ -95,7 +95,7 @@ async def get_servers_display_names(squad_uuids: list[str]) -> str: for country in countries: if country['uuid'] == uuid: server_names.append(country['name']) - logger.debug(f'Найден сервер в кэше: {uuid} -> {country["name"]}') + logger.debug('Найден сервер в кэше', uuid=uuid, country=country['name']) break if not server_names: @@ -110,7 +110,7 @@ async def get_servers_display_names(squad_uuids: list[str]) -> str: return ', '.join(server_names) except Exception as e: - logger.error(f'Ошибка получения названий серверов: {e}') + logger.error('Ошибка получения названий серверов', error=e) if len(squad_uuids) == 1: return '🎯 Тестовый сервер' return f'{len(squad_uuids)} стран' @@ -134,7 +134,7 @@ async def get_current_devices_count(db_user: User) -> str: return '—' except Exception as e: - logger.error(f'Ошибка получения количества устройств: {e}') + logger.error('Ошибка получения количества устройств', error=e) return '—' @@ -387,10 +387,10 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d }, ) logger.info( - 'Сохранена корзина add_devices для пользователя %s: +%s устройств, цена %s коп.', - db_user.telegram_id, - devices_difference, - price, + 'Сохранена корзина add_devices для пользователя : + устройств, цена коп.', + telegram_id=db_user.telegram_id, + devices_difference=devices_difference, + price=price, ) await callback.message.answer( @@ -457,7 +457,7 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d ), ).format(connected=connected_count, new=new_devices_count) except Exception as e: - logger.error(f'Ошибка проверки устройств: {e}') + logger.error('Ошибка проверки устройств', error=e) confirm_text = texts.t( 'DEVICE_CHANGE_CONFIRMATION', @@ -577,9 +577,10 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d if connected_count > new_devices_count: devices_to_remove = connected_count - new_devices_count logger.info( - f'🔧 Удаление лишних устройств при уменьшении лимита: ' - f'подключено {connected_count}, новый лимит {new_devices_count}, ' - f'удаляем {devices_to_remove}' + '🔧 Удаление лишних устройств при уменьшении лимита: подключено новый лимит удаляем', + connected_count=connected_count, + new_devices_count=new_devices_count, + devices_to_remove=devices_to_remove, ) # Сортируем по дате (последние в конце) и удаляем последние @@ -596,11 +597,13 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d delete_data = {'userUuid': db_user.remnawave_uuid, 'hwid': device_hwid} await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) devices_reset_count += 1 - logger.info(f'✅ Удалено устройство {device_hwid}') + logger.info('✅ Удалено устройство', device_hwid=device_hwid) except Exception as del_error: - logger.error(f'Ошибка удаления устройства {device_hwid}: {del_error}') + logger.error( + 'Ошибка удаления устройства', device_hwid=device_hwid, del_error=del_error + ) except Exception as reset_error: - logger.error(f'Ошибка удаления устройств при уменьшении лимита: {reset_error}') + logger.error('Ошибка удаления устройств при уменьшении лимита', reset_error=reset_error) await db.refresh(db_user) await db.refresh(subscription) @@ -613,7 +616,7 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d db, db_user, subscription, 'devices', current_devices, new_devices_count, price ) except Exception as e: - logger.error(f'Ошибка отправки уведомления об изменении устройств: {e}') + logger.error('Ошибка отправки уведомления об изменении устройств', error=e) if new_devices_count > current_devices: success_text = texts.t( @@ -651,11 +654,15 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d await callback.message.edit_text(success_text, reply_markup=get_back_keyboard(db_user.language)) logger.info( - f'✅ Пользователь {db_user.telegram_id} изменил количество устройств с {current_devices} на {new_devices_count}, доплата: {price / 100}₽' + '✅ Пользователь изменил количество устройств с на доплата: ₽', + telegram_id=db_user.telegram_id, + current_devices=current_devices, + new_devices_count=new_devices_count, + price=price / 100, ) except Exception as e: - logger.error(f'Ошибка изменения количества устройств: {e}') + logger.error('Ошибка изменения количества устройств', error=e) await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() @@ -711,7 +718,7 @@ async def handle_device_management(callback: types.CallbackQuery, db_user: User, ) except Exception as e: - logger.error(f'Ошибка получения списка устройств: {e}') + logger.error('Ошибка получения списка устройств', error=e) await callback.answer( texts.t( 'DEVICE_FETCH_INFO_ERROR', @@ -790,7 +797,7 @@ async def handle_devices_page(callback: types.CallbackQuery, db_user: User, db: ) except Exception as e: - logger.error(f'Ошибка перехода на страницу устройств: {e}') + logger.error('Ошибка перехода на страницу устройств', error=e) await callback.answer( texts.t('DEVICE_PAGE_LOAD_ERROR', '❌ Ошибка загрузки страницы'), show_alert=True, @@ -801,7 +808,7 @@ async def handle_single_device_reset(callback: types.CallbackQuery, db_user: Use try: callback_parts = callback.data.split('_') if len(callback_parts) < 4: - logger.error(f'Некорректный формат callback_data: {callback.data}') + logger.error('Некорректный формат callback_data', callback_data=callback.data) await callback.answer( texts.t('DEVICE_RESET_INVALID_REQUEST', '❌ Ошибка: некорректный запрос'), show_alert=True, @@ -811,10 +818,10 @@ async def handle_single_device_reset(callback: types.CallbackQuery, db_user: Use device_index = int(callback_parts[2]) page = int(callback_parts[3]) - logger.info(f'🔧 Сброс устройства: index={device_index}, page={page}') + logger.info('🔧 Сброс устройства: index=, page', device_index=device_index, page=page) except (ValueError, IndexError) as e: - logger.error(f'❌ Ошибка парсинга callback_data {callback.data}: {e}') + logger.error('❌ Ошибка парсинга callback_data', callback_data=callback.data, error=e) await callback.answer( texts.t('DEVICE_RESET_PARSE_ERROR', '❌ Ошибка обработки запроса'), show_alert=True, @@ -879,7 +886,11 @@ async def handle_single_device_reset(callback: types.CallbackQuery, db_user: Use reply_markup=get_back_keyboard(db_user.language), ) - logger.info(f'✅ Пользователь {db_user.telegram_id} сбросил устройство {device_info}') + logger.info( + '✅ Пользователь сбросил устройство', + telegram_id=db_user.telegram_id, + device_info=device_info, + ) else: await callback.answer( texts.t( @@ -900,7 +911,7 @@ async def handle_single_device_reset(callback: types.CallbackQuery, db_user: Use ) except Exception as e: - logger.error(f'Ошибка сброса устройства: {e}') + logger.error('Ошибка сброса устройства', error=e) await callback.answer( texts.t('DEVICE_RESET_ERROR', '❌ Ошибка сброса устройства'), show_alert=True, @@ -944,7 +955,7 @@ async def handle_all_devices_reset_from_management(callback: types.CallbackQuery ) return - logger.info(f'🔧 Найдено {len(devices_list)} устройств для сброса') + logger.info('🔧 Найдено устройств для сброса', devices_list_count=len(devices_list)) success_count = 0 failed_count = 0 @@ -957,14 +968,16 @@ async def handle_all_devices_reset_from_management(callback: types.CallbackQuery await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) success_count += 1 - logger.info(f'✅ Устройство {device_hwid} удалено') + logger.info('✅ Устройство удалено', device_hwid=device_hwid) except Exception as device_error: failed_count += 1 - logger.error(f'❌ Ошибка удаления устройства {device_hwid}: {device_error}') + logger.error( + '❌ Ошибка удаления устройства', device_hwid=device_hwid, device_error=device_error + ) else: failed_count += 1 - logger.warning(f'⚠️ У устройства нет HWID: {device}') + logger.warning('⚠️ У устройства нет HWID', device=device) if success_count > 0: if failed_count == 0: @@ -981,7 +994,11 @@ async def handle_all_devices_reset_from_management(callback: types.CallbackQuery reply_markup=get_back_keyboard(db_user.language), parse_mode='HTML', ) - logger.info(f'✅ Пользователь {db_user.telegram_id} успешно сбросил {success_count} устройств') + logger.info( + '✅ Пользователь успешно сбросил устройств', + telegram_id=db_user.telegram_id, + success_count=success_count, + ) else: await callback.message.edit_text( texts.t( @@ -997,7 +1014,10 @@ async def handle_all_devices_reset_from_management(callback: types.CallbackQuery parse_mode='HTML', ) logger.warning( - f'⚠️ Частичный сброс у пользователя {db_user.telegram_id}: {success_count}/{len(devices_list)}' + '⚠️ Частичный сброс у пользователя /', + telegram_id=db_user.telegram_id, + success_count=success_count, + devices_list_count=len(devices_list), ) else: await callback.message.edit_text( @@ -1012,10 +1032,12 @@ async def handle_all_devices_reset_from_management(callback: types.CallbackQuery reply_markup=get_back_keyboard(db_user.language), parse_mode='HTML', ) - logger.error(f'❌ Не удалось сбросить ни одного устройства у пользователя {db_user.telegram_id}') + logger.error( + '❌ Не удалось сбросить ни одного устройства у пользователя', telegram_id=db_user.telegram_id + ) except Exception as e: - logger.error(f'Ошибка сброса всех устройств: {e}') + logger.error('Ошибка сброса всех устройств', error=e) await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() @@ -1111,12 +1133,12 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: period_label = f'{charged_months} мес' logger.info( - 'Добавление %s устройств: %.2f₽/мес × %s = %.2f₽ (скидка %.2f₽)', - devices_count, - discounted_per_month / 100, - period_label, - price / 100, - total_discount / 100, + 'Добавление устройств: ₽/мес × = ₽ (скидка ₽)', + devices_count=devices_count, + discounted_per_month=discounted_per_month / 100, + period_label=period_label, + price=price / 100, + total_discount=total_discount / 100, ) if db_user.balance_kopeks < price: @@ -1147,10 +1169,10 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: }, ) logger.info( - 'Сохранена корзина add_devices для пользователя %s: +%s устройств, цена %s коп.', - db_user.telegram_id, - devices_count, - price, + 'Сохранена корзина add_devices для пользователя : + устройств, цена коп.', + telegram_id=db_user.telegram_id, + devices_count=devices_count, + price=price, ) await callback.message.edit_text( @@ -1201,7 +1223,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: db, db_user, subscription, 'devices', old_device_limit, subscription.device_limit, price ) except Exception as e: - logger.error(f'Ошибка отправки уведомления о докупке устройств: {e}') + logger.error('Ошибка отправки уведомления о докупке устройств', error=e) success_text = ( '✅ Устройства успешно добавлены!\n\n' @@ -1214,10 +1236,15 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: await callback.message.edit_text(success_text, reply_markup=get_back_keyboard(db_user.language)) - logger.info(f'✅ Пользователь {db_user.telegram_id} добавил {devices_count} устройств за {price / 100}₽') + logger.info( + '✅ Пользователь добавил устройств за ₽', + telegram_id=db_user.telegram_id, + devices_count=devices_count, + price=price / 100, + ) except Exception as e: - logger.error(f'Ошибка добавления устройств: {e}') + logger.error('Ошибка добавления устройств', error=e) await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() diff --git a/app/handlers/subscription/notifications.py b/app/handlers/subscription/notifications.py index 83d3db11..37e5a99d 100644 --- a/app/handlers/subscription/notifications.py +++ b/app/handlers/subscription/notifications.py @@ -16,7 +16,7 @@ async def send_trial_notification( notification_service = AdminNotificationService(callback.bot) await notification_service.send_trial_activation_notification(db, db_user, subscription) except Exception as e: - logger.error(f'Ошибка отправки уведомления о триале: {e}') + logger.error('Ошибка отправки уведомления о триале', error=e) async def send_purchase_notification( @@ -38,7 +38,7 @@ async def send_purchase_notification( db, db_user, subscription, transaction, period_days, was_trial_conversion ) except Exception as e: - logger.error(f'Ошибка отправки уведомления о покупке: {e}') + logger.error('Ошибка отправки уведомления о покупке', error=e) async def send_extension_notification( @@ -60,4 +60,4 @@ async def send_extension_notification( db, db_user, subscription, transaction, extended_days, old_end_date ) except Exception as e: - logger.error(f'Ошибка отправки уведомления о продлении: {e}') + logger.error('Ошибка отправки уведомления о продлении', error=e) diff --git a/app/handlers/subscription/pricing.py b/app/handlers/subscription/pricing.py index 7d935dee..f491160d 100644 --- a/app/handlers/subscription/pricing.py +++ b/app/handlers/subscription/pricing.py @@ -361,24 +361,24 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int: total_cost = base_cost + servers_cost + traffic_cost + devices_cost - logger.info(f'📊 Месячная стоимость конфигурации подписки {subscription.id}:') + logger.info('📊 Месячная стоимость конфигурации подписки', subscription_id=subscription.id) base_log = f' 📅 Базовый тариф (30 дней): {base_cost_original / 100}₽' if period_discount_percent > 0: discount_value = base_cost_original * period_discount_percent // 100 base_log += f' → {base_cost / 100}₽ (скидка {period_discount_percent}%: -{discount_value / 100}₽)' logger.info(base_log) if servers_cost > 0: - logger.info(f' 🌍 Серверы: {servers_cost / 100}₽') + logger.info('🌍 Серверы: ₽', servers_cost=servers_cost / 100) if traffic_cost > 0: - logger.info(f' 📊 Трафик: {traffic_cost / 100}₽') + logger.info('📊 Трафик: ₽', traffic_cost=traffic_cost / 100) if devices_cost > 0: - logger.info(f' 📱 Устройства: {devices_cost / 100}₽') - logger.info(f' 💎 ИТОГО: {total_cost / 100}₽') + logger.info('📱 Устройства: ₽', devices_cost=devices_cost / 100) + logger.info('💎 ИТОГО: ₽', total_cost=total_cost / 100) return total_cost except Exception as e: - logger.error(f'⚠️ Ошибка расчета стоимости подписки: {e}') + logger.error('⚠️ Ошибка расчета стоимости подписки', error=e) return 0 diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 18ca0867..c3dea8d1 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -1,10 +1,7 @@ -import logging from datetime import datetime, timedelta - - -logger = logging.getLogger(__name__) from typing import Any +import structlog from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext @@ -58,7 +55,7 @@ from app.services.user_cart_service import user_cart_service from app.utils.decorators import error_handler -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _serialize_markup(markup: InlineKeyboardMarkup | None) -> Any | None: @@ -202,7 +199,9 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d # Проверяем и синхронизируем подписку с RemnaWave если необходимо sync_success, sync_error = await subscription_service.ensure_subscription_synced(db, subscription) if not sync_success: - logger.warning(f'Не удалось синхронизировать подписку {subscription.id} с RemnaWave: {sync_error}') + logger.warning( + 'Не удалось синхронизировать подписку с RemnaWave', subscription_id=subscription.id, sync_error=sync_error + ) await db.refresh(subscription) await db.refresh(db_user) @@ -294,12 +293,18 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d devices_count = devices_info.get('total', 0) devices_list = devices_info.get('devices', []) devices_used_str = str(devices_count) - logger.info(f'Найдено {devices_count} устройств для пользователя {db_user.telegram_id}') + logger.info( + 'Найдено устройств для пользователя', + devices_count=devices_count, + telegram_id=db_user.telegram_id, + ) else: - logger.warning(f'Не удалось получить информацию об устройствах для {db_user.telegram_id}') + logger.warning( + 'Не удалось получить информацию об устройствах для', telegram_id=db_user.telegram_id + ) except Exception as e: - logger.error(f'Ошибка получения устройств для отображения: {e}') + logger.error('Ошибка получения устройств для отображения', error=e) devices_used = await get_current_devices_count(db_user) devices_used_str = str(devices_used) @@ -385,7 +390,7 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d tariff_info_block = '\n
' + '\n'.join(tariff_info_lines) + '
' except Exception as e: - logger.warning(f'Ошибка получения тарифа: {e}', exc_info=True) + logger.warning('Ошибка получения тарифа', error=e, exc_info=True) # Определяем, суточный ли тариф для выбора шаблона is_daily_tariff = tariff and getattr(tariff, 'is_daily', False) @@ -604,9 +609,9 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None) if tariff_trial_days: trial_days = tariff_trial_days - logger.info(f'Показываем триал с тарифом {trial_tariff.name}') + logger.info('Показываем триал с тарифом', trial_tariff_name=trial_tariff.name) except Exception as e: - logger.error(f'Ошибка получения триального тарифа: {e}') + logger.error('Ошибка получения триального тарифа', error=e) try: from app.database.crud.server_squad import get_trial_eligible_server_squads @@ -638,7 +643,7 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy logger.warning('Не настроены сквады для выдачи триалов') except Exception as e: - logger.error(f'Ошибка получения триального сервера: {e}') + logger.error('Ошибка получения триального сервера', error=e) if not settings.is_devices_selection_enabled(): forced_limit = settings.get_disabled_mode_device_limit() @@ -859,9 +864,13 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None) if tariff_trial_days: trial_duration = tariff_trial_days - logger.info(f'Используем триальный тариф {trial_tariff.name} (ID: {trial_tariff.id})') + logger.info( + 'Используем триальный тариф (ID: )', + trial_tariff_name=trial_tariff.name, + trial_tariff_id=trial_tariff.id, + ) except Exception as e: - logger.error(f'Ошибка получения триального тарифа: {e}') + logger.error('Ошибка получения триального тарифа', error=e) subscription = await create_trial_subscription( db, @@ -895,9 +904,7 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async return logger.error( - 'Insufficient funds detected after trial creation for user %s: %s', - db_user.id, - error, + 'Insufficient funds detected after trial creation for user', db_user_id=db_user.id, error=error ) required_label = settings.format_price(error.required_amount) balance_label = settings.format_price(error.balance_amount) @@ -951,7 +958,7 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async subscription, ) except RemnaWaveConfigurationError as error: - logger.error('RemnaWave update skipped due to configuration error: %s', error) + logger.error('RemnaWave update skipped due to configuration error', error=error) revert_result = await revert_trial_activation( db, db_user, @@ -983,9 +990,9 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async return except Exception as error: logger.error( - 'Failed to create RemnaWave user for trial subscription %s: %s', - getattr(subscription, 'id', ''), - error, + 'Failed to create RemnaWave user for trial subscription', + getattr=getattr(subscription, 'id', ''), + error=error, ) revert_result = await revert_trial_activation( db, @@ -1028,7 +1035,7 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async charged_amount_kopeks=charged_amount, ) except Exception as e: - logger.error(f'Ошибка отправки уведомления о триале: {e}') + logger.error('Ошибка отправки уведомления о триале', error=e) subscription_link = get_display_subscription_link(subscription) hide_subscription_link = settings.should_hide_subscription_link() @@ -1200,10 +1207,10 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async reply_markup=get_back_keyboard(db_user.language), ) - logger.info(f'✅ Активирована тестовая подписка для пользователя {db_user.telegram_id}') + logger.info('✅ Активирована тестовая подписка для пользователя', telegram_id=db_user.telegram_id) except Exception as e: - logger.error(f'Ошибка активации триала: {e}') + logger.error('Ошибка активации триала', error=e) failure_text = texts.ERROR if subscription and remnawave_user is None: @@ -1411,9 +1418,9 @@ async def return_to_saved_cart(callback: types.CallbackQuery, state: FSMContext, ) except ValueError as recalculation_error: logger.error( - 'Не удалось пересчитать сохраненную корзину пользователя %s: %s', - db_user.telegram_id, - recalculation_error, + 'Не удалось пересчитать сохраненную корзину пользователя', + telegram_id=db_user.telegram_id, + recalculation_error=recalculation_error, ) forced_limit = settings.get_disabled_mode_device_limit() if forced_limit is None: @@ -1646,7 +1653,7 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use } except Exception as e: - logger.error(f'Ошибка расчета цены для периода {days}: {e}') + logger.error('Ошибка расчета цены для периода', days=days, error=e) continue if not renewal_prices: @@ -1847,11 +1854,16 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, original_price) if not is_valid: - logger.error(f'Ошибка в расчете цены продления для пользователя {db_user.telegram_id}') + logger.error('Ошибка в расчете цены продления для пользователя', telegram_id=db_user.telegram_id) await callback.answer('Ошибка расчета цены. Обратитесь в поддержку.', show_alert=True) return - logger.info(f'💰 Расчет продления подписки {subscription.id} на {days} дней ({months_in_period} мес):') + logger.info( + '💰 Расчет продления подписки на дней ( мес)', + subscription_id=subscription.id, + days=days, + months_in_period=months_in_period, + ) base_log = f' 📅 Период {days} дней: {base_price_original / 100}₽' if base_discount_total > 0: base_log += f' → {base_price / 100}₽ (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)' @@ -1888,14 +1900,14 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us ) if promo_component['discount'] > 0: logger.info( - ' 🎯 Промо-предложение: -%s₽ (%s%%)', - promo_component['discount'] / 100, - promo_component['percent'], + '🎯 Промо-предложение: -₽ (%)', + promo_component=promo_component['discount'] / 100, + promo_component_2=promo_component['percent'], ) - logger.info(f' 💎 ИТОГО: {price / 100}₽') + logger.info('💎 ИТОГО: ₽', price=price / 100) except Exception as e: - logger.error(f'⚠ ОШИБКА РАСЧЕТА ЦЕНЫ: {e}') + logger.error('⚠ ОШИБКА РАСЧЕТА ЦЕНЫ', error=e) await callback.answer('⚠ Ошибка расчета стоимости', show_alert=True) return @@ -1980,7 +1992,11 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us subscription.traffic_limit_gb = fixed_limit subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика - logger.info(f'🔄 Сброс трафика при продлении: {old_traffic_limit} ГБ → {fixed_limit} ГБ') + logger.info( + '🔄 Сброс трафика при продлении: ГБ → ГБ', + old_traffic_limit=old_traffic_limit, + fixed_limit=fixed_limit, + ) await db.commit() await db.refresh(subscription) @@ -2022,7 +2038,7 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us else: logger.error('⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE') except Exception as e: - logger.error(f'⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}') + logger.error('⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE', error=e) transaction = await create_transaction( db=db, @@ -2045,7 +2061,7 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us balance_after=refreshed_balance, ) except Exception as e: - logger.error(f'Ошибка отправки уведомления о продлении: {e}') + logger.error('Ошибка отправки уведомления о продлении', error=e) success_message = ( '✅ Подписка успешно продлена!\n\n' @@ -2067,13 +2083,18 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us await callback.message.edit_text(success_message, reply_markup=get_back_keyboard(db_user.language)) - logger.info(f'✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней за {price / 100}₽') + logger.info( + '✅ Пользователь продлил подписку на дней за ₽', + telegram_id=db_user.telegram_id, + days=days, + price=price / 100, + ) except Exception as e: - logger.error(f'⚠ КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ: {e}') + logger.error('⚠ КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ', error=e) import traceback - logger.error(f'TRACEBACK: {traceback.format_exc()}') + logger.error('TRACEBACK', format_exc=traceback.format_exc()) await callback.message.edit_text( '⚠ Произошла ошибка при продлении подписки. Обратитесь в поддержку.', @@ -2419,30 +2440,34 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_ max_allowed_increase = max(500, int(final_price * 0.05)) # 5% или минимум 5₽ if price_difference > max_allowed_increase: logger.error( - f'Цена выросла для пользователя {db_user.telegram_id}: ' - f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽, ' - f'разница=+{price_difference / 100}₽ (>{max_allowed_increase / 100}₽). ' - f'Покупка заблокирована.' + 'Цена выросла для пользователя кэш=₽, пересчет=₽, разница=+₽ (>₽). Покупка заблокирована.', + telegram_id=db_user.telegram_id, + cached_total_price=cached_total_price / 100, + final_price=final_price / 100, + price_difference=price_difference / 100, + max_allowed_increase=max_allowed_increase / 100, ) await callback.answer('Цена изменилась. Пожалуйста, начните оформление заново.', show_alert=True) return if price_difference > 100: # допуск 1₽ logger.warning( - f'Небольшой рост цены для пользователя {db_user.telegram_id}: ' - f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽. ' - f'Используем пересчитанную цену.' + 'Небольшой рост цены для пользователя кэш=₽, пересчет=₽. Используем пересчитанную цену.', + telegram_id=db_user.telegram_id, + cached_total_price=cached_total_price / 100, + final_price=final_price / 100, ) elif price_difference < -100: # цена снизилась более чем на 1₽ logger.info( - f'Цена снизилась для пользователя {db_user.telegram_id}: ' - f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽. ' - f'Применяем новую цену.' + 'Цена снизилась для пользователя кэш=₽, пересчет=₽. Применяем новую цену.', + telegram_id=db_user.telegram_id, + cached_total_price=cached_total_price / 100, + final_price=final_price / 100, ) # Используем пересчитанную цену validation_total_price = calculated_total_before_promo - logger.info(f'Расчет покупки подписки на {data["period_days"]} дней ({months_in_period} мес):') + logger.info('Расчет покупки подписки на дней ( мес)', data=data['period_days'], months_in_period=months_in_period) base_log = f' Период: {base_price_original / 100}₽' if base_discount_total and base_discount_total > 0: base_log += f' → {base_price / 100}₽ (скидка {base_discount_percent}%: -{base_discount_total / 100}₽)' @@ -2468,11 +2493,11 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_ logger.info(message) if promo_offer_discount_value > 0: logger.info( - ' 🎯 Промо-предложение: -%s₽ (%s%%)', - promo_offer_discount_value / 100, - promo_offer_discount_percent, + '🎯 Промо-предложение: -₽ (%)', + promo_offer_discount_value=promo_offer_discount_value / 100, + promo_offer_discount_percent=promo_offer_discount_percent, ) - logger.info(f' ИТОГО: {final_price / 100}₽') + logger.info('ИТОГО: ₽', final_price=final_price / 100) if db_user.balance_kopeks < final_price: missing_kopeks = final_price - db_user.balance_kopeks @@ -2567,12 +2592,12 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_ current_time = datetime.utcnow() if existing_subscription: - logger.info(f'Обновляем существующую подписку пользователя {db_user.telegram_id}') + logger.info('Обновляем существующую подписку пользователя', telegram_id=db_user.telegram_id) bonus_period = timedelta() if existing_subscription.is_trial: - logger.info(f'Конверсия из триала в платную для пользователя {db_user.telegram_id}') + logger.info('Конверсия из триала в платную для пользователя', telegram_id=db_user.telegram_id) was_trial_conversion = True trial_duration = (current_time - existing_subscription.start_date).days @@ -2582,9 +2607,9 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_ if remaining_trial_delta.total_seconds() > 0: bonus_period = remaining_trial_delta logger.info( - 'Добавляем оставшееся время триала (%s) к новой подписке пользователя %s', - bonus_period, - db_user.telegram_id, + 'Добавляем оставшееся время триала к новой подписке пользователя', + bonus_period=bonus_period, + telegram_id=db_user.telegram_id, ) try: @@ -2599,10 +2624,13 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_ first_paid_period_days=period_days, ) logger.info( - f'Записана конверсия: {trial_duration} дн. триал → {period_days} дн. платная за {final_price / 100}₽' + 'Записана конверсия: дн. триал → дн. платная за ₽', + trial_duration=trial_duration, + period_days=period_days, + final_price=final_price / 100, ) except Exception as conversion_error: - logger.error(f'Ошибка записи конверсии: {conversion_error}') + logger.error('Ошибка записи конверсии', conversion_error=conversion_error) existing_subscription.is_trial = False existing_subscription.status = SubscriptionStatus.ACTIVE.value @@ -2650,7 +2678,7 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_ subscription = existing_subscription else: - logger.info(f'Создаем новую подписку для пользователя {db_user.telegram_id}') + logger.info('Создаем новую подписку для пользователя', telegram_id=db_user.telegram_id) default_device_limit = getattr(settings, 'DEFAULT_DEVICE_LIMIT', 1) resolved_device_limit = selected_devices @@ -2704,7 +2732,7 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_ await add_subscription_servers(db, subscription, server_ids, server_prices) await add_user_to_servers(db, server_ids) - logger.info(f'Сохранены цены серверов за весь период: {server_prices}') + logger.info('Сохранены цены серверов за весь период', server_prices=server_prices) await db.refresh(db_user) @@ -2726,7 +2754,7 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_ ) if not remnawave_user: - logger.error(f'Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}') + logger.error('Не удалось создать/обновить RemnaWave пользователя для', telegram_id=db_user.telegram_id) remnawave_user = await subscription_service.create_remnawave_user( db, subscription, @@ -2748,7 +2776,7 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_ db, db_user, subscription, transaction, period_days, was_trial_conversion ) except Exception as e: - logger.error(f'Ошибка отправки уведомления о покупке: {e}') + logger.error('Ошибка отправки уведомления о покупке', error=e) await db.refresh(db_user) await db.refresh(subscription) @@ -2921,11 +2949,14 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_ purchase_completed = True logger.info( - f'Пользователь {db_user.telegram_id} купил подписку на {data["period_days"]} дней за {final_price / 100}₽' + 'Пользователь купил подписку на дней за ₽', + telegram_id=db_user.telegram_id, + data=data['period_days'], + final_price=final_price / 100, ) except Exception as e: - logger.error(f'Ошибка покупки подписки: {e}') + logger.error('Ошибка покупки подписки', error=e) await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) if purchase_completed: @@ -2951,7 +2982,9 @@ async def resume_subscription_checkout( try: summary_text, prepared_data = await _prepare_subscription_summary(db_user, draft, texts) except ValueError as exc: - logger.error(f'Ошибка восстановления заказа подписки для пользователя {db_user.telegram_id}: {exc}') + logger.error( + 'Ошибка восстановления заказа подписки для пользователя', telegram_id=db_user.telegram_id, error=exc + ) await clear_subscription_checkout_draft(db_user.id) await callback.answer(texts.NO_SAVED_SUBSCRIPTION_ORDER, show_alert=True) return @@ -3001,7 +3034,11 @@ async def create_paid_subscription_with_traffic_mode( subscription = await create_paid_subscription(**create_kwargs) - logger.info(f'📋 Создана подписка с трафиком: {traffic_limit_gb} ГБ (режим: {settings.TRAFFIC_SELECTION_MODE})') + logger.info( + '📋 Создана подписка с трафиком: ГБ (режим: )', + traffic_limit_gb=traffic_limit_gb, + TRAFFIC_SELECTION_MODE=settings.TRAFFIC_SELECTION_MODE, + ) return subscription @@ -3157,9 +3194,11 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery, reset_traffic=False, reset_reason=None, ) - logger.info(f'✅ Синхронизировано с Remnawave после возобновления суточной подписки {subscription.id}') + logger.info( + '✅ Синхронизировано с Remnawave после возобновления суточной подписки', subscription_id=subscription.id + ) except Exception as e: - logger.error(f'Ошибка синхронизации с Remnawave при возобновлении: {e}') + logger.error('Ошибка синхронизации с Remnawave при возобновлении', error=e) else: # Подписка активна, ставим на паузу subscription = await toggle_daily_subscription_pause(db, subscription) @@ -3249,7 +3288,7 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user: subscription, ) except RemnaWaveConfigurationError as error: - logger.error('RemnaWave update skipped due to configuration error: %s', error) + logger.error('RemnaWave update skipped due to configuration error', error=error) # Откатываем подписку и возвращаем деньги await rollback_trial_subscription_activation(db, subscription) from app.database.crud.user import add_user_balance @@ -3274,9 +3313,9 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user: return except Exception as error: logger.error( - 'Failed to create RemnaWave user for trial subscription %s: %s', - getattr(subscription, 'id', ''), - error, + 'Failed to create RemnaWave user for trial subscription', + getattr=getattr(subscription, 'id', ''), + error=error, ) # Откатываем подписку и возвращаем деньги await rollback_trial_subscription_activation(db, subscription) @@ -3311,7 +3350,7 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user: charged_amount_kopeks=trial_price_kopeks, ) except Exception as e: - logger.error(f'Ошибка отправки уведомления о триале: {e}') + logger.error('Ошибка отправки уведомления о триале', error=e) # Показываем успешное сообщение с ссылкой subscription_link = get_display_subscription_link(subscription) @@ -3385,9 +3424,7 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user: except Exception as error: logger.error( - 'Unexpected error during paid trial activation for user %s: %s', - user_id_snapshot, - error, + 'Unexpected error during paid trial activation for user', user_id_snapshot=user_id_snapshot, error=error ) # Откатываем сессию чтобы очистить PendingRollbackError try: @@ -3409,9 +3446,7 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user: await db.refresh(db_user) except Exception as refund_error: logger.error( - 'Failed to refund trial payment for user %s: %s', - user_id_snapshot, - refund_error, + 'Failed to refund trial payment for user', user_id_snapshot=user_id_snapshot, refund_error=refund_error ) await callback.message.edit_text( @@ -3694,7 +3729,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us try: usd_rate = await currency_converter.get_usd_to_rub_rate() except Exception as rate_error: - logger.warning('Не удалось получить курс USD: %s', rate_error) + logger.warning('Не удалось получить курс USD', rate_error=rate_error) usd_rate = 95.0 amount_rubles = trial_price_kopeks / 100 @@ -3953,7 +3988,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us await callback.answer() except Exception as error: - logger.error(f'Error processing trial payment method {payment_method}: {error}') + logger.error('Error processing trial payment method', payment_method=payment_method, error=error) await callback.answer('❌ Произошла ошибка при создании платежа. Попробуйте позже.', show_alert=True) @@ -4179,14 +4214,14 @@ async def handle_simple_subscription_purchase( resolved_squad_uuid=subscription_params.get('squad_uuid'), ) logger.debug( - 'SIMPLE_SUBSCRIPTION_PURCHASE_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s', - db_user.id, - price_kopeks, - price_breakdown.get('base_price', 0), - price_breakdown.get('traffic_price', 0), - price_breakdown.get('devices_price', 0), - price_breakdown.get('servers_price', 0), - price_breakdown.get('total_discount', 0), + 'SIMPLE_SUBSCRIPTION_PURCHASE_PRICE | user= | total= | base= | traffic= | devices= | servers= | discount', + db_user_id=db_user.id, + price_kopeks=price_kopeks, + price_breakdown=price_breakdown.get('base_price', 0), + price_breakdown_2=price_breakdown.get('traffic_price', 0), + price_breakdown_3=price_breakdown.get('devices_price', 0), + price_breakdown_4=price_breakdown.get('servers_price', 0), + price_breakdown_5=price_breakdown.get('total_discount', 0), ) traffic_text = ( 'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params["traffic_limit_gb"]} ГБ' @@ -4299,15 +4334,15 @@ async def _extend_existing_subscription( resolved_squad_uuid=squad_uuid, ) logger.warning( - 'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | device_limit=%s', - db_user.id, - price_kopeks, - price_breakdown.get('base_price', 0), - price_breakdown.get('traffic_price', 0), - price_breakdown.get('devices_price', 0), - price_breakdown.get('servers_price', 0), - price_breakdown.get('total_discount', 0), - device_limit, + 'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user= | total= | base= | traffic= | devices= | servers= | discount= | device_limit', + db_user_id=db_user.id, + price_kopeks=price_kopeks, + price_breakdown=price_breakdown.get('base_price', 0), + price_breakdown_2=price_breakdown.get('traffic_price', 0), + price_breakdown_3=price_breakdown.get('devices_price', 0), + price_breakdown_4=price_breakdown.get('servers_price', 0), + price_breakdown_5=price_breakdown.get('total_discount', 0), + device_limit=device_limit, ) # Проверяем баланс пользователя @@ -4429,7 +4464,7 @@ async def _extend_existing_subscription( else: logger.error('⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE') except Exception as e: - logger.error(f'⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}') + logger.error('⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE', error=e) # Создаём транзакцию transaction = await create_transaction( @@ -4454,7 +4489,7 @@ async def _extend_existing_subscription( balance_after=db_user.balance_kopeks, ) except Exception as e: - logger.error(f'Ошибка отправки уведомления о продлении: {e}') + logger.error('Ошибка отправки уведомления о продлении', error=e) # Отправляем сообщение пользователю success_message = ( @@ -4471,6 +4506,9 @@ async def _extend_existing_subscription( await callback.message.edit_text(success_message, reply_markup=get_back_keyboard(db_user.language)) logger.info( - f'✅ Пользователь {db_user.telegram_id} продлил подписку на {period_days} дней за {price_kopeks / 100}₽' + '✅ Пользователь продлил подписку на дней за ₽', + telegram_id=db_user.telegram_id, + period_days=period_days, + price_kopeks=price_kopeks / 100, ) await callback.answer() diff --git a/app/handlers/subscription/summary.py b/app/handlers/subscription/summary.py index 9a1c9502..1d79dc86 100644 --- a/app/handlers/subscription/summary.py +++ b/app/handlers/subscription/summary.py @@ -1,6 +1,6 @@ -import logging from typing import Optional +import structlog from aiogram import types from aiogram.fsm.context import FSMContext @@ -10,7 +10,7 @@ from app.services.subscription_checkout_service import save_subscription_checkou from app.states import SubscriptionStates -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def present_subscription_summary( @@ -35,11 +35,7 @@ async def present_subscription_summary( try: summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts) except ValueError as exc: - logger.error( - 'Ошибка в расчете цены подписки для пользователя %s: %s', - db_user.telegram_id, - exc, - ) + logger.error('Ошибка в расчете цены подписки для пользователя', telegram_id=db_user.telegram_id, exc=exc) await callback.answer('Ошибка расчета цены. Обратитесь в поддержку.', show_alert=True) return False diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index 37e15a81..97c926d7 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -1,8 +1,8 @@ """Покупка подписки по тарифам.""" -import logging from datetime import timedelta +import structlog from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup @@ -22,7 +22,7 @@ from app.utils.decorators import error_handler from app.utils.promo_offer import get_user_active_promo_discount_percent -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _format_traffic(gb: int) -> str: @@ -873,7 +873,7 @@ async def handle_custom_confirm( reset_reason='покупка тарифа', ) except Exception as e: - logger.error(f'Ошибка обновления Remnawave: {e}') + logger.error('Ошибка обновления Remnawave', error=e) # Создаем транзакцию await create_transaction( @@ -897,13 +897,13 @@ async def handle_custom_confirm( amount_kopeks=total_price, ) except Exception as e: - logger.error(f'Ошибка отправки уведомления админу: {e}') + logger.error('Ошибка отправки уведомления админу', error=e) # Очищаем корзину после успешной покупки try: await user_cart_service.delete_user_cart(db_user.id) except Exception as e: - logger.error(f'Ошибка очистки корзины: {e}') + logger.error('Ошибка очистки корзины', error=e) await state.clear() @@ -928,7 +928,7 @@ async def handle_custom_confirm( await callback.answer('Подписка оформлена!', show_alert=True) except Exception as e: - logger.error(f'Ошибка при покупке тарифа с кастомными параметрами: {e}', exc_info=True) + logger.error('Ошибка при покупке тарифа с кастомными параметрами', error=e, exc_info=True) await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True) @@ -1180,7 +1180,7 @@ async def confirm_tariff_purchase( reset_reason='покупка тарифа', ) except Exception as e: - logger.error(f'Ошибка обновления Remnawave: {e}') + logger.error('Ошибка обновления Remnawave', error=e) # Создаем транзакцию await create_transaction( @@ -1204,14 +1204,14 @@ async def confirm_tariff_purchase( amount_kopeks=final_price, ) except Exception as e: - logger.error(f'Ошибка отправки уведомления админу: {e}') + logger.error('Ошибка отправки уведомления админу', error=e) # Очищаем корзину после успешной покупки try: await user_cart_service.delete_user_cart(db_user.id) - logger.info(f'Корзина очищена после покупки тарифа для пользователя {db_user.telegram_id}') + logger.info('Корзина очищена после покупки тарифа для пользователя', telegram_id=db_user.telegram_id) except Exception as e: - logger.error(f'Ошибка очистки корзины: {e}') + logger.error('Ошибка очистки корзины', error=e) await state.clear() @@ -1236,7 +1236,7 @@ async def confirm_tariff_purchase( await callback.answer('Подписка оформлена!', show_alert=True) except Exception as e: - logger.error(f'Ошибка при покупке тарифа: {e}', exc_info=True) + logger.error('Ошибка при покупке тарифа', error=e, exc_info=True) await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True) @@ -1360,7 +1360,7 @@ async def confirm_daily_tariff_purchase( reset_reason='покупка суточного тарифа', ) except Exception as e: - logger.error(f'Ошибка обновления Remnawave: {e}') + logger.error('Ошибка обновления Remnawave', error=e) # Создаем транзакцию await create_transaction( @@ -1384,14 +1384,16 @@ async def confirm_daily_tariff_purchase( amount_kopeks=daily_price, ) except Exception as e: - logger.error(f'Ошибка отправки уведомления админу: {e}') + logger.error('Ошибка отправки уведомления админу', error=e) # Очищаем корзину после успешной покупки try: await user_cart_service.delete_user_cart(db_user.id) - logger.info(f'Корзина очищена после покупки суточного тарифа для пользователя {db_user.telegram_id}') + logger.info( + 'Корзина очищена после покупки суточного тарифа для пользователя', telegram_id=db_user.telegram_id + ) except Exception as e: - logger.error(f'Ошибка очистки корзины: {e}') + logger.error('Ошибка очистки корзины', error=e) await state.clear() @@ -1417,7 +1419,7 @@ async def confirm_daily_tariff_purchase( await callback.answer('Подписка оформлена!', show_alert=True) except Exception as e: - logger.error(f'Ошибка при покупке суточного тарифа: {e}', exc_info=True) + logger.error('Ошибка при покупке суточного тарифа', error=e, exc_info=True) await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True) @@ -1714,7 +1716,7 @@ async def confirm_tariff_extend( reset_reason='продление тарифа', ) except Exception as e: - logger.error(f'Ошибка обновления Remnawave: {e}') + logger.error('Ошибка обновления Remnawave', error=e) # Создаем транзакцию await create_transaction( @@ -1738,14 +1740,14 @@ async def confirm_tariff_extend( amount_kopeks=final_price, ) except Exception as e: - logger.error(f'Ошибка отправки уведомления админу: {e}') + logger.error('Ошибка отправки уведомления админу', error=e) # Очищаем корзину после успешной покупки try: await user_cart_service.delete_user_cart(db_user.id) - logger.info(f'Корзина очищена после продления тарифа для пользователя {db_user.telegram_id}') + logger.info('Корзина очищена после продления тарифа для пользователя', telegram_id=db_user.telegram_id) except Exception as e: - logger.error(f'Ошибка очистки корзины: {e}') + logger.error('Ошибка очистки корзины', error=e) await state.clear() @@ -1769,7 +1771,7 @@ async def confirm_tariff_extend( await callback.answer('Подписка продлена!', show_alert=True) except Exception as e: - logger.error(f'Ошибка при продлении тарифа: {e}', exc_info=True) + logger.error('Ошибка при продлении тарифа', error=e, exc_info=True) await callback.answer('Произошла ошибка при продлении подписки', show_alert=True) @@ -2271,7 +2273,7 @@ async def confirm_tariff_switch( reset_reason='переключение тарифа', ) except Exception as e: - logger.error(f'Ошибка обновления Remnawave при переключении тарифа: {e}') + logger.error('Ошибка обновления Remnawave при переключении тарифа', error=e) # Гарантированный сброс устройств при смене тарифа await db.refresh(db_user) @@ -2282,9 +2284,9 @@ async def confirm_tariff_switch( service = RemnaWaveService() async with service.get_api_client() as api: await api.reset_user_devices(db_user.remnawave_uuid) - logger.info(f'🔧 Сброшены устройства при смене тарифа для user_id={db_user.id}') + logger.info('🔧 Сброшены устройства при смене тарифа для user_id', db_user_id=db_user.id) except Exception as e: - logger.error(f'Ошибка сброса устройств при смене тарифа: {e}') + logger.error('Ошибка сброса устройств при смене тарифа', error=e) # Создаем транзакцию await create_transaction( @@ -2309,14 +2311,14 @@ async def confirm_tariff_switch( purchase_type='tariff_switch', ) except Exception as e: - logger.error(f'Ошибка отправки уведомления админу: {e}') + logger.error('Ошибка отправки уведомления админу', error=e) # Очищаем корзину после успешной покупки try: await user_cart_service.delete_user_cart(db_user.id) - logger.info(f'Корзина очищена после смены тарифа для пользователя {db_user.telegram_id}') + logger.info('Корзина очищена после смены тарифа для пользователя', telegram_id=db_user.telegram_id) except Exception as e: - logger.error(f'Ошибка очистки корзины: {e}') + logger.error('Ошибка очистки корзины', error=e) await state.clear() @@ -2344,7 +2346,7 @@ async def confirm_tariff_switch( await callback.answer('Тариф изменён!', show_alert=True) except Exception as e: - logger.error(f'Ошибка при переключении тарифа: {e}', exc_info=True) + logger.error('Ошибка при переключении тарифа', error=e, exc_info=True) await callback.answer('Произошла ошибка при переключении тарифа', show_alert=True) @@ -2450,7 +2452,7 @@ async def confirm_daily_tariff_switch( reset_reason='смена на суточный тариф', ) except Exception as e: - logger.error(f'Ошибка обновления Remnawave: {e}') + logger.error('Ошибка обновления Remnawave', error=e) # Гарантированный сброс устройств при смене тарифа await db.refresh(db_user) @@ -2461,9 +2463,9 @@ async def confirm_daily_tariff_switch( service = RemnaWaveService() async with service.get_api_client() as api: await api.reset_user_devices(db_user.remnawave_uuid) - logger.info(f'🔧 Сброшены устройства при смене на суточный тариф для user_id={db_user.id}') + logger.info('🔧 Сброшены устройства при смене на суточный тариф для user_id', db_user_id=db_user.id) except Exception as e: - logger.error(f'Ошибка сброса устройств при смене тарифа: {e}') + logger.error('Ошибка сброса устройств при смене тарифа', error=e) # Создаем транзакцию await create_transaction( @@ -2488,7 +2490,7 @@ async def confirm_daily_tariff_switch( purchase_type='tariff_switch', ) except Exception as e: - logger.error(f'Ошибка отправки уведомления админу: {e}') + logger.error('Ошибка отправки уведомления админу', error=e) await state.clear() @@ -2513,7 +2515,7 @@ async def confirm_daily_tariff_switch( await callback.answer('Тариф изменён!', show_alert=True) except Exception as e: - logger.error(f'Ошибка при смене на суточный тариф: {e}', exc_info=True) + logger.error('Ошибка при смене на суточный тариф', error=e, exc_info=True) await callback.answer('Произошла ошибка при смене тарифа', show_alert=True) @@ -3029,7 +3031,7 @@ async def confirm_instant_switch( reset_reason='мгновенное переключение тарифа', ) except Exception as e: - logger.error(f'Ошибка обновления Remnawave при мгновенном переключении: {e}') + logger.error('Ошибка обновления Remnawave при мгновенном переключении', error=e) # Гарантированный сброс устройств при смене тарифа await db.refresh(db_user) @@ -3040,9 +3042,11 @@ async def confirm_instant_switch( service = RemnaWaveService() async with service.get_api_client() as api: await api.reset_user_devices(db_user.remnawave_uuid) - logger.info(f'🔧 Сброшены устройства при мгновенном переключении тарифа для user_id={db_user.id}') + logger.info( + '🔧 Сброшены устройства при мгновенном переключении тарифа для user_id', db_user_id=db_user.id + ) except Exception as e: - logger.error(f'Ошибка сброса устройств при переключении тарифа: {e}') + logger.error('Ошибка сброса устройств при переключении тарифа', error=e) # Создаем транзакцию если была оплата if is_upgrade and upgrade_cost > 0: @@ -3068,7 +3072,7 @@ async def confirm_instant_switch( purchase_type='tariff_switch', ) except Exception as e: - logger.error(f'Ошибка отправки уведомления админу: {e}') + logger.error('Ошибка отправки уведомления админу', error=e) await state.clear() @@ -3117,7 +3121,7 @@ async def confirm_instant_switch( await callback.answer('Тариф изменён!', show_alert=True) except Exception as e: - logger.error(f'Ошибка при мгновенном переключении тарифа: {e}', exc_info=True) + logger.error('Ошибка при мгновенном переключении тарифа', error=e, exc_info=True) await callback.answer('Произошла ошибка при переключении тарифа', show_alert=True) diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py index b0ac129d..38734721 100644 --- a/app/handlers/subscription/traffic.py +++ b/app/handlers/subscription/traffic.py @@ -332,10 +332,10 @@ async def confirm_reset_traffic(callback: types.CallbackQuery, db_user: User, db reply_markup=get_back_keyboard(db_user.language), ) - logger.info(f'✅ Пользователь {db_user.telegram_id} сбросил трафик') + logger.info('✅ Пользователь сбросил трафик', telegram_id=db_user.telegram_id) except Exception as e: - logger.error(f'Ошибка сброса трафика: {e}') + logger.error('Ошибка сброса трафика', error=e) await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() @@ -350,16 +350,16 @@ async def refresh_traffic_config(): packages = settings.get_traffic_packages() enabled_count = sum(1 for pkg in packages if pkg['enabled']) - logger.info(f'🔄 Конфигурация трафика обновлена: {enabled_count} активных пакетов') + logger.info('🔄 Конфигурация трафика обновлена: активных пакетов', enabled_count=enabled_count) for pkg in packages: if pkg['enabled']: gb_text = '♾️ Безлимит' if pkg['gb'] == 0 else f'{pkg["gb"]} ГБ' - logger.info(f' 📦 {gb_text}: {pkg["price"] / 100}₽') + logger.info('📦 ₽', gb_text=gb_text, pkg=pkg['price'] / 100) return True except Exception as e: - logger.error(f'⚠️ Ошибка обновления конфигурации трафика: {e}') + logger.error('⚠️ Ошибка обновления конфигурации трафика', error=e) return False @@ -517,9 +517,11 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes } try: await user_cart_service.save_user_cart(db_user.id, cart_data) - logger.info(f'Cart saved for traffic purchase (bot) user {db_user.telegram_id}: +{traffic_gb} GB') + logger.info( + 'Cart saved for traffic purchase (bot) user +', telegram_id=db_user.telegram_id, traffic_gb=traffic_gb + ) except Exception as e: - logger.error(f'Error saving cart for traffic purchase (bot): {e}') + logger.error('Error saving cart for traffic purchase (bot)', error=e) message_text = texts.t( 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', @@ -599,7 +601,7 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes db, db_user, subscription, 'traffic', old_traffic_limit, subscription.traffic_limit_gb, price ) except Exception as e: - logger.error(f'Ошибка отправки уведомления о докупке трафика: {e}') + logger.error('Ошибка отправки уведомления о докупке трафика', error=e) success_text = '✅ Трафик успешно добавлен!\n\n' if traffic_gb == 0: @@ -615,10 +617,10 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes await callback.message.edit_text(success_text, reply_markup=get_back_keyboard(db_user.language)) - logger.info(f'✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика') + logger.info('✅ Пользователь добавил ГБ трафика', telegram_id=db_user.telegram_id, traffic_gb=traffic_gb) except Exception as e: - logger.error(f'Ошибка добавления трафика: {e}') + logger.error('Ошибка добавления трафика', error=e) await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() @@ -842,7 +844,7 @@ async def execute_switch_traffic(callback: types.CallbackQuery, db_user: User, d db, db_user, subscription, 'traffic', current_traffic, new_traffic_gb, price_difference ) except Exception as e: - logger.error(f'Ошибка отправки уведомления об изменении трафика: {e}') + logger.error('Ошибка отправки уведомления об изменении трафика', error=e) if new_traffic_gb > current_traffic: success_text = '✅ Лимит трафика увеличен!\n\n' @@ -859,11 +861,15 @@ async def execute_switch_traffic(callback: types.CallbackQuery, db_user: User, d await callback.message.edit_text(success_text, reply_markup=get_back_keyboard(db_user.language)) logger.info( - f'✅ Пользователь {db_user.telegram_id} переключил трафик с {current_traffic}GB на {new_traffic_gb}GB, доплата: {price_difference / 100}₽' + '✅ Пользователь переключил трафик с на доплата: ₽', + telegram_id=db_user.telegram_id, + current_traffic=current_traffic, + new_traffic_gb=new_traffic_gb, + price_difference=price_difference / 100, ) except Exception as e: - logger.error(f'Ошибка переключения трафика: {e}') + logger.error('Ошибка переключения трафика', error=e) await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() diff --git a/app/handlers/support.py b/app/handlers/support.py index 0030fdc7..8c50f319 100644 --- a/app/handlers/support.py +++ b/app/handlers/support.py @@ -1,5 +1,4 @@ -import logging - +import structlog from aiogram import Dispatcher, F, types from app.database.models import User @@ -9,7 +8,7 @@ from app.services.support_settings_service import SupportSettingsService from app.utils.photo_message import edit_or_answer_photo -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def show_support_info(callback: types.CallbackQuery, db_user: User): diff --git a/app/handlers/tickets.py b/app/handlers/tickets.py index 548572b0..e4aa5c7d 100644 --- a/app/handlers/tickets.py +++ b/app/handlers/tickets.py @@ -1,7 +1,7 @@ import asyncio -import logging import time +import structlog from aiogram import Bot, Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext @@ -25,7 +25,7 @@ from app.utils.photo_message import edit_or_answer_photo from app.utils.timezone import format_local_datetime -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class TicketStates(StatesGroup): @@ -303,7 +303,7 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext, await notify_admins_about_new_ticket(ticket, db) except Exception as e: - logger.error(f'Error creating ticket: {e}') + logger.error('Error creating ticket', error=e) texts = get_texts(db_user.language) await message.answer( texts.t('TICKET_CREATE_ERROR', '❌ Произошла ошибка при создании тикета. Попробуйте позже.') @@ -889,11 +889,11 @@ async def handle_ticket_reply(message: types.Message, state: FSMContext, db_user await state.clear() # Уведомить админов об ответе пользователя - logger.info(f'Attempting to notify admins about ticket reply #{ticket_id}') + logger.info('Attempting to notify admins about ticket reply #', ticket_id=ticket_id) await notify_admins_about_ticket_reply(ticket, reply_text, db) except Exception as e: - logger.error(f'Error adding ticket reply: {e}') + logger.error('Error adding ticket reply', error=e) texts = get_texts(db_user.language) await message.answer( texts.t('TICKET_REPLY_ERROR', '❌ Произошла ошибка при отправке ответа. Попробуйте позже.') @@ -928,7 +928,7 @@ async def close_ticket(callback: types.CallbackQuery, db_user: User, db: AsyncSe await callback.answer(texts.t('TICKET_CLOSE_ERROR', '❌ Ошибка при закрытии тикета.'), show_alert=True) except Exception as e: - logger.error(f'Error closing ticket: {e}') + logger.error('Error closing ticket', error=e) texts = get_texts(db_user.language) await callback.answer(texts.t('TICKET_CLOSE_ERROR', '❌ Ошибка при закрытии тикета.'), show_alert=True) @@ -990,7 +990,9 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession): from app.config import settings if not settings.is_admin_notifications_enabled(): - logger.info(f'Admin notifications disabled. Ticket #{ticket.id} created by user {ticket.user_id}') + logger.info( + 'Admin notifications disabled. Ticket # created by user', ticket_id=ticket.id, user_id=ticket.user_id + ) return # Получаем язык пользователя для локализации заголовков в уведомлении @@ -1033,17 +1035,17 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession): service = AdminNotificationService(bot) await service.send_ticket_event_notification(notification_text, None) except Exception as e: - logger.error(f'Error notifying admins about new ticket: {e}') + logger.error('Error notifying admins about new ticket', error=e) async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db: AsyncSession): """Уведомить админов об ответе пользователя на тикет""" - logger.info(f'notify_admins_about_ticket_reply called for ticket #{ticket.id}') + logger.info('notify_admins_about_ticket_reply called for ticket #', ticket_id=ticket.id) try: from app.config import settings if not settings.is_admin_notifications_enabled(): - logger.info(f'Admin notifications disabled. Reply to ticket #{ticket.id}') + logger.info('Admin notifications disabled. Reply to ticket #', ticket_id=ticket.id) return title = (ticket.title or '').strip() @@ -1081,9 +1083,9 @@ async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db: service = AdminNotificationService(bot) result = await service.send_ticket_event_notification(notification_text, None) - logger.info(f'Ticket #{ticket.id} reply notification sent: {result}') + logger.info('Ticket # reply notification sent', ticket_id=ticket.id, result=result) except Exception as e: - logger.error(f'Error notifying admins about ticket reply: {e}') + logger.error('Error notifying admins about ticket reply', error=e) def register_handlers(dp: Dispatcher): diff --git a/app/handlers/webhooks.py b/app/handlers/webhooks.py index 87ce9bf8..cd982634 100644 --- a/app/handlers/webhooks.py +++ b/app/handlers/webhooks.py @@ -1,5 +1,4 @@ -import logging - +import structlog from aiogram import Bot, types from aiohttp import web @@ -11,7 +10,7 @@ from app.database.models import PaymentMethod, TransactionType from app.external.tribute import TributeService -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # Глобальная ссылка на бота для отправки уведомлений _bot_instance: Bot | None = None @@ -48,7 +47,7 @@ async def tribute_webhook(request): ) if existing_transaction: - logger.info(f'Платеж {processed_data["payment_id"]} уже обработан') + logger.info('Платеж уже обработан', processed_data=processed_data['payment_id']) return web.Response(status=200, text='Already processed') if processed_data['status'] == 'completed': @@ -72,18 +71,18 @@ async def tribute_webhook(request): external_id=processed_data['payment_id'], ) - logger.info(f'✅ Обработан Tribute платеж: {processed_data["payment_id"]}') + logger.info('✅ Обработан Tribute платеж', processed_data=processed_data['payment_id']) await db.commit() return web.Response(status=200, text='OK') except Exception as e: - logger.error(f'Ошибка обработки Tribute webhook: {e}') + logger.error('Ошибка обработки Tribute webhook', error=e) await db.rollback() return web.Response(status=500, text='Internal error') except Exception as e: - logger.error(f'Ошибка в Tribute webhook: {e}') + logger.error('Ошибка в Tribute webhook', error=e) return web.Response(status=500, text='Internal error') @@ -103,7 +102,9 @@ async def handle_successful_payment(message: types.Message): ) if existing_transaction: - logger.info(f'Stars платеж {payment.telegram_payment_charge_id} уже обработан') + logger.info( + 'Stars платеж уже обработан', telegram_payment_charge_id=payment.telegram_payment_charge_id + ) return user = await get_user_by_id(db, user_id) @@ -129,23 +130,25 @@ async def handle_successful_payment(message: types.Message): f'подписка будет приобретена автоматически после пополнения баланса.' ) - logger.info(f'✅ Обработан Stars платеж: {payment.telegram_payment_charge_id}') + logger.info( + '✅ Обработан Stars платеж', telegram_payment_charge_id=payment.telegram_payment_charge_id + ) await db.commit() except Exception as e: - logger.error(f'Ошибка обработки Stars платежа: {e}') + logger.error('Ошибка обработки Stars платежа', error=e) await db.rollback() except Exception as e: - logger.error(f'Ошибка в обработчике Stars платежа: {e}') + logger.error('Ошибка в обработчике Stars платежа', error=e) async def handle_pre_checkout_query(pre_checkout_query: types.PreCheckoutQuery): try: await pre_checkout_query.answer(ok=True) - logger.info(f'Pre-checkout query принят: {pre_checkout_query.id}') + logger.info('Pre-checkout query принят', pre_checkout_query_id=pre_checkout_query.id) except Exception as e: - logger.error(f'Ошибка в pre-checkout query: {e}') + logger.error('Ошибка в pre-checkout query', error=e) await pre_checkout_query.answer(ok=False, error_message='Ошибка обработки платежа') diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 806aec18..95b47b23 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1,6 +1,6 @@ -import logging from datetime import UTC, datetime +import structlog from aiogram import types from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession @@ -21,7 +21,7 @@ from app.utils.subscription_utils import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def get_main_menu_keyboard_async( @@ -110,7 +110,7 @@ async def get_main_menu_keyboard_async( referral_count = referral_data.get('invited_count', 0) referral_earnings_kopeks = referral_data.get('total_earned_kopeks', 0) except Exception as e: - logger.error(f'Error getting referral data: {e}') + logger.error('Error getting referral data', error=e) context = MenuContext( language=language, @@ -524,8 +524,14 @@ def get_main_menu_keyboard( ) if settings.DEBUG: - print( - f'DEBUG KEYBOARD: language={language}, is_admin={is_admin}, has_had_paid={has_had_paid_subscription}, has_active={has_active_subscription}, sub_active={subscription_is_active}, balance={balance_kopeks}' + logger.debug( + 'DEBUG KEYBOARD', + language=language, + is_admin=is_admin, + has_had_paid=has_had_paid_subscription, + has_active=has_active_subscription, + sub_active=subscription_is_active, + balance=balance_kopeks, ) safe_balance = balance_kopeks or 0 @@ -704,14 +710,14 @@ def get_main_menu_keyboard( keyboard.append(row) if settings.DEBUG: - print(f'DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}') + logger.debug('DEBUG KEYBOARD: админ кнопка', is_admin=is_admin) if is_admin: if settings.DEBUG: - print('DEBUG KEYBOARD: Админ кнопка ДОБАВЛЕНА!') + logger.debug('DEBUG KEYBOARD: Админ кнопка ДОБАВЛЕНА') keyboard.append([InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel')]) elif settings.DEBUG: - print('DEBUG KEYBOARD: Админ кнопка НЕ добавлена') + logger.debug('DEBUG KEYBOARD: Админ кнопка НЕ добавлена') # Moderator access (limited support panel) if (not is_admin) and is_moderator: keyboard.append([InlineKeyboardButton(text='🧑‍⚖️ Модерация', callback_data='moderator_panel')]) @@ -1286,28 +1292,24 @@ def get_subscription_period_keyboard( def get_traffic_packages_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: - import logging - - logger = logging.getLogger(__name__) - from app.config import settings if settings.is_traffic_topup_blocked(): return get_back_keyboard(language) - logger.info(f"🔍 RAW CONFIG: '{settings.TRAFFIC_PACKAGES_CONFIG}'") + logger.info('🔍 RAW CONFIG', TRAFFIC_PACKAGES_CONFIG=settings.TRAFFIC_PACKAGES_CONFIG) all_packages = settings.get_traffic_packages() - logger.info(f'🔍 ALL PACKAGES: {all_packages}') + logger.info('🔍 ALL PACKAGES', all_packages=all_packages) enabled_packages = [pkg for pkg in all_packages if pkg['enabled']] disabled_packages = [pkg for pkg in all_packages if not pkg['enabled']] - logger.info(f'🔍 ENABLED: {len(enabled_packages)} packages') - logger.info(f'🔍 DISABLED: {len(disabled_packages)} packages') + logger.info('🔍 ENABLED: packages', enabled_packages_count=len(enabled_packages)) + logger.info('🔍 DISABLED: packages', disabled_packages_count=len(disabled_packages)) for pkg in disabled_packages: - logger.info(f'🔍 DISABLED PACKAGE: {pkg["gb"]}GB = {pkg["price"]} kopeks, enabled={pkg["enabled"]}') + logger.info('🔍 DISABLED PACKAGE: kopeks, enabled', pkg=pkg['gb'], pkg_2=pkg['price'], pkg_3=pkg['enabled']) texts = get_texts(language) keyboard = [] @@ -2203,7 +2205,9 @@ def get_manage_countries_keyboard( if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) logger.info( - f'🔍 Расчет для управления странами: осталось {months_multiplier} месяцев до {subscription_end_date}' + '🔍 Расчет для управления странами: осталось месяцев до', + months_multiplier=months_multiplier, + subscription_end_date=subscription_end_date, ) buttons = [] @@ -2238,12 +2242,12 @@ def get_manage_countries_keyboard( if months_multiplier > 1: price_text = f' ({discounted_per_month // 100}₽/мес × {months_multiplier} = {total_price // 100}₽)' logger.info( - '🔍 Сервер %s: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)', - name, - discounted_per_month / 100, - months_multiplier, - total_price / 100, - (discount_per_month * months_multiplier) / 100, + '🔍 Сервер : ₽/мес × мес = ₽ (скидка ₽)', + name=name, + discounted_per_month=discounted_per_month / 100, + months_multiplier=months_multiplier, + total_price=total_price / 100, + discount_per_month=(discount_per_month * months_multiplier) / 100, ) else: price_text = f' ({total_price // 100}₽)' @@ -2257,7 +2261,7 @@ def get_manage_countries_keyboard( if total_cost > 0: apply_text = f'✅ Применить изменения ({total_cost // 100} ₽)' - logger.info(f'🔍 Общая стоимость новых серверов: {total_cost / 100}₽') + logger.info('🔍 Общая стоимость новых серверов: ₽', total_cost=total_cost / 100) else: apply_text = '✅ Применить изменения' diff --git a/app/localization/loader.py b/app/localization/loader.py index 9e2248eb..f9b2de9f 100644 --- a/app/localization/loader.py +++ b/app/localization/loader.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import logging import os import shutil import tempfile @@ -9,10 +8,12 @@ from functools import cache from pathlib import Path from typing import Any +import structlog + from app.config import settings -_logger = logging.getLogger(__name__) +_logger = structlog.get_logger(__name__) _FALLBACK_LANGUAGE = 'ru' @@ -84,7 +85,7 @@ def _determine_default_language() -> str: try: available_languages = settings.get_available_languages() except Exception as error: # pragma: no cover - defensive logging - _logger.warning('Failed to load available languages from settings: %s', error) + _logger.warning('Failed to load available languages from settings', error=error) available_languages = [] available_map = { @@ -104,26 +105,26 @@ def _determine_default_language() -> str: if _locale_file_exists(normalized_configured): _logger.warning( - "Configured default language '%s' is not listed in AVAILABLE_LANGUAGES. Falling back to '%s'.", - configured, - _FALLBACK_LANGUAGE, + "Configured default language '' is not listed in AVAILABLE_LANGUAGES. Falling back to ''.", + configured=configured, + FALLBACK_LANGUAGE=_FALLBACK_LANGUAGE, ) else: _logger.warning( - "Configured default language '%s' is not available. Falling back to '%s'.", - configured, - _FALLBACK_LANGUAGE, + "Configured default language '' is not available. Falling back to ''.", + configured=configured, + FALLBACK_LANGUAGE=_FALLBACK_LANGUAGE, ) else: - _logger.debug("DEFAULT_LANGUAGE is not set. Falling back to '%s'.", _FALLBACK_LANGUAGE) + _logger.debug("DEFAULT_LANGUAGE is not set. Falling back to ''.", FALLBACK_LANGUAGE=_FALLBACK_LANGUAGE) fallback_language = _select_fallback_language(available_map) if _normalize_language_code(fallback_language) != _normalize_language_code(_FALLBACK_LANGUAGE): _logger.warning( - "Fallback language '%s' is not available. Using '%s' instead.", - _FALLBACK_LANGUAGE, - fallback_language, + "Fallback language '' is not available. Using '' instead.", + FALLBACK_LANGUAGE=_FALLBACK_LANGUAGE, + fallback_language=fallback_language, ) return fallback_language or _FALLBACK_LANGUAGE @@ -174,24 +175,21 @@ def _directory_is_writable(directory: Path) -> bool: return True except PermissionError as error: _logger.warning( - 'Locale directory %s is not writable%s. Ensure the mounted directory allows writes for the container user or configure LOCALES_PATH to a writable path. (%s)', - directory, - user_hint, - error, + 'Locale directory is not writable. Ensure the mounted directory allows writes for the container user or configure LOCALES_PATH to a writable path.', + directory=directory, + user_hint=user_hint, + error=error, ) except OSError as error: _logger.warning( - 'Unable to prepare locale directory %s for writing%s: %s. Configure LOCALES_PATH to a writable path.', - directory, - user_hint, - error, + 'Unable to prepare locale directory for writing: . Configure LOCALES_PATH to a writable path.', + directory=directory, + user_hint=user_hint, + error=error, ) except Exception as error: # pragma: no cover - defensive logging _logger.warning( - 'Unexpected error while checking locale directory %s%s: %s', - directory, - user_hint, - error, + 'Unexpected error while checking locale directory', directory=directory, user_hint=user_hint, error=error ) return False @@ -201,11 +199,11 @@ def ensure_locale_templates() -> None: try: destination.mkdir(parents=True, exist_ok=True) except Exception as error: - _logger.warning('Unable to create locales directory %s: %s', destination, error) + _logger.warning('Unable to create locales directory', destination=destination, error=error) return if not _DEFAULT_LOCALES_DIR.exists(): - _logger.debug('Default locales directory %s is missing', _DEFAULT_LOCALES_DIR) + _logger.debug('Default locales directory is missing', DEFAULT_LOCALES_DIR=_DEFAULT_LOCALES_DIR) return if not _directory_is_writable(destination): @@ -217,12 +215,7 @@ def ensure_locale_templates() -> None: try: shutil.copyfile(source, target) except Exception as error: - _logger.warning( - 'Failed to copy default locale %s to %s: %s', - source, - target, - error, - ) + _logger.warning('Failed to copy default locale to', source=source, target=target, error=error) if not destination_has_files: for template in _DEFAULT_LOCALES_DIR.iterdir(): @@ -239,7 +232,7 @@ def ensure_locale_templates() -> None: continue if not source_path.exists(): - _logger.debug('Default locale template %s is missing at %s', locale_code, source_path) + _logger.debug('Default locale template is missing at', locale_code=locale_code, source_path=source_path) continue _copy_locale(source_path, target_path) @@ -275,10 +268,10 @@ def _load_locale_file(path: Path) -> dict[str, Any]: ) from import_error return yaml.safe_load(path.read_text(encoding='utf-8')) or {} except Exception as error: - _logger.warning('Failed to parse locale file %s: %s', path, error) + _logger.warning('Failed to parse locale file', path=path, error=error) return {} - _logger.warning('Unsupported locale format for %s', path) + _logger.warning('Unsupported locale format for', path=path) return {} @@ -301,9 +294,7 @@ def load_locale(language: str) -> dict[str, Any]: if not merged and language != DEFAULT_LANGUAGE: _logger.warning( - 'Locale %s not found. Falling back to default language %s.', - language, - DEFAULT_LANGUAGE, + 'Locale not found. Falling back to default language .', language=language, DEFAULT_LANGUAGE=DEFAULT_LANGUAGE ) return load_locale(DEFAULT_LANGUAGE) return merged diff --git a/app/localization/texts.py b/app/localization/texts.py index 61e96cc1..308b2488 100644 --- a/app/localization/texts.py +++ b/app/localization/texts.py @@ -1,9 +1,10 @@ from __future__ import annotations import asyncio -import logging from typing import Any +import structlog + from app.config import settings from app.localization.loader import ( DEFAULT_LANGUAGE, @@ -12,7 +13,7 @@ from app.localization.loader import ( ) -_logger = logging.getLogger(__name__) +_logger = structlog.get_logger(__name__) _cached_rules: dict[str, str] = {} @@ -190,11 +191,7 @@ class Texts: if item in self._fallback_values: return self._fallback_values[item] - _logger.warning( - "Missing localization key '%s' for language '%s'", - item, - self.language, - ) + _logger.warning("Missing localization key '' for language ''", item=item, language=self.language) raise KeyError(item) @staticmethod @@ -232,7 +229,7 @@ async def get_rules_from_db(language: str = DEFAULT_LANGUAGE) -> str: return rules except Exception as error: # pragma: no cover - defensive logging - _logger.warning('Failed to load rules from DB for %s: %s', language, error) + _logger.warning('Failed to load rules from DB for', language=language, error=error) default = _get_default_rules(language) _cached_rules[language] = default diff --git a/app/logging_config.py b/app/logging_config.py new file mode 100644 index 00000000..0bd21c5b --- /dev/null +++ b/app/logging_config.py @@ -0,0 +1,154 @@ +"""Centralized structlog configuration. + +Configures structlog with ProcessorFormatter so that both structlog.get_logger(__name__) +and logging.getLogger() calls produce identically formatted output. + +Usage:: + + from app.logging_config import setup_logging + + file_formatter, console_formatter, telegram_notifier = setup_logging() + # Apply formatters to handlers... + # Later: telegram_notifier.set_bot(bot) +""" + +from __future__ import annotations + +import logging +from typing import Any + +import structlog + +from app.config import settings + + +def _create_timezone_timestamper() -> structlog.types.Processor: + """Create a timestamper processor that uses the configured timezone.""" + from zoneinfo import ZoneInfo + + try: + tz = ZoneInfo(settings.TIMEZONE) + except Exception: + tz = ZoneInfo('UTC') + + from datetime import datetime + + def timestamper(logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]: + dt = datetime.now(tz=tz) + event_dict['timestamp'] = dt.strftime('%Y-%m-%d %H:%M:%S') + return event_dict + + return timestamper + + +def _clean_logger_name(logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]: + """Strip __main__ logger name — it's redundant noise in startup logs.""" + if event_dict.get('logger') == '__main__': + del event_dict['logger'] + return event_dict + + +def setup_logging() -> tuple[logging.Formatter, logging.Formatter, Any]: + """Configure structlog and return formatters + notifier. + + Returns: + (file_formatter, console_formatter, telegram_notifier) + + - file_formatter: ProcessorFormatter without ANSI colors (for file handlers) + - console_formatter: ProcessorFormatter with auto-detected colors (for console) + - telegram_notifier: TelegramNotifierProcessor (call .set_bot(bot) later) + """ + from app.logging_handler import TelegramNotifierProcessor + + telegram_notifier = TelegramNotifierProcessor() + timestamper = _create_timezone_timestamper() + + # Shared processors applied to both structlog and stdlib log entries. + # Order matters: each processor enriches event_dict for the next one. + shared_processors: list[structlog.types.Processor] = [ + structlog.contextvars.merge_contextvars, + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + _clean_logger_name, + structlog.stdlib.ExtraAdder(), + structlog.stdlib.PositionalArgumentsFormatter(), + timestamper, + structlog.processors.StackInfoRenderer(), + # TelegramNotifierProcessor MUST run while exc_info is still a raw + # tuple so it can extract the traceback for Telegram notifications. + # ConsoleRenderer handles exc_info formatting downstream (with Rich + # tracebacks on console, plain text in files). + telegram_notifier, + ] + + # Configure structlog for structlog-originated logs. + # wrap_for_formatter packages event_dict into the stdlib LogRecord + # so ProcessorFormatter can extract and render it. + structlog.configure( + processors=shared_processors + + [ + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + wrapper_class=structlog.make_filtering_bound_logger( + getattr(logging, settings.LOG_LEVEL, logging.INFO), + ), + logger_factory=structlog.stdlib.LoggerFactory(), + # NOTE: cache is safe because LOG_LEVEL is set once at startup. + # If dynamic level changes are ever added, switch to False. + cache_logger_on_first_use=True, + ) + + # File formatter: no ANSI colors, plain tracebacks (safe for log files) + file_formatter = structlog.stdlib.ProcessorFormatter( + foreign_pre_chain=shared_processors, + processors=[ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + structlog.dev.ConsoleRenderer( + colors=False, + pad_event_to=0, + pad_level=False, + exception_formatter=structlog.dev.plain_traceback, + ), + ], + ) + + # Console formatter: colors enabled by default on non-Windows. + # Rich tracebacks with conservative limits to avoid 5000-line dumps. + console_formatter = structlog.stdlib.ProcessorFormatter( + foreign_pre_chain=shared_processors, + processors=[ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + structlog.dev.ConsoleRenderer( + pad_event_to=0, + pad_level=False, + exception_formatter=structlog.dev.RichTracebackFormatter( + show_locals=False, + max_frames=20, + extra_lines=1, + width=120, + suppress=['aiogram', 'aiohttp'], + ), + ), + ], + ) + + _configure_noisy_loggers() + + return file_formatter, console_formatter, telegram_notifier + + +def _configure_noisy_loggers() -> None: + """Suppress noisy third-party loggers.""" + for name, level in { + 'aiohttp.access': logging.ERROR, + 'aiohttp.client': logging.WARNING, + 'aiohttp.internal': logging.WARNING, + 'app.external.remnawave_api': logging.WARNING, + 'aiogram': logging.WARNING, + 'uvicorn.access': logging.ERROR, + 'uvicorn.error': logging.WARNING, + 'uvicorn.protocols.websockets.websockets_impl': logging.WARNING, + 'websockets.server': logging.WARNING, + 'websockets': logging.WARNING, + }.items(): + logging.getLogger(name).setLevel(level) diff --git a/app/logging_handler.py b/app/logging_handler.py index f6298ecd..2cf95624 100644 --- a/app/logging_handler.py +++ b/app/logging_handler.py @@ -1,42 +1,44 @@ -"""Кастомный logging handler для отправки ERROR/CRITICAL в админский чат Telegram. +"""Structlog processor for sending ERROR/CRITICAL logs to admin Telegram chat. -Перехватывает все log records уровня ERROR и CRITICAL и отправляет их -в админский чат через существующий механизм send_error_to_admin_chat() -из app.middlewares.global_error. +Intercepts all log events at ERROR level and above, deduplicates them, +and schedules async delivery to the admin Telegram chat via the existing +``send_error_to_admin_chat`` infrastructure. -Дедупликация: -- Записи, уже обработанные GlobalErrorMiddleware или @error_handler, - помечаются атрибутом _admin_notified = True и пропускаются. -- Хеши недавних сообщений хранятся в LRU-кеше для предотвращения - дублирования одинаковых ошибок за короткий период. +Deduplication: +- Events already processed by GlobalErrorMiddleware / @error_handler + carry ``_admin_notified=True`` and are skipped. +- Recent message hashes are kept in a TTL cache to prevent duplicate + notifications for the same error within a short window. Async bridge: -- logging.Handler.emit() -- синхронный. Мы используем - asyncio.get_running_loop().call_soon_threadsafe() для планирования - asyncio.Task из любого потока (sync или async). +- structlog processors are synchronous. We use + ``asyncio.get_running_loop().call_soon_threadsafe()`` to schedule an + asyncio.Task from any thread. Deferred init: -- Bot instance создаётся позже в main.py. Метод set_bot() позволяет - передать его после создания. До этого записи молча пропускаются. +- The Bot instance is created later in main.py. ``set_bot()`` injects + it after creation. Until then, events are silently passed through. """ from __future__ import annotations import asyncio import hashlib -import logging +import sys +import threading import time -from typing import Final +import traceback +from typing import Any, Final from aiogram import Bot -# Константы +# Constants RECENT_HASHES_MAX_SIZE: Final[int] = 256 -RECENT_HASH_TTL_SECONDS: Final[float] = 300.0 # 5 минут -- совпадает с cooldown в global_error +RECENT_HASH_TTL_SECONDS: Final[float] = 300.0 # 5 min — matches cooldown in global_error -# Логгеры, от которых мы гарантированно не хотим получать уведомления, -# даже если они вдруг выдадут ERROR (шум от транспортного уровня). +# Logger name prefixes we never want notifications from +# (noisy transport-level loggers). IGNORED_LOGGER_PREFIXES: Final[tuple[str, ...]] = ( 'aiohttp.access', 'aiohttp.client', @@ -46,179 +48,236 @@ IGNORED_LOGGER_PREFIXES: Final[tuple[str, ...]] = ( 'uvicorn.protocols', 'websockets', 'asyncio', + # Payment modules — isolated to payments.log, must not leak to Telegram + 'app.payments', + 'app.services.payment', + 'app.services.yookassa_service', + 'app.services.tribute_service', + 'app.services.mulenpay_service', + 'app.services.cloudpayments_service', + 'app.services.platega_service', + 'app.services.pal24_service', + 'app.services.wata_service', + 'app.services.kassa_ai_service', + 'app.services.freekassa_service', + 'app.external.cryptobot', + 'app.external.heleket', + 'app.external.tribute', + 'app.external.yookassa_webhook', + 'app.external.wata_webhook', + 'app.external.heleket_webhook', + 'app.external.pal24_client', + 'app.external.telegram_stars', + 'app.webserver.payments', ) -class TelegramErrorHandler(logging.Handler): - """Logging handler, отправляющий ERROR/CRITICAL записи в админский Telegram-чат. +class TelegramNotifierProcessor: + """Structlog processor that sends ERROR/CRITICAL events to the admin Telegram chat. - Использует существующий механизм троттлинга и буферизации из + Uses the existing throttling and buffering from ``app.middlewares.global_error.send_error_to_admin_chat``. Usage:: - handler = TelegramErrorHandler() - handler.setLevel(logging.ERROR) - logging.getLogger().addHandler(handler) - - # Позже, когда Bot создан: - handler.set_bot(bot) + notifier = TelegramNotifierProcessor() + # Add to shared_processors list in logging_config.py + # Later, when Bot is created: + notifier.set_bot(bot) """ - def __init__(self, level: int = logging.ERROR) -> None: - super().__init__(level=level) + def __init__(self) -> None: self._bot: Bot | None = None - # LRU-подобный кеш хешей недавних сообщений: hash -> timestamp + # LRU-like cache of recent message hashes: hash -> timestamp self._recent_hashes: dict[str, float] = {} + self._lock = threading.Lock() # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def set_bot(self, bot: Bot) -> None: - """Устанавливает Bot instance для отправки сообщений. + """Inject the Bot instance for sending messages. - Вызывается из main.py после создания бота. + Called from main.py after the bot is created. """ self._bot = bot # ------------------------------------------------------------------ - # logging.Handler interface + # Processor interface # ------------------------------------------------------------------ - def emit(self, record: logging.LogRecord) -> None: - """Обрабатывает log record. + def __call__( + self, + logger: Any, + method_name: str, + event_dict: dict[str, Any], + ) -> dict[str, Any]: + """Process a log event. Passthrough — always returns event_dict.""" - Синхронный метод (требование logging). Планирует async-отправку - через event loop. - """ - # 1. Фильтр по уровню (на случай если кто-то обойдёт setLevel) - if record.levelno < logging.ERROR: - return + # 1. Only handle error-level events + level = event_dict.get('level', '') + if level not in ('error', 'critical', 'exception'): + return event_dict - # 2. Уже отправлено через GlobalErrorMiddleware / @error_handler - if getattr(record, '_admin_notified', False): - return + # 2. Already sent via GlobalErrorMiddleware / @error_handler + if event_dict.get('_admin_notified'): + return event_dict - # 3. Фильтруем шумные логгеры - if any(record.name.startswith(prefix) for prefix in IGNORED_LOGGER_PREFIXES): - return + # 3. Filter noisy loggers + logger_name = event_dict.get('logger', '') + if any(logger_name.startswith(prefix) for prefix in IGNORED_LOGGER_PREFIXES): + return event_dict - # 4. Бот ещё не инициализирован -- пропускаем + # 4. Resolve exc_info=True to actual tuple while still in except block. + # logger.exception() sets exc_info=True (bool); we need the tuple for + # traceback extraction. sys.exc_info() works because the processor runs + # synchronously inside the except clause. + exc_info = event_dict.get('exc_info') + if exc_info is True: + event_dict['exc_info'] = sys.exc_info() + + # 5. Bot not initialized yet — skip bot = self._bot if bot is None: - return + return event_dict - # 5. Дедупликация по хешу (logger_name + message) - msg_hash = self._compute_hash(record) + # 6. Deduplication via hash + msg_hash = self._compute_hash(event_dict) now = time.monotonic() - # Чистим просроченные записи (ленивая очистка) - self._evict_stale(now) + with self._lock: + self._evict_stale(now) + if msg_hash in self._recent_hashes: + return event_dict + self._recent_hashes[msg_hash] = now - if msg_hash in self._recent_hashes: - return - self._recent_hashes[msg_hash] = now + # 7. Schedule async send + self._schedule_send(bot, event_dict) - # 6. Планируем отправку через event loop - self._schedule_send(bot, record) + return event_dict # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ @staticmethod - def _compute_hash(record: logging.LogRecord) -> str: - """Вычисляет короткий хеш для дедупликации. + def _compute_hash(event_dict: dict[str, Any]) -> str: + """Compute a short hash for deduplication. - Хешируем имя логгера + сообщение (без timestamp). + Hashes logger name + event message + exception type (if present). """ - raw = f'{record.name}:{record.getMessage()}' + logger_name = event_dict.get('logger', '') + event_msg = event_dict.get('event', '') + + # Include exception type for better dedup granularity + exc_type = '' + exc_info = event_dict.get('exc_info') + if exc_info and isinstance(exc_info, tuple) and exc_info[0] is not None: + exc_type = exc_info[0].__name__ + + raw = f'{logger_name}:{event_msg}:{exc_type}' return hashlib.md5(raw.encode('utf-8', errors='replace')).hexdigest() def _evict_stale(self, now: float) -> None: - """Удаляет устаревшие записи из кеша хешей.""" + """Remove expired entries from the hash cache. Must be called under self._lock.""" if not self._recent_hashes: return stale_keys = [k for k, ts in self._recent_hashes.items() if (now - ts) > RECENT_HASH_TTL_SECONDS] for k in stale_keys: self._recent_hashes.pop(k, None) - # Принудительная очистка при переполнении — удаляем самые старые + # Force eviction on overflow — remove oldest entries if len(self._recent_hashes) > RECENT_HASHES_MAX_SIZE: - sorted_keys = sorted(self._recent_hashes, key=self._recent_hashes.get) + sorted_keys = sorted(self._recent_hashes, key=lambda k: self._recent_hashes[k]) for k in sorted_keys[: len(self._recent_hashes) - RECENT_HASHES_MAX_SIZE]: self._recent_hashes.pop(k, None) - def _schedule_send(self, bot: Bot, record: logging.LogRecord) -> None: - """Планирует асинхронную отправку в event loop. + def _schedule_send(self, bot: Bot, event_dict: dict[str, Any]) -> None: + """Schedule async delivery via event loop. - Работает из любого потока: - - Если вызов из async-контекста -- создаём Task напрямую. - - Если из другого потока -- используем call_soon_threadsafe. + Works from any thread: + - From async context: creates Task directly. + - From other threads: uses call_soon_threadsafe. """ try: loop = asyncio.get_running_loop() except RuntimeError: - # Нет running loop -- мы в стороннем потоке без loop. - # Пытаемся получить loop, привязанный к основному потоку. - try: - loop = asyncio.get_event_loop() - if loop.is_closed(): - return - loop.call_soon_threadsafe(self._create_send_task, bot, record, loop) - except RuntimeError: - return + # No running loop — silently skip. + # The structlog processor runs in sync context; if there's no loop, + # we can't send anything. + return else: - # Мы в async-контексте -- создаём task напрямую - self._create_send_task(bot, record, loop) + # We're in async context — create task directly + self._create_send_task(bot, event_dict, loop) - def _create_send_task(self, bot: Bot, record: logging.LogRecord, loop: asyncio.AbstractEventLoop) -> None: - """Создаёт asyncio.Task для отправки уведомления.""" - loop.create_task(self._send(bot, record)) + def _create_send_task( + self, + bot: Bot, + event_dict: dict[str, Any], + loop: asyncio.AbstractEventLoop, + ) -> None: + """Create an asyncio.Task for sending the notification.""" + loop.create_task(self._send(bot, event_dict)) @staticmethod - async def _send(bot: Bot, record: logging.LogRecord) -> None: - """Отправляет log record в админский чат через существующую инфраструктуру.""" + async def _send(bot: Bot, event_dict: dict[str, Any]) -> None: + """Send the log event to the admin chat via existing infrastructure.""" try: - # Ленивый импорт -- избегаем циклических зависимостей при старте + # Lazy import to avoid circular dependencies at startup from app.middlewares.global_error import send_error_to_admin_chat - # Формируем pseudo-Exception из log record - error = _make_log_record_error(record) + # Build a pseudo-Exception from the event_dict + error = _make_event_dict_error(event_dict) - context_parts: list[str] = [f'Logger: {record.name}'] - if record.funcName: - context_parts.append(f'Function: {record.funcName}') - if record.pathname and record.lineno: - context_parts.append(f'Location: {record.pathname}:{record.lineno}') + # Build rich context from event_dict + context_parts: list[str] = [] + logger_name = event_dict.get('logger', '') + if logger_name: + context_parts.append(f'Logger: {logger_name}') + user_id = event_dict.get('user_id') + username = event_dict.get('username') + if user_id: + user_str = f'User: {user_id}' + if username: + user_str += f' (@{username})' + context_parts.append(user_str) context = '\n'.join(context_parts) - # Извлекаем traceback из log record (если есть exc_info) + # Extract traceback from exc_info if present tb_override: str | None = None - if record.exc_info and record.exc_info[2] is not None: - import traceback - - tb_override = ''.join(traceback.format_exception(*record.exc_info)) - elif record.exc_text: - tb_override = record.exc_text + exc_info = event_dict.get('exc_info') + if exc_info and isinstance(exc_info, tuple) and exc_info[2] is not None: + tb_override = ''.join(traceback.format_exception(*exc_info)) await send_error_to_admin_chat(bot, error, context, tb_override=tb_override) except Exception: - # Ни в коем случае не даём исключению утечь -- это logging handler, - # рекурсия убьёт приложение. + # Never let an exception leak — this is a logging processor, + # recursion would kill the application. pass -def _make_log_record_error(record: logging.LogRecord) -> Exception: - """Создаёт Exception-обёртку для LogRecord. +def _make_event_dict_error(event_dict: dict[str, Any]) -> Exception: + """Create an Exception wrapper for a structlog event_dict. - send_error_to_admin_chat использует type(error).__name__ как error_type. - Мы динамически создаём класс с правильным именем, чтобы не мутировать - общий класс между вызовами. + ``send_error_to_admin_chat`` uses ``type(error).__name__`` as error_type. + If exc_info contains a real exception, use its type name. + Otherwise, create a descriptive class from the log level. """ - class_name = f'Log{record.levelname.capitalize()}' + # Prefer the real exception type from exc_info or error kwarg + exc_info = event_dict.get('exc_info') + if exc_info and isinstance(exc_info, tuple) and exc_info[1] is not None: + real_exc = exc_info[1] + class_name = type(real_exc).__name__ + else: + error_kwarg = event_dict.get('error') + if error_kwarg and isinstance(error_kwarg, BaseException): + class_name = type(error_kwarg).__name__ + else: + level = event_dict.get('level', 'error') + class_name = f'Log{level.capitalize()}' + error_cls = type( class_name, (Exception,), @@ -226,6 +285,7 @@ def _make_log_record_error(record: logging.LogRecord) -> Exception: '__str__': lambda self: self.args[0] if self.args else '', }, ) - error = error_cls(record.getMessage()) - error.record = record # type: ignore[attr-defined] + message = str(event_dict.get('event', '')) + error = error_cls(message) + error.event_dict = event_dict # type: ignore[attr-defined] return error diff --git a/app/middlewares/auth.py b/app/middlewares/auth.py index 603eb302..12c0aed5 100644 --- a/app/middlewares/auth.py +++ b/app/middlewares/auth.py @@ -1,9 +1,9 @@ import asyncio -import logging from collections.abc import Awaitable, Callable from datetime import datetime from typing import Any +import structlog from aiogram import BaseMiddleware from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.fsm.context import FSMContext @@ -19,7 +19,7 @@ from app.utils.check_reg_process import is_registration_process from app.utils.validators import sanitize_telegram_name -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def _refresh_remnawave_description(remnawave_uuid: str, description: str, telegram_id: int) -> None: @@ -27,9 +27,11 @@ async def _refresh_remnawave_description(remnawave_uuid: str, description: str, remnawave_service = RemnaWaveService() async with remnawave_service.get_api_client() as api: await api.update_user(uuid=remnawave_uuid, description=description) - logger.info(f'✅ [Middleware] Описание пользователя {telegram_id} обновлено в RemnaWave') + logger.info('✅ [Middleware] Описание пользователя обновлено в RemnaWave', telegram_id=telegram_id) except Exception as remnawave_error: - logger.error(f'❌ [Middleware] Ошибка обновления RemnaWave для {telegram_id}: {remnawave_error}') + logger.error( + '❌ [Middleware] Ошибка обновления RemnaWave для', telegram_id=telegram_id, remnawave_error=remnawave_error + ) class AuthMiddleware(BaseMiddleware): @@ -73,13 +75,13 @@ class AuthMiddleware(BaseMiddleware): if is_reg_process or is_channel_check or is_start_command: if is_start_command: - logger.info(f'🚀 Пропускаем команду /start от пользователя {user.id}') + logger.info('🚀 Пропускаем команду /start от пользователя', user_id=user.id) elif is_channel_check: logger.info( - f'🔍 Пропускаем незарегистрированного пользователя {user.id} для проверки канала' + '🔍 Пропускаем незарегистрированного пользователя для проверки канала', user_id=user.id ) else: - logger.info(f'🔍 Пропускаем пользователя {user.id} в процессе регистрации') + logger.info('🔍 Пропускаем пользователя в процессе регистрации', user_id=user.id) data['db'] = db data['db_user'] = None data['is_admin'] = False @@ -90,7 +92,7 @@ class AuthMiddleware(BaseMiddleware): await event.answer('▶️ Для начала работы необходимо выполнить команду /start') elif isinstance(event, CallbackQuery): await event.answer('▶️ Необходимо начать с команды /start', show_alert=True) - logger.info(f'🚫 Заблокирован незарегистрированный пользователь {user.id}') + logger.info('🚫 Заблокирован незарегистрированный пользователь', user_id=user.id) return None from app.database.models import UserStatus @@ -99,7 +101,7 @@ class AuthMiddleware(BaseMiddleware): await event.answer('🚫 Ваш аккаунт заблокирован администратором.') elif isinstance(event, CallbackQuery): await event.answer('🚫 Ваш аккаунт заблокирован администратором.', show_alert=True) - logger.info(f'🚫 Заблокированный пользователь {user.id} попытался использовать бота') + logger.info('🚫 Заблокированный пользователь попытался использовать бота', user_id=user.id) return None if db_user.status == UserStatus.DELETED.value: @@ -137,7 +139,7 @@ class AuthMiddleware(BaseMiddleware): ) if is_start_or_registration: - logger.info(f'🔄 Удаленный пользователь {user.id} начинает повторную регистрацию') + logger.info('🔄 Удаленный пользователь начинает повторную регистрацию', user_id=user.id) data['db'] = db data['db_user'] = None data['is_admin'] = False @@ -152,7 +154,7 @@ class AuthMiddleware(BaseMiddleware): await event.answer( '❌ Ваш аккаунт был удален. Для повторной регистрации выполните /start', show_alert=True ) - logger.info(f'❌ Удаленный пользователь {user.id} попытался использовать бота без /start') + logger.info('❌ Удаленный пользователь попытался использовать бота без /start', user_id=user.id) return None profile_updated = False @@ -161,7 +163,10 @@ class AuthMiddleware(BaseMiddleware): old_username = db_user.username db_user.username = user.username logger.info( - f"🔄 [Middleware] Username обновлен для {user.id}: '{old_username}' → '{db_user.username}'" + '🔄 [Middleware] Username обновлен для', + user_id=user.id, + old_username=old_username, + username=db_user.username, ) profile_updated = True @@ -171,7 +176,10 @@ class AuthMiddleware(BaseMiddleware): old_first_name = db_user.first_name db_user.first_name = safe_first logger.info( - f"🔄 [Middleware] Имя обновлено для {user.id}: '{old_first_name}' → '{db_user.first_name}'" + '🔄 [Middleware] Имя обновлено для', + user_id=user.id, + old_first_name=old_first_name, + first_name=db_user.first_name, ) profile_updated = True @@ -179,7 +187,10 @@ class AuthMiddleware(BaseMiddleware): old_last_name = db_user.last_name db_user.last_name = safe_last logger.info( - f"🔄 [Middleware] Фамилия обновлена для {user.id}: '{old_last_name}' → '{db_user.last_name}'" + '🔄 [Middleware] Фамилия обновлена для', + user_id=user.id, + old_last_name=old_last_name, + last_name=db_user.last_name, ) profile_updated = True @@ -187,7 +198,7 @@ class AuthMiddleware(BaseMiddleware): if profile_updated: db_user.updated_at = datetime.utcnow() - logger.info(f'💾 [Middleware] Профиль пользователя {user.id} обновлен в middleware') + logger.info('💾 [Middleware] Профиль пользователя обновлен в middleware', user_id=user.id) if db_user.remnawave_uuid: description = settings.format_remnawave_user_description( @@ -210,15 +221,15 @@ class AuthMiddleware(BaseMiddleware): await db.commit() except (InterfaceError, OperationalError) as conn_err: # Соединение закрылось (таймаут после долгой операции) - просто логируем - logger.warning(f'⚠️ Соединение с БД закрыто после обработки, пропускаем commit: {conn_err}') + logger.warning('⚠️ Соединение с БД закрыто после обработки, пропускаем commit', conn_err=conn_err) return result except (InterfaceError, OperationalError) as conn_err: # Соединение с БД закрылось - не пытаемся rollback - logger.error(f'Ошибка соединения с БД в AuthMiddleware: {conn_err}') - logger.error(f'Event type: {type(event)}') + logger.error('Ошибка соединения с БД в AuthMiddleware', conn_err=conn_err) + logger.error('Event type', event_type=type(event)) if hasattr(event, 'data'): - logger.error(f'Callback data: {event.data}') + logger.error('Callback data', event_data=event.data) raise except TelegramForbiddenError: # User blocked the bot — normal, not an error @@ -230,10 +241,10 @@ class AuthMiddleware(BaseMiddleware): return None raise except Exception as e: - logger.error(f'Ошибка в AuthMiddleware: {e}') - logger.error(f'Event type: {type(event)}') + logger.error('Ошибка в AuthMiddleware', error=e) + logger.error('Event type', event_type=type(event)) if hasattr(event, 'data'): - logger.error(f'Callback data: {event.data}') + logger.error('Callback data', event_data=event.data) try: await db.rollback() except (InterfaceError, OperationalError): diff --git a/app/middlewares/blacklist.py b/app/middlewares/blacklist.py index 25215247..3f1d8692 100644 --- a/app/middlewares/blacklist.py +++ b/app/middlewares/blacklist.py @@ -1,14 +1,14 @@ -import logging from collections.abc import Awaitable, Callable from typing import Any +import structlog from aiogram import BaseMiddleware from aiogram.types import CallbackQuery, Message, PreCheckoutQuery, TelegramObject, User as TgUser from app.services.blacklist_service import blacklist_service -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class BlacklistMiddleware(BaseMiddleware): @@ -30,7 +30,7 @@ class BlacklistMiddleware(BaseMiddleware): if not is_blacklisted: return await handler(event, data) - logger.warning(f'🚫 Пользователь {user.id} (@{user.username}) из черного списка: {reason}') + logger.warning('🚫 Пользователь (@) из черного списка', user_id=user.id, username=user.username, reason=reason) block_text = ( f'🚫 Доступ запрещен\n\nПричина: {reason}\n\nЕсли вы считаете, что это ошибка, обратитесь в поддержку.' @@ -44,6 +44,6 @@ class BlacklistMiddleware(BaseMiddleware): elif isinstance(event, PreCheckoutQuery): await event.answer(ok=False, error_message='Доступ запрещен') except Exception as e: - logger.error(f'Ошибка отправки сообщения о блокировке пользователю {user.id}: {e}') + logger.error('Ошибка отправки сообщения о блокировке пользователю', user_id=user.id, error=e) return None diff --git a/app/middlewares/button_stats.py b/app/middlewares/button_stats.py index c5260123..0b33a79f 100644 --- a/app/middlewares/button_stats.py +++ b/app/middlewares/button_stats.py @@ -1,10 +1,10 @@ """Middleware для автоматического логирования кликов по кнопкам.""" import asyncio -import logging from collections.abc import Awaitable, Callable from typing import Any +import structlog from aiogram import BaseMiddleware from aiogram.types import CallbackQuery, TelegramObject @@ -12,7 +12,7 @@ from app.config import settings from app.database.database import AsyncSessionLocal -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # Известные builtin callback_data из меню BUILTIN_CALLBACKS: set[str] = { @@ -134,7 +134,7 @@ class ButtonStatsMiddleware(BaseMiddleware): ) except Exception as e: # Не прерываем обработку при ошибке логирования - logger.error(f'Ошибка логирования клика по кнопке: {e}', exc_info=True) + logger.error('Ошибка логирования клика по кнопке', error=e, exc_info=True) # Продолжаем обработку return await handler(event, data) @@ -204,6 +204,6 @@ class ButtonStatsMiddleware(BaseMiddleware): button_text=button_text, ) except Exception as e: - logger.warning(f'Ошибка записи клика в БД {button_id}: {e}') + logger.warning('Ошибка записи клика в БД', button_id=button_id, error=e) except Exception as e: - logger.warning(f'Ошибка создания сессии БД для логирования клика: {e}') + logger.warning('Ошибка создания сессии БД для логирования клика', error=e) diff --git a/app/middlewares/channel_checker.py b/app/middlewares/channel_checker.py index c46f0105..f6be0023 100644 --- a/app/middlewares/channel_checker.py +++ b/app/middlewares/channel_checker.py @@ -1,9 +1,9 @@ -import logging from collections.abc import Awaitable, Callable from datetime import datetime from typing import Any import redis.asyncio as aioredis +import structlog from aiogram import BaseMiddleware, Bot, types from aiogram.enums import ChatMemberStatus from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError @@ -24,7 +24,7 @@ from app.services.subscription_service import SubscriptionService from app.utils.check_reg_process import is_registration_process -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # Ключ для хранения pending_start_payload в Redis (резервный механизм) REDIS_PAYLOAD_KEY_PREFIX = 'pending_start_payload:' @@ -39,17 +39,11 @@ async def save_pending_payload_to_redis(telegram_id: int, payload: str) -> bool: await redis_client.set(key, payload, ex=REDIS_PAYLOAD_TTL) await redis_client.aclose() logger.info( - "💾 [Redis fallback] Сохранен payload '%s' для пользователя %s", - payload, - telegram_id, + "💾 [Redis fallback] Сохранен payload '' для пользователя", payload=payload, telegram_id=telegram_id ) return True except Exception as e: - logger.error( - '❌ [Redis fallback] Ошибка сохранения payload для %s: %s', - telegram_id, - e, - ) + logger.error('❌ [Redis fallback] Ошибка сохранения payload для', telegram_id=telegram_id, e=e) return False @@ -64,7 +58,7 @@ async def get_pending_payload_from_redis(telegram_id: int) -> str | None: return payload.decode('utf-8') if isinstance(payload, bytes) else payload return None except Exception as e: - logger.debug('❌ [Redis fallback] Ошибка получения payload для %s: %s', telegram_id, e) + logger.debug('❌ [Redis fallback] Ошибка получения payload для', telegram_id=telegram_id, e=e) return None @@ -121,8 +115,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): # Админам разрешаем пропускать проверку подписки if settings.is_admin(telegram_id): logger.debug( - '✅ Пользователь %s является администратором — пропускаем проверку подписки', - telegram_id, + '✅ Пользователь является администратором — пропускаем проверку подписки', telegram_id=telegram_id ) return await handler(event, data) @@ -166,7 +159,9 @@ class ChannelCheckerMiddleware(BaseMiddleware): await self._reactivate_subscription_on_subscribe(telegram_id, bot) return await handler(event, data) if member.status in self.BAD_MEMBER_STATUS: - logger.info(f'❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})') + logger.info( + '❌ Пользователь не подписан на канал (статус: )', telegram_id=telegram_id, status=member.status + ) if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL): await self._deactivate_subscription_on_unsubscribe(telegram_id, bot, channel_link) @@ -180,28 +175,28 @@ class ChannelCheckerMiddleware(BaseMiddleware): return None return await self._deny_message(event, bot, channel_link, channel_id) - logger.warning(f'⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}') + logger.warning('⚠️ Неожиданный статус пользователя', telegram_id=telegram_id, status=member.status) await self._capture_start_payload(state, event, bot) return await self._deny_message(event, bot, channel_link, channel_id) except TelegramForbiddenError as e: - logger.error(f'❌ Бот заблокирован в канале {channel_id}: {e}') + logger.error('❌ Бот заблокирован в канале', channel_id=channel_id, error=e) await self._capture_start_payload(state, event, bot) return await self._deny_message(event, bot, channel_link, channel_id) except TelegramBadRequest as e: if 'chat not found' in str(e).lower(): - logger.error(f'❌ Канал {channel_id} не найден: {e}') + logger.error('❌ Канал не найден', channel_id=channel_id, error=e) elif 'user not found' in str(e).lower(): - logger.error(f'❌ Пользователь {telegram_id} не найден: {e}') + logger.error('❌ Пользователь не найден', telegram_id=telegram_id, error=e) else: - logger.error(f'❌ Ошибка запроса к каналу {channel_id}: {e}') + logger.error('❌ Ошибка запроса к каналу', channel_id=channel_id, error=e) await self._capture_start_payload(state, event, bot) return await self._deny_message(event, bot, channel_link, channel_id) except TelegramNetworkError as e: - logger.warning(f'⚠️ Таймаут при проверке подписки на канал: {e}') + logger.warning('⚠️ Таймаут при проверке подписки на канал', error=e) return await handler(event, data) except Exception as e: - logger.error(f'❌ Неожиданная ошибка при проверке подписки: {e}') + logger.error('❌ Неожиданная ошибка при проверке подписки', error=e) return await handler(event, data) @staticmethod @@ -258,15 +253,10 @@ class ChannelCheckerMiddleware(BaseMiddleware): state_data['pending_start_payload'] = payload await state.set_data(state_data) logger.info( - "💾 Сохранен start payload '%s' для пользователя %s (FSM)", - payload, - telegram_id, + "💾 Сохранен start payload '' для пользователя (FSM)", payload=payload, telegram_id=telegram_id ) else: - logger.warning( - '⚠️ _capture_start_payload: state=None для пользователя %s', - telegram_id, - ) + logger.warning('⚠️ _capture_start_payload: state=None для пользователя', telegram_id=telegram_id) # Также сохраняем в Redis как резерв (на случай потери FSM state) if telegram_id: @@ -291,9 +281,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): state_data = await state.get_data() or {} except Exception as error: logger.error( - '❌ Не удалось получить данные состояния для уведомления по кампании %s: %s', - payload, - error, + '❌ Не удалось получить данные состояния для уведомления по кампании', payload=payload, error=error ) return @@ -323,11 +311,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): await state.update_data(campaign_notification_sent=True) await db.commit() except Exception as error: - logger.error( - '❌ Ошибка отправки уведомления о переходе по кампании %s: %s', - payload, - error, - ) + logger.error('❌ Ошибка отправки уведомления о переходе по кампании', payload=payload, error=error) await db.rollback() async def _deactivate_subscription_on_unsubscribe( @@ -356,9 +340,9 @@ class ChannelCheckerMiddleware(BaseMiddleware): await deactivate_subscription(db, subscription) sub_type = 'Триальная' if subscription.is_trial else 'Платная' logger.info( - '🚫 %s подписка пользователя %s отключена после отписки от канала', - sub_type, - telegram_id, + '🚫 подписка пользователя отключена после отписки от канала', + sub_type=sub_type, + telegram_id=telegram_id, ) if user.remnawave_uuid: @@ -367,9 +351,9 @@ class ChannelCheckerMiddleware(BaseMiddleware): await service.disable_remnawave_user(user.remnawave_uuid) except Exception as api_error: logger.error( - '❌ Не удалось отключить пользователя RemnaWave %s: %s', - user.remnawave_uuid, - api_error, + '❌ Не удалось отключить пользователя RemnaWave', + remnawave_uuid=user.remnawave_uuid, + api_error=api_error, ) # Уведомляем пользователя о деактивации @@ -384,16 +368,16 @@ class ChannelCheckerMiddleware(BaseMiddleware): await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb) except Exception as notify_error: logger.error( - '❌ Не удалось отправить уведомление о деактивации пользователю %s: %s', - telegram_id, - notify_error, + '❌ Не удалось отправить уведомление о деактивации пользователю', + telegram_id=telegram_id, + notify_error=notify_error, ) await db.commit() except Exception as db_error: logger.error( - '❌ Ошибка деактивации подписки пользователя %s после отписки: %s', - telegram_id, - db_error, + '❌ Ошибка деактивации подписки пользователя после отписки', + telegram_id=telegram_id, + db_error=db_error, ) await db.rollback() @@ -410,10 +394,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): # НЕ реактивируем подписку заблокированным пользователям if user.status == UserStatus.BLOCKED.value: - logger.info( - '🚫 Пропуск реактивации для заблокированного пользователя %s', - telegram_id, - ) + logger.info('🚫 Пропуск реактивации для заблокированного пользователя', telegram_id=telegram_id) return subscription = user.subscription @@ -430,9 +411,9 @@ class ChannelCheckerMiddleware(BaseMiddleware): await reactivate_subscription(db, subscription) sub_type = 'Триальная' if subscription.is_trial else 'Платная' logger.info( - '✅ %s подписка пользователя %s реактивирована после подписки на канал', - sub_type, - telegram_id, + '✅ подписка пользователя реактивирована после подписки на канал', + sub_type=sub_type, + telegram_id=telegram_id, ) # Включаем в RemnaWave @@ -442,9 +423,9 @@ class ChannelCheckerMiddleware(BaseMiddleware): await service.enable_remnawave_user(user.remnawave_uuid) except Exception as api_error: logger.error( - '❌ Не удалось включить пользователя RemnaWave %s: %s', - user.remnawave_uuid, - api_error, + '❌ Не удалось включить пользователя RemnaWave', + remnawave_uuid=user.remnawave_uuid, + api_error=api_error, ) # Уведомляем пользователя о реактивации @@ -457,17 +438,13 @@ class ChannelCheckerMiddleware(BaseMiddleware): await bot.send_message(telegram_id, notification_text) except Exception as notify_error: logger.warning( - 'Не удалось отправить уведомление о реактивации пользователю %s: %s', - telegram_id, - notify_error, + 'Не удалось отправить уведомление о реактивации пользователю', + telegram_id=telegram_id, + notify_error=notify_error, ) await db.commit() except Exception as db_error: - logger.error( - '❌ Ошибка реактивации подписки пользователя %s: %s', - telegram_id, - db_error, - ) + logger.error('❌ Ошибка реактивации подписки пользователя', telegram_id=telegram_id, db_error=db_error) await db.rollback() @staticmethod @@ -522,4 +499,4 @@ class ChannelCheckerMiddleware(BaseMiddleware): elif isinstance(event, Update) and event.message: return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb) except Exception as e: - logger.error(f'❌ Ошибка при отправке сообщения о подписке: {e}') + logger.error('❌ Ошибка при отправке сообщения о подписке', error=e) diff --git a/app/middlewares/context_binding.py b/app/middlewares/context_binding.py new file mode 100644 index 00000000..e727fcb6 --- /dev/null +++ b/app/middlewares/context_binding.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject +from structlog.contextvars import bound_contextvars + + +class ContextVarsMiddleware(BaseMiddleware): + """Bind user/chat context to structlog contextvars for automatic log enrichment.""" + + async def __call__( + self, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: dict[str, Any], + ) -> Any: + ctx: dict[str, Any] = {} + if hasattr(event, 'from_user') and event.from_user: + ctx['user_id'] = event.from_user.id + ctx['username'] = event.from_user.username or '' + if hasattr(event, 'chat') and event.chat: + ctx['chat_id'] = event.chat.id + with bound_contextvars(**ctx): + return await handler(event, data) diff --git a/app/middlewares/display_name_restriction.py b/app/middlewares/display_name_restriction.py index 03e4bcf8..3bc0d88b 100644 --- a/app/middlewares/display_name_restriction.py +++ b/app/middlewares/display_name_restriction.py @@ -1,8 +1,8 @@ -import logging import re from collections.abc import Awaitable, Callable from typing import Any +import structlog from aiogram import BaseMiddleware from aiogram.types import ( CallbackQuery, @@ -16,7 +16,7 @@ from app.config import settings from app.localization.texts import get_texts -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) ZERO_WIDTH_PATTERN = re.compile(r'[\u200B-\u200D\uFEFF]') @@ -101,9 +101,9 @@ class DisplayNameRestrictionMiddleware(BaseMiddleware): ) logger.warning( - "🚫 DisplayNameRestriction: user %s blocked due to suspicious name '%s'", - user.id, - suspicious_value, + "🚫 DisplayNameRestriction: user blocked due to suspicious name ''", + user_id=user.id, + suspicious_value=suspicious_value, ) if isinstance(event, Message): diff --git a/app/middlewares/global_error.py b/app/middlewares/global_error.py index 3ba37453..68f809fd 100644 --- a/app/middlewares/global_error.py +++ b/app/middlewares/global_error.py @@ -1,9 +1,9 @@ -import logging import traceback from collections.abc import Awaitable, Callable -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from typing import Any, Final +import structlog from aiogram import BaseMiddleware, Bot from aiogram.enums import ParseMode from aiogram.exceptions import TelegramBadRequest @@ -15,7 +15,7 @@ from app.services.startup_notification_service import _get_error_recommendations from app.utils.timezone import format_local_datetime -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # Константы ERROR_NOTIFICATION_COOLDOWN_MINUTES: Final[int] = 5 @@ -69,11 +69,11 @@ class GlobalErrorMiddleware(BaseMiddleware): return await self._handle_telegram_error(event, e, data) except (InterfaceError, OperationalError) as e: # Ошибки соединения с БД (таймаут после долгих операций) - логируем, но не спамим админам - logger.warning('⚠️ Ошибка соединения с БД в GlobalErrorMiddleware: %s', e) + logger.warning('⚠️ Ошибка соединения с БД в GlobalErrorMiddleware', e=e) raise except Exception as e: user_info = self._get_user_info(event) - logger.error('Неожиданная ошибка в GlobalErrorMiddleware (user=%s): %s', user_info, e, exc_info=True) + logger.error('Неожиданная ошибка в GlobalErrorMiddleware (user=)', user_info=user_info, e=e, exc_info=True) raise async def _handle_telegram_error(self, event: TelegramObject, error: TelegramBadRequest, data: dict[str, Any]): @@ -85,14 +85,14 @@ class GlobalErrorMiddleware(BaseMiddleware): return await self._handle_message_not_modified(event, error, data) if self._is_topic_required_error(error_message): # Канал с топиками — просто игнорируем - logger.debug('[GlobalErrorMiddleware] Игнорируем ошибку топика: %s', error) + logger.debug('[GlobalErrorMiddleware] Игнорируем ошибку топика', error=error) return None if self._is_bad_request_error(error_message): return await self._handle_bad_request(event, error, data) # Неизвестная ошибка — логируем user_info = self._get_user_info(event) - logger.error('Неизвестная Telegram API ошибка (user=%s): %s', user_info, error) + logger.error('Неизвестная Telegram API ошибка (user=)', user_info=user_info, error=error) raise error def _is_old_query_error(self, error_message: str) -> bool: @@ -110,14 +110,18 @@ class GlobalErrorMiddleware(BaseMiddleware): async def _handle_old_query(self, event: TelegramObject, error: TelegramBadRequest): if isinstance(event, CallbackQuery): user_info = self._get_user_info(event) - logger.warning("[GlobalErrorMiddleware] Игнорируем устаревший callback '%s' от %s", event.data, user_info) + logger.warning( + "[GlobalErrorMiddleware] Игнорируем устаревший callback '' от", + event_data=event.data, + user_info=user_info, + ) else: - logger.warning('[GlobalErrorMiddleware] Игнорируем устаревший запрос: %s', error) + logger.warning('[GlobalErrorMiddleware] Игнорируем устаревший запрос', error=error) async def _handle_message_not_modified( self, event: TelegramObject, error: TelegramBadRequest, data: dict[str, Any] ): - logger.debug('[GlobalErrorMiddleware] Сообщение не было изменено: %s', error) + logger.debug('[GlobalErrorMiddleware] Сообщение не было изменено', error=error) if isinstance(event, CallbackQuery): try: @@ -125,24 +129,24 @@ class GlobalErrorMiddleware(BaseMiddleware): logger.debug("Успешно ответили на callback после 'message not modified'") except TelegramBadRequest as answer_error: if not self._is_old_query_error(str(answer_error).lower()): - logger.warning('Ошибка при ответе на callback: %s', answer_error) + logger.warning('Ошибка при ответе на callback', answer_error=answer_error) async def _handle_bad_request(self, event: TelegramObject, error: TelegramBadRequest, data: dict[str, Any]): error_message = str(error).lower() if BOT_BLOCKED_PHRASE in error_message: user_info = self._get_user_info(event) if hasattr(event, 'from_user') else 'Unknown' - logger.info('[GlobalErrorMiddleware] Бот заблокирован пользователем %s', user_info) + logger.info('[GlobalErrorMiddleware] Бот заблокирован пользователем', user_info=user_info) return if USER_DEACTIVATED_PHRASE in error_message: user_info = self._get_user_info(event) if hasattr(event, 'from_user') else 'Unknown' - logger.info('[GlobalErrorMiddleware] Пользователь деактивирован %s', user_info) + logger.info('[GlobalErrorMiddleware] Пользователь деактивирован', user_info=user_info) return if CHAT_NOT_FOUND_PHRASE in error_message or MESSAGE_NOT_FOUND_PHRASE in error_message: - logger.warning('[GlobalErrorMiddleware] Чат или сообщение не найдено: %s', error) + logger.warning('[GlobalErrorMiddleware] Чат или сообщение не найдено', error=error) return user_info = self._get_user_info(event) - logger.error('[GlobalErrorMiddleware] Неизвестная bad request ошибка (user=%s): %s', user_info, error) + logger.error('[GlobalErrorMiddleware] Неизвестная bad request ошибка (user=)', user_info=user_info, error=error) raise error def _get_user_info(self, event: TelegramObject) -> str: @@ -233,9 +237,9 @@ async def send_error_to_admin_chat( _error_buffer.pop(0) # Проверяем троттлинг - now = datetime.utcnow() + now = datetime.now(tz=UTC) if _last_error_notification and (now - _last_error_notification) < _error_notification_cooldown: - logger.debug('Ошибка добавлена в буфер, троттлинг активен: %s', error_type) + logger.debug('Ошибка добавлена в буфер, троттлинг активен', error_type=error_type) return False _last_error_notification = now @@ -269,9 +273,7 @@ async def send_error_to_admin_chat( log_content = '\n'.join(log_lines) - # Очищаем буфер после отправки errors_count = len(_error_buffer) - _error_buffer.clear() file_name = f'error_report_{now.strftime(DATETIME_FORMAT_FILENAME)}.txt' file = BufferedInputFile( @@ -318,9 +320,10 @@ async def send_error_to_admin_chat( message_kwargs['message_thread_id'] = topic_id await bot.send_document(**message_kwargs) - logger.info('Уведомление об ошибке отправлено в чат %s', chat_id) + _error_buffer.clear() # Clear only after successful send + logger.info('Уведомление об ошибке отправлено в чат', chat_id=chat_id) return True except Exception as e: - logger.error('Ошибка отправки уведомления об ошибке: %s', e, extra={'_admin_notified': True}) + logger.error('Ошибка отправки уведомления об ошибке', e=e, _admin_notified=True) return False diff --git a/app/middlewares/logging.py b/app/middlewares/logging.py index d118106d..064c6337 100644 --- a/app/middlewares/logging.py +++ b/app/middlewares/logging.py @@ -1,13 +1,13 @@ -import logging -import time from collections.abc import Awaitable, Callable +from time import monotonic from typing import Any +import structlog from aiogram import BaseMiddleware from aiogram.types import CallbackQuery, Message, TelegramObject -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class LoggingMiddleware(BaseMiddleware): @@ -17,27 +17,27 @@ class LoggingMiddleware(BaseMiddleware): event: TelegramObject, data: dict[str, Any], ) -> Any: - start_time = time.time() + start_time = monotonic() try: - if isinstance(event, Message): + if isinstance(event, Message) and event.from_user: user_info = f'@{event.from_user.username}' if event.from_user.username else f'ID:{event.from_user.id}' text = event.text or event.caption or '[медиа]' - logger.info(f'📩 Сообщение от {user_info}: {text}') + logger.info('📩 Сообщение от', user_info=user_info, text=text) - elif isinstance(event, CallbackQuery): + elif isinstance(event, CallbackQuery) and event.from_user: user_info = f'@{event.from_user.username}' if event.from_user.username else f'ID:{event.from_user.id}' - logger.info(f'🔘 Callback от {user_info}: {event.data}') + logger.info('🔘 Callback от', user_info=user_info, event_data=event.data) result = await handler(event, data) - execution_time = time.time() - start_time + execution_time = monotonic() - start_time if execution_time > 1.0: - logger.warning(f'⏱️ Медленная операция: {execution_time:.2f}s') + logger.warning('⏱️ Медленная операция', execution_time=round(execution_time, 2)) return result except Exception as e: - execution_time = time.time() - start_time - logger.error(f'❌ Ошибка при обработке события за {execution_time:.2f}s: {e}') + execution_time = monotonic() - start_time + logger.exception('❌ Ошибка при обработке события за', execution_time=round(execution_time, 2), error=e) raise diff --git a/app/middlewares/maintenance.py b/app/middlewares/maintenance.py index de8a05fd..62634418 100644 --- a/app/middlewares/maintenance.py +++ b/app/middlewares/maintenance.py @@ -1,7 +1,7 @@ -import logging from collections.abc import Awaitable, Callable from typing import Any +import structlog from aiogram import BaseMiddleware from aiogram.types import CallbackQuery, Message, TelegramObject, User as TgUser @@ -9,7 +9,7 @@ from app.config import settings from app.services.maintenance_service import maintenance_service -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class MaintenanceMiddleware(BaseMiddleware): @@ -40,7 +40,7 @@ class MaintenanceMiddleware(BaseMiddleware): elif isinstance(event, CallbackQuery): await event.answer(maintenance_message, show_alert=True) except Exception as e: - logger.error(f'Ошибка отправки сообщения о техработах пользователю {user.id}: {e}') + logger.error('Ошибка отправки сообщения о техработах пользователю', user_id=user.id, error=e) - logger.info(f'🔧 Пользователь {user.id} заблокирован во время техработ') + logger.info('🔧 Пользователь заблокирован во время техработ', user_id=user.id) return None diff --git a/app/middlewares/subscription_checker.py b/app/middlewares/subscription_checker.py index 17fd5700..58b16226 100644 --- a/app/middlewares/subscription_checker.py +++ b/app/middlewares/subscription_checker.py @@ -1,15 +1,15 @@ -import logging from collections.abc import Awaitable, Callable from datetime import datetime, timedelta from typing import Any +import structlog from aiogram import BaseMiddleware from aiogram.types import TelegramObject from app.database.models import SubscriptionStatus -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # Буфер времени перед деактивацией (защита от race condition при продлении) EXPIRATION_BUFFER_MINUTES = 5 @@ -55,18 +55,22 @@ class SubscriptionStatusMiddleware(BaseMiddleware): await db.commit() logger.warning( - f'⏰ Middleware DEACTIVATION: подписка {subscription.id} ' - f'(user_id={user.id}) деактивирована. ' - f'end_date={subscription.end_date}, просрочена на {time_since_expiry}' + '⏰ Middleware DEACTIVATION: подписка (user_id=) деактивирована. end_date=, просрочена на', + subscription_id=subscription.id, + user_id=user.id, + end_date=subscription.end_date, + time_since_expiry=time_since_expiry, ) else: # Подписка только что истекла - не деактивируем сразу (может быть продление) logger.debug( - f'⏰ Middleware: подписка пользователя {user.id} истекла недавно ' - f'({time_since_expiry}), ждём буфер {EXPIRATION_BUFFER_MINUTES} мин' + '⏰ Middleware: подписка пользователя истекла недавно ждём буфер мин', + user_id=user.id, + time_since_expiry=time_since_expiry, + EXPIRATION_BUFFER_MINUTES=EXPIRATION_BUFFER_MINUTES, ) except Exception as e: - logger.error(f'Ошибка проверки статуса подписки: {e}') + logger.error('Ошибка проверки статуса подписки', error=e) return await handler(event, data) diff --git a/app/middlewares/throttling.py b/app/middlewares/throttling.py index 3445a888..2da144e9 100644 --- a/app/middlewares/throttling.py +++ b/app/middlewares/throttling.py @@ -1,14 +1,14 @@ -import logging import time from collections.abc import Awaitable, Callable from typing import Any +import structlog from aiogram import BaseMiddleware from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, Message, TelegramObject -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class ThrottlingMiddleware(BaseMiddleware): @@ -56,11 +56,11 @@ class ThrottlingMiddleware(BaseMiddleware): if len(timestamps) >= self.start_max_calls: cooldown = int(self.start_window - (now - timestamps[0])) + 1 logger.warning( - 'Rate-limit /start для %s: %d вызовов за %ds (лимит %d)', - user_id, - len(timestamps), - int(self.start_window), - self.start_max_calls, + 'Rate-limit /start для : вызовов за s (лимит)', + user_id=user_id, + timestamps_count=len(timestamps), + start_window=int(self.start_window), + start_max_calls=self.start_max_calls, ) try: await event.answer(f'⏳ Слишком много запросов. Попробуйте через {cooldown} сек.') @@ -76,7 +76,7 @@ class ThrottlingMiddleware(BaseMiddleware): last_call = self.user_buckets.get(user_id, 0) if now - last_call < self.rate_limit: - logger.warning(f'Throttling для пользователя {user_id}') + logger.warning('Throttling для пользователя', user_id=user_id) # Для сообщений: молчим только если это состояние работы с тикетами; иначе показываем блок if isinstance(event, Message): diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index 29e3267d..9b88a0b5 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -1,7 +1,7 @@ -import logging from datetime import datetime from typing import Any +import structlog from aiogram import Bot, types from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from sqlalchemy.exc import MissingGreenlet @@ -23,7 +23,7 @@ from app.database.models import ( from app.utils.timezone import format_local_datetime -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class AdminNotificationService: @@ -52,7 +52,7 @@ class AdminNotificationService: return f'User#{referred_by_id}' except Exception as e: - logger.error(f'Ошибка получения данных рефера {referred_by_id}: {e}') + logger.error('Ошибка получения данных рефера', referred_by_id=referred_by_id, error=e) return f'ID {referred_by_id}' async def _get_user_promo_group(self, db: AsyncSession, user: User) -> PromoGroup | None: @@ -75,10 +75,10 @@ class AdminNotificationService: return await get_promo_group_by_id(db, user.promo_group_id) except Exception as e: logger.error( - 'Ошибка загрузки промогруппы %s пользователя %s: %s', - user.promo_group_id, - user.telegram_id, - e, + 'Ошибка загрузки промогруппы пользователя', + promo_group_id=user.promo_group_id, + telegram_id=user.telegram_id, + e=e, ) return None @@ -151,9 +151,9 @@ class AdminNotificationService: ) except Exception: logger.error( - 'Не удалось сохранить событие подписки (%s) для пользователя %s', - event_type, - getattr(user, 'id', 'unknown'), + 'Не удалось сохранить событие подписки для пользователя', + event_type=event_type, + getattr=getattr(user, 'id', 'unknown'), exc_info=True, ) @@ -161,8 +161,8 @@ class AdminNotificationService: await db.rollback() except Exception: logger.error( - 'Не удалось выполнить rollback после ошибки события подписки пользователя %s', - getattr(user, 'id', 'unknown'), + 'Не удалось выполнить rollback после ошибки события подписки пользователя', + getattr=getattr(user, 'id', 'unknown'), exc_info=True, ) @@ -361,7 +361,7 @@ class AdminNotificationService: return await self._send_message('\n'.join(message_lines)) except Exception as e: - logger.error(f'Ошибка отправки уведомления о триале: {e}') + logger.error('Ошибка отправки уведомления о триале', error=e) return False async def _get_tariff_name(self, db: AsyncSession, subscription: Subscription) -> str | None: @@ -489,7 +489,7 @@ class AdminNotificationService: return await self._send_message('\n'.join(message_lines)) except Exception as e: - logger.error(f'Ошибка отправки уведомления о покупке: {e}') + logger.error('Ошибка отправки уведомления о покупке', error=e) return False async def send_version_update_notification(self, current_version: str, latest_version, total_updates: int) -> bool: @@ -552,7 +552,7 @@ class AdminNotificationService: return await self._send_message(message) except Exception as e: - logger.error(f'Ошибка отправки уведомления об обновлении: {e}') + logger.error('Ошибка отправки уведомления об обновлении', error=e) return False async def send_version_check_error_notification(self, error_message: str, current_version: str) -> bool: @@ -573,7 +573,7 @@ class AdminNotificationService: return await self._send_message(message) except Exception as e: - logger.error(f'Ошибка отправки уведомления об ошибке проверки версий: {e}') + logger.error('Ошибка отправки уведомления об ошибке проверки версий', error=e) return False def _build_balance_topup_message( @@ -730,8 +730,8 @@ class AdminNotificationService: ) except Exception: logger.error( - 'Не удалось сохранить событие пополнения баланса пользователя %s', - getattr(user, 'id', 'unknown'), + 'Не удалось сохранить событие пополнения баланса пользователя', + getattr=getattr(user, 'id', 'unknown'), exc_info=True, ) @@ -751,26 +751,23 @@ class AdminNotificationService: ) logger.info('Сообщение уведомления создано успешно') except Exception as error: - logger.info(f'Перехвачена ошибка при создании сообщения уведомления: {type(error).__name__}: {error}') + logger.info( + 'Перехвачена ошибка при создании сообщения уведомления', __name__=type(error).__name__, error=error + ) if not self._is_lazy_loading_error(error): - logger.error( - 'Ошибка подготовки уведомления о пополнении: %s', - error, - exc_info=True, - ) + logger.error('Ошибка подготовки уведомления о пополнении', error=error, exc_info=True) return False if db is None: logger.error( - 'Недостаточно данных для уведомления о пополнении и отсутствует доступ к БД: %s', - error, + 'Недостаточно данных для уведомления о пополнении и отсутствует доступ к БД', + error=error, exc_info=True, ) return False logger.warning( - 'Повторная загрузка данных для уведомления о пополнении после ошибки ленивой загрузки: %s', - error, + 'Повторная загрузка данных для уведомления о пополнении после ошибки ленивой загрузки', error=error ) try: @@ -784,8 +781,8 @@ class AdminNotificationService: logger.info('Данные успешно перезагружены') except Exception as reload_error: logger.error( - 'Ошибка повторной загрузки данных для уведомления о пополнении: %s', - reload_error, + 'Ошибка повторной загрузки данных для уведомления о пополнении', + reload_error=reload_error, exc_info=True, ) return False @@ -804,8 +801,8 @@ class AdminNotificationService: logger.info('Сообщение успешно создано после перезагрузки данных') except Exception as rebuild_error: logger.error( - 'Ошибка повторной подготовки уведомления о пополнении после повторной загрузки: %s', - rebuild_error, + 'Ошибка повторной подготовки уведомления о пополнении после повторной загрузки', + rebuild_error=rebuild_error, exc_info=True, ) return False @@ -813,10 +810,7 @@ class AdminNotificationService: try: return await self._send_message(message) except Exception as e: - logger.error( - f'Ошибка отправки уведомления о пополнении: {e}', - exc_info=True, - ) + logger.error('Ошибка отправки уведомления о пополнении', error=e, exc_info=True) return False async def send_subscription_extension_notification( @@ -894,7 +888,7 @@ class AdminNotificationService: return await self._send_message(message) except Exception as e: - logger.error(f'Ошибка отправки уведомления о продлении: {e}') + logger.error('Ошибка отправки уведомления о продлении', error=e) return False async def send_promocode_activation_notification( @@ -933,8 +927,8 @@ class AdminNotificationService: ) except Exception: logger.error( - 'Не удалось сохранить событие активации промокода пользователя %s', - getattr(user, 'id', 'unknown'), + 'Не удалось сохранить событие активации промокода пользователя', + getattr=getattr(user, 'id', 'unknown'), exc_info=True, ) @@ -1001,7 +995,7 @@ class AdminNotificationService: return await self._send_message('\n'.join(message_lines)) except Exception as e: - logger.error(f'Ошибка отправки уведомления об активации промокода: {e}') + logger.error('Ошибка отправки уведомления об активации промокода', error=e) return False async def send_campaign_link_visit_notification( @@ -1031,8 +1025,8 @@ class AdminNotificationService: ) except Exception: logger.error( - 'Не удалось сохранить событие перехода по кампании для пользователя %s', - getattr(user, 'id', 'unknown'), + 'Не удалось сохранить событие перехода по кампании для пользователя', + getattr=getattr(user, 'id', 'unknown'), exc_info=True, ) @@ -1080,7 +1074,7 @@ class AdminNotificationService: return await self._send_message('\n'.join(message_lines)) except Exception as e: - logger.error(f'Ошибка отправки уведомления о переходе по кампании: {e}') + logger.error('Ошибка отправки уведомления о переходе по кампании', error=e) return False async def send_user_promo_group_change_notification( @@ -1116,8 +1110,8 @@ class AdminNotificationService: ) except Exception: logger.error( - 'Не удалось сохранить событие смены промогруппы пользователя %s', - getattr(user, 'id', 'unknown'), + 'Не удалось сохранить событие смены промогруппы пользователя', + getattr=getattr(user, 'id', 'unknown'), exc_info=True, ) @@ -1170,7 +1164,7 @@ class AdminNotificationService: return await self._send_message('\n'.join(message_lines)) except Exception as e: - logger.error(f'Ошибка отправки уведомления о смене промогруппы: {e}') + logger.error('Ошибка отправки уведомления о смене промогруппы', error=e) return False async def _send_message( @@ -1200,17 +1194,17 @@ class AdminNotificationService: message_kwargs['reply_markup'] = reply_markup await self.bot.send_message(**message_kwargs) - logger.info(f'Уведомление отправлено в чат {self.chat_id}') + logger.info('Уведомление отправлено в чат', chat_id=self.chat_id) return True except TelegramForbiddenError: - logger.error(f'Бот не имеет прав для отправки в чат {self.chat_id}') + logger.error('Бот не имеет прав для отправки в чат', chat_id=self.chat_id) return False except TelegramBadRequest as e: - logger.error(f'Ошибка отправки уведомления: {e}') + logger.error('Ошибка отправки уведомления', error=e) return False except Exception as e: - logger.error(f'Неожиданная ошибка при отправке уведомления: {e}') + logger.error('Неожиданная ошибка при отправке уведомления', error=e) return False def _is_enabled(self) -> bool: @@ -1279,7 +1273,7 @@ class AdminNotificationService: servers_names = await get_servers_display_names(squad_uuids) return f'{len(squad_uuids)} шт. ({servers_names})' except Exception as e: - logger.warning(f'Не удалось получить названия серверов: {e}') + logger.warning('Не удалось получить названия серверов', error=e) return f'{len(squad_uuids)} шт.' async def send_maintenance_status_notification( @@ -1422,7 +1416,7 @@ class AdminNotificationService: return await self._send_message(message) except Exception as e: - logger.error(f'Ошибка отправки уведомления о техработах: {e}') + logger.error('Ошибка отправки уведомления о техработах', error=e) return False async def send_remnawave_panel_status_notification(self, status: str, details: dict[str, Any] = None) -> bool: @@ -1509,7 +1503,7 @@ class AdminNotificationService: return await self._send_message(message) except Exception as e: - logger.error(f'Ошибка отправки уведомления о статусе панели Remnawave: {e}') + logger.error('Ошибка отправки уведомления о статусе панели Remnawave', error=e) return False async def send_subscription_update_notification( @@ -1602,7 +1596,7 @@ class AdminNotificationService: return await self._send_message('\n'.join(message_lines)) except Exception as e: - logger.error(f'Ошибка отправки уведомления об изменении подписки: {e}') + logger.error('Ошибка отправки уведомления об изменении подписки', error=e) return False async def _format_servers_detailed(self, server_uuids: list[str]) -> str: @@ -1619,7 +1613,7 @@ class AdminNotificationService: return f'{len(server_uuids)} серверов' except Exception as e: - logger.warning(f'Ошибка получения названий серверов для уведомления: {e}') + logger.warning('Ошибка получения названий серверов для уведомления', error=e) return f'{len(server_uuids)} серверов' def _format_update_value(self, value: Any, update_type: str) -> str: @@ -1676,7 +1670,7 @@ class AdminNotificationService: return await self._send_message(message) except Exception as e: - logger.error(f'Ошибка отправки уведомления о массовой блокировке: {e}') + logger.error('Ошибка отправки уведомления о массовой блокировке', error=e) return False async def send_ticket_event_notification( @@ -1694,7 +1688,9 @@ class AdminNotificationService: runtime_enabled = True if not (self._is_enabled() and runtime_enabled): logger.info( - f'Ticket notification skipped: _is_enabled={self._is_enabled()}, runtime_enabled={runtime_enabled}' + 'Ticket notification skipped: _is_enabled=, runtime_enabled', + _is_enabled=self._is_enabled(), + runtime_enabled=runtime_enabled, ) return False return await self._send_message(text, reply_markup=keyboard, ticket_event=True) @@ -1728,16 +1724,18 @@ class AdminNotificationService: await bot.send_message(**message_kwargs) logger.info( - f'Уведомление о подозрительной активности отправлено в чат {self.chat_id}, топик {notification_topic_id}' + 'Уведомление о подозрительной активности отправлено в чат топик', + chat_id=self.chat_id, + notification_topic_id=notification_topic_id, ) return True except TelegramForbiddenError: - logger.error(f'Бот не имеет прав для отправки в чат {self.chat_id}') + logger.error('Бот не имеет прав для отправки в чат', chat_id=self.chat_id) return False except TelegramBadRequest as e: - logger.error(f'Ошибка отправки уведомления о подозрительной активности: {e}') + logger.error('Ошибка отправки уведомления о подозрительной активности', error=e) return False except Exception as e: - logger.error(f'Неожиданная ошибка при отправке уведомления о подозрительной активности: {e}') + logger.error('Неожиданная ошибка при отправке уведомления о подозрительной активности', error=e) return False diff --git a/app/services/backup_service.py b/app/services/backup_service.py index 32749676..c75c22f1 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -1,7 +1,6 @@ import asyncio import gzip import json as json_lib -import logging import math import os import shutil @@ -15,6 +14,7 @@ from typing import Any import aiofiles import pyzipper +import structlog from aiogram.types import FSInputFile from sqlalchemy import inspect, select, text from sqlalchemy.exc import IntegrityError @@ -99,7 +99,7 @@ from app.database.models import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @dataclass @@ -255,8 +255,8 @@ class BackupService: except ValueError: default_hours, default_minutes = 3, 0 logger.warning( - "Некорректное значение BACKUP_TIME='%s'. Используется значение по умолчанию 03:00.", - self._settings.backup_time, + "Некорректное значение BACKUP_TIME=''. Используется значение по умолчанию 03:00.", + backup_time=self._settings.backup_time, ) self._settings.backup_time = '03:00' return default_hours, default_minutes @@ -276,7 +276,7 @@ class BackupService: if hours <= 0: logger.warning( - 'Некорректное значение BACKUP_INTERVAL_HOURS=%s. Используется значение по умолчанию 24.', hours + 'Некорректное значение BACKUP_INTERVAL_HOURS=. Используется значение по умолчанию 24.', hours=hours ) hours = 24 self._settings.backup_interval_hours = hours @@ -300,11 +300,7 @@ class BackupService: override_path = Path(override) if override_path.exists() and os.access(override_path, os.X_OK): return str(override_path) - logger.warning( - 'Путь %s из %s недоступен или не является исполняемым', - override, - env_var, - ) + logger.warning('Путь из недоступен или не является исполняемым', override=override, env_var=env_var) resolved = shutil.which(command) if resolved: @@ -396,7 +392,7 @@ class BackupService: async def restore_backup(self, backup_file_path: str, clear_existing: bool = False) -> tuple[bool, str]: try: - logger.info(f'📄 Начинаем восстановление из {backup_file_path}') + logger.info('📄 Начинаем восстановление из', backup_file_path=backup_file_path) backup_path = Path(backup_file_path) if not backup_path.exists(): @@ -446,7 +442,7 @@ class BackupService: overview['tables_count'] = len(table_names) except Exception as exc: - logger.warning('Не удалось собрать статистику по БД: %s', exc) + logger.warning('Не удалось собрать статистику по БД', exc=exc) return overview @@ -499,7 +495,7 @@ class BackupService: settings.POSTGRES_DB, ] - logger.info('📦 Экспорт PostgreSQL через pg_dump (%s)...', pg_dump_path) + logger.info('📦 Экспорт PostgreSQL через pg_dump ...', pg_dump_path=pg_dump_path) dump_path.parent.mkdir(parents=True, exist_ok=True) with dump_path.open('wb') as dump_file: @@ -515,7 +511,7 @@ class BackupService: error_text = stderr.decode() if stderr else 'pg_dump error' raise RuntimeError(f'pg_dump завершился с ошибкой: {error_text}') - logger.info('✅ PostgreSQL dump создан (%s)', dump_path) + logger.info('✅ PostgreSQL dump создан', dump_path=dump_path) async def _dump_postgres_json(self, staging_dir: Path, include_logs: bool) -> dict[str, Any]: models_to_backup = self._get_models_for_backup(include_logs) @@ -544,10 +540,7 @@ class BackupService: size = dump_path.stat().st_size if dump_path.exists() else 0 - logger.info( - '✅ PostgreSQL экспортирован через ORM в JSON (%s)', - dump_path, - ) + logger.info('✅ PostgreSQL экспортирован через ORM в JSON', dump_path=dump_path) return { 'type': 'postgresql', @@ -567,7 +560,7 @@ class BackupService: dump_path.parent.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(shutil.copy2, sqlite_path, dump_path) - logger.info('✅ SQLite база данных скопирована (%s)', dump_path) + logger.info('✅ SQLite база данных скопирована', dump_path=dump_path) async def _export_database_via_orm( self, @@ -580,7 +573,7 @@ class BackupService: try: for model in models_to_backup: table_name = model.__tablename__ - logger.info('📊 Экспортируем таблицу: %s', table_name) + logger.info('📊 Экспортируем таблицу', table_name=table_name) query = select(model) @@ -623,11 +616,7 @@ class BackupService: backup_data[table_name] = table_data total_records += len(table_data) - logger.info( - '✅ Экспортировано %s записей из %s', - len(table_data), - table_name, - ) + logger.info('✅ Экспортировано записей из', table_data_count=len(table_data), table_name=table_name) association_data = await self._export_association_tables(db) for records in association_data.values(): @@ -637,7 +626,7 @@ class BackupService: return backup_data, association_data, total_records, tables_count except Exception as exc: - logger.error('Ошибка при экспорте данных: %s', exc) + logger.error('Ошибка при экспорте данных', exc=exc) raise async def _collect_files(self, staging_dir: Path, include_logs: bool) -> list[dict[str, Any]]: @@ -732,7 +721,7 @@ class BackupService: async with aiofiles.open(metadata_path, encoding='utf-8') as meta_file: metadata = json_lib.loads(await meta_file.read()) - logger.info('📊 Загружен бекап формата %s', metadata.get('format_version', 'unknown')) + logger.info('📊 Загружен бекап формата', metadata=metadata.get('format_version', 'unknown')) database_info = metadata.get('database', {}) metadata.get('data_snapshot', {}) @@ -806,7 +795,7 @@ class BackupService: if proc.returncode != 0: raise RuntimeError(f'Не удалось очистить схему: {stderr.decode()}') - logger.info('📥 Восстановление PostgreSQL через psql (%s)...', psql_path) + logger.info('📥 Восстановление PostgreSQL через psql ...', psql_path=psql_path) restore_command = [ psql_path, settings.POSTGRES_DB, @@ -824,7 +813,7 @@ class BackupService: if proc.returncode != 0: raise RuntimeError(f'Ошибка psql: {stderr.decode()}') - logger.info('✅ PostgreSQL восстановлен (%s)', dump_path) + logger.info('✅ PostgreSQL восстановлен', dump_path=dump_path) async def _restore_postgres_json(self, dump_path: Path, clear_existing: bool): if not dump_path.exists(): @@ -844,7 +833,7 @@ class BackupService: clear_existing, ) - logger.info('✅ PostgreSQL восстановлен из ORM JSON (%s)', dump_path) + logger.info('✅ PostgreSQL восстановлен из ORM JSON', dump_path=dump_path) async def _restore_sqlite(self, dump_path: Path, clear_existing: bool): if not dump_path.exists(): @@ -857,7 +846,7 @@ class BackupService: target_path.unlink() await asyncio.to_thread(shutil.copy2, dump_path, target_path) - logger.info('✅ SQLite база восстановлена (%s)', target_path) + logger.info('✅ SQLite база восстановлена', target_path=target_path) async def _restore_data_snapshot(self, source_dir: Path, clear_existing: bool): if not source_dir.exists(): @@ -895,21 +884,21 @@ class BackupService: target_resolved = target_path.resolve() if not str(target_resolved).startswith(str(allowed_base) + os.sep) and target_resolved != allowed_base: - logger.warning('Заблокирована запись за пределами data_dir: %s', target_path) + logger.warning('Заблокирована запись за пределами data_dir', target_path=target_path) continue source_file = (temp_path / relative_path).resolve() if not str(source_file).startswith(str(temp_path.resolve()) + os.sep): - logger.warning('Path traversal в relative_path: %s', relative_path) + logger.warning('Path traversal в relative_path', relative_path=relative_path) continue if not source_file.exists(): - logger.warning('Файл %s отсутствует в архиве', relative_path) + logger.warning('Файл отсутствует в архиве', relative_path=relative_path) continue target_resolved.parent.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(shutil.copy2, source_file, target_resolved) - logger.info('📁 Файл %s восстановлен', target_resolved) + logger.info('📁 Файл восстановлен', target_resolved=target_resolved) async def _restore_database_payload( self, @@ -921,17 +910,14 @@ class BackupService: if not backup_data: raise ValueError('❌ Файл бекапа не содержит данных') - logger.info( - '📊 Загружен дамп: %s', - metadata.get('timestamp', 'неизвестная дата'), - ) + logger.info('📊 Загружен дамп', metadata=metadata.get('timestamp', 'неизвестная дата')) estimated_records = metadata.get('total_records') if estimated_records is None: estimated_records = sum(len(records) for records in backup_data.values()) estimated_records += sum(len(records) for records in association_data.values()) - logger.info('📈 Содержит %s записей', estimated_records) + logger.info('📈 Содержит записей', estimated_records=estimated_records) restored_records = 0 restored_tables = 0 @@ -956,9 +942,7 @@ class BackupService: continue logger.info( - '🔥 Восстанавливаем таблицу %s (%s записей)', - table_name, - len(records), + '🔥 Восстанавливаем таблицу (записей)', table_name=table_name, records_count=len(records) ) restored = await self._restore_table_records( db, @@ -971,7 +955,7 @@ class BackupService: if restored: restored_tables += 1 - logger.info('✅ Таблица %s восстановлена', table_name) + logger.info('✅ Таблица восстановлена', table_name=table_name) await self._restore_users_without_referrals( db, @@ -990,9 +974,7 @@ class BackupService: continue logger.info( - '🔥 Восстанавливаем таблицу %s (%s записей)', - table_name, - len(records), + '🔥 Восстанавливаем таблицу (записей)', table_name=table_name, records_count=len(records) ) restored = await self._restore_table_records( db, @@ -1005,7 +987,7 @@ class BackupService: if restored: restored_tables += 1 - logger.info('✅ Таблица %s восстановлена', table_name) + logger.info('✅ Таблица восстановлена', table_name=table_name) # Flush все изменения перед обновлением реферальных связей await db.flush() @@ -1024,7 +1006,7 @@ class BackupService: except Exception as exc: await db.rollback() - logger.error('Ошибка при восстановлении: %s', exc) + logger.error('Ошибка при восстановлении', exc=exc) raise return restored_tables, restored_records @@ -1062,7 +1044,7 @@ class BackupService: if file_snapshots: restored_files = await self._restore_file_snapshots(file_snapshots) if restored_files: - logger.info(f'📁 Восстановлено файлов конфигурации: {restored_files}') + logger.info('📁 Восстановлено файлов конфигурации', restored_files=restored_files) message = ( f'✅ Восстановление завершено!\n' @@ -1079,7 +1061,7 @@ class BackupService: if not users_data: return - logger.info(f'👥 Восстанавливаем {len(users_data)} пользователей без реферальных связей') + logger.info('👥 Восстанавливаем пользователей без реферальных связей', users_data_count=len(users_data)) User = models_by_table['users'] @@ -1104,9 +1086,9 @@ class BackupService: await db.flush() except IntegrityError: logger.warning( - 'Дубликат пользователя (id=%s, telegram_id=%s), пропускаем', - processed_data.get('id'), - processed_data.get('telegram_id'), + 'Дубликат пользователя (id telegram_id=), пропускаем', + processed_data=processed_data.get('id'), + processed_data_2=processed_data.get('telegram_id'), ) continue else: @@ -1117,19 +1099,19 @@ class BackupService: await db.flush() except IntegrityError: logger.warning( - 'Дубликат пользователя (telegram_id=%s), пропускаем', - processed_data.get('telegram_id'), + 'Дубликат пользователя (telegram_id=), пропускаем', + processed_data=processed_data.get('telegram_id'), ) continue except Exception as e: - logger.error(f'Ошибка при восстановлении пользователя: {e}') + logger.error('Ошибка при восстановлении пользователя', error=e) raise try: await db.flush() except IntegrityError as e: - logger.warning('IntegrityError при flush пользователей, откатываем: %s', e) + logger.warning('IntegrityError при flush пользователей, откатываем', e=e) await db.rollback() logger.info('✅ Пользователи без реферальных связей восстановлены') @@ -1156,12 +1138,14 @@ class BackupService: if user: user.referred_by_id = referred_by_id else: - logger.warning(f'Пользователь {user_id} не найден для обновления реферальной связи') + logger.warning('Пользователь не найден для обновления реферальной связи', user_id=user_id) else: - logger.warning(f'Реферер {referred_by_id} не найден для пользователя {user_id}') + logger.warning( + 'Реферер не найден для пользователя', referred_by_id=referred_by_id, user_id=user_id + ) except Exception as e: - logger.error(f'Ошибка при обновлении реферальной связи: {e}') + logger.error('Ошибка при обновлении реферальной связи', error=e) continue await db.flush() @@ -1177,7 +1161,7 @@ class BackupService: column = getattr(model.__table__.columns, key, None) if column is None: - logger.warning(f'Колонка {key} не найдена в модели {table_name}') + logger.warning('Колонка не найдена в модели', key=key, table_name=table_name) continue column_type_str = str(column.type).upper() @@ -1189,19 +1173,19 @@ class BackupService: else: processed_data[key] = datetime.strptime(value, '%Y-%m-%d %H:%M:%S') except (ValueError, TypeError) as e: - logger.warning(f'Не удалось парсить дату {value} для поля {key}: {e}') + logger.warning('Не удалось парсить дату для поля', value=value, key=key, error=e) processed_data[key] = datetime.utcnow() elif column_type_str == 'TIME' and isinstance(value, str): try: processed_data[key] = dt_time.fromisoformat(value) except (ValueError, TypeError) as e: - logger.warning(f'Не удалось парсить время {value} для поля {key}: {e}') + logger.warning('Не удалось парсить время для поля', value=value, key=key, error=e) processed_data[key] = dt_time(hour=12, minute=0) elif column_type_str == 'DATE' and isinstance(value, str): try: processed_data[key] = dt_date.fromisoformat(value) except (ValueError, TypeError) as e: - logger.warning(f'Не удалось парсить дату {value} для поля {key}: {e}') + logger.warning('Не удалось парсить дату для поля', value=value, key=key, error=e) processed_data[key] = None 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') @@ -1242,13 +1226,13 @@ class BackupService: for table_name, table_obj in self.association_tables.items(): try: - logger.info(f'📊 Экспортируем таблицу связей: {table_name}') + logger.info('📊 Экспортируем таблицу связей', table_name=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}') + logger.info('✅ Экспортировано связей из', rows_count=len(rows), table_name=table_name) except Exception as e: - logger.error(f'Ошибка экспорта таблицы связей {table_name}: {e}') + logger.error('Ошибка экспорта таблицы связей', table_name=table_name, error=e) return association_data @@ -1294,7 +1278,7 @@ class BackupService: values = {col: record.get(col) for col in col_names} if any(v is None for v in values.values()): - logger.warning('Пропущена некорректная запись %s: %s', table_name, record) + logger.warning('Пропущена некорректная запись', table_name=table_name, record=record) continue try: @@ -1307,7 +1291,7 @@ class BackupService: existing = await db.execute(exists_stmt) if existing.scalar_one_or_none() is not None: - logger.debug('Запись %s %s уже существует', table_name, values) + logger.debug('Запись уже существует', table_name=table_name, values=values) continue try: @@ -1315,10 +1299,10 @@ class BackupService: await db.execute(table_obj.insert().values(**values)) restored += 1 except IntegrityError: - logger.warning('Пропускаем связь %s %s (FK или дубликат)', table_name, values) + logger.warning('Пропускаем связь (FK или дубликат)', table_name=table_name, values=values) continue except Exception as e: - logger.error('Ошибка при восстановлении связи %s %s: %s', table_name, values, e) + logger.error('Ошибка при восстановлении связи', table_name=table_name, values=values, e=e) raise return restored @@ -1334,9 +1318,12 @@ class BackupService: try: result = await db.execute(select(Tariff.id)) existing_tariff_ids = {row[0] for row in result.fetchall()} - logger.info(f'📋 Найдено {len(existing_tariff_ids)} существующих тарифов для валидации FK') + logger.info( + '📋 Найдено существующих тарифов для валидации FK', + existing_tariff_ids_count=len(existing_tariff_ids), + ) except Exception as e: - logger.warning(f'⚠️ Не удалось получить список тарифов: {e}') + logger.warning('⚠️ Не удалось получить список тарифов', error=e) for record_data in records: try: @@ -1346,7 +1333,9 @@ class BackupService: if table_name == 'subscriptions' and 'tariff_id' in processed_data: tariff_id = processed_data.get('tariff_id') if tariff_id is not None and tariff_id not in existing_tariff_ids: - logger.warning(f'⚠️ Тариф {tariff_id} не найден, устанавливаем tariff_id=NULL для подписки') + logger.warning( + '⚠️ Тариф не найден, устанавливаем tariff_id=NULL для подписки', tariff_id=tariff_id + ) processed_data['tariff_id'] = None pk_cols = self._get_primary_key_columns(model) @@ -1381,8 +1370,8 @@ class BackupService: restored_count += 1 except Exception as e: - logger.error(f'Ошибка восстановления записи в {table_name}: {e}') - logger.error(f'Проблемные данные: {record_data}') + logger.error('Ошибка восстановления записи в', table_name=table_name, error=e) + logger.error('Проблемные данные', record_data=record_data) raise return restored_count @@ -1483,14 +1472,14 @@ class BackupService: # Проверяем, нужно ли сохранить таблицу if backup_data and table_name in preserve_if_no_backup: if not backup_data.get(table_name): - logger.info(f'⏭️ Пропускаем очистку {table_name} (нет данных в бекапе)') + logger.info('⏭️ Пропускаем очистку (нет данных в бекапе)', table_name=table_name) continue try: await db.execute(text(f'DELETE FROM {table_name}')) - logger.info(f'🗑️ Очищена таблица {table_name}') + logger.info('🗑️ Очищена таблица', table_name=table_name) except Exception as e: - logger.warning(f'⚠️ Не удалось очистить таблицу {table_name}: {e}') + logger.warning('⚠️ Не удалось очистить таблицу', table_name=table_name, error=e) async def _collect_file_snapshots(self) -> dict[str, dict[str, Any]]: snapshots: dict[str, dict[str, Any]] = {} @@ -1507,9 +1496,9 @@ class BackupService: 'content': content, 'modified_at': datetime.fromtimestamp(path_obj.stat().st_mtime).isoformat(), } - logger.info('📁 Добавлен в бекап файл конфигурации: %s', path_obj) + logger.info('📁 Добавлен в бекап файл конфигурации', path_obj=path_obj) except Exception as e: - logger.error('Ошибка чтения файла конфигурации %s: %s', path_obj, e) + logger.error('Ошибка чтения файла конфигурации', path_obj=path_obj, e=e) return snapshots @@ -1528,9 +1517,9 @@ class BackupService: 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) + logger.info('📁 Файл app-config восстановлен по пути', target_path=target_path) except Exception as e: - logger.error('Ошибка восстановления файла %s: %s', target_path, e) + logger.error('Ошибка восстановления файла', target_path=target_path, e=e) return restored_files @@ -1588,7 +1577,7 @@ class BackupService: backups.append(backup_info) except Exception as e: - logger.error(f'Ошибка чтения метаданных {backup_file}: {e}') + logger.error('Ошибка чтения метаданных', backup_file=backup_file, error=e) file_stats = backup_file.stat() backups.append( { @@ -1608,7 +1597,7 @@ class BackupService: ) except Exception as e: - logger.error(f'Ошибка получения списка бекапов: {e}') + logger.error('Ошибка получения списка бекапов', error=e) return backups @@ -1642,12 +1631,12 @@ class BackupService: for backup in backups[self._settings.max_backups_keep :]: try: await self.delete_backup(backup['filename']) - logger.info(f'🗑️ Удален старый бекап: {backup["filename"]}') + logger.info('🗑️ Удален старый бекап', backup=backup['filename']) except Exception as e: - logger.error(f'Ошибка удаления старого бекапа {backup["filename"]}: {e}') + logger.error('Ошибка удаления старого бекапа', backup=backup['filename'], error=e) except Exception as e: - logger.error(f'Ошибка очистки старых бекапов: {e}') + logger.error('Ошибка очистки старых бекапов', error=e) async def get_backup_settings(self) -> BackupSettings: return self._settings @@ -1666,7 +1655,7 @@ class BackupService: return True except Exception as e: - logger.error(f'Ошибка обновления настроек бекапов: {e}') + logger.error('Ошибка обновления настроек бекапов', error=e) return False async def start_auto_backup(self): @@ -1678,9 +1667,9 @@ class BackupService: interval = self._get_backup_interval() self._auto_backup_task = asyncio.create_task(self._auto_backup_loop(next_run)) logger.info( - '📄 Автобекапы включены, интервал: %.2fч, ближайший запуск: %s', - interval.total_seconds() / 3600, - next_run.strftime('%d.%m.%Y %H:%M:%S'), + '📄 Автобекапы включены, интервал: ч, ближайший запуск', + total_seconds=interval.total_seconds() / 3600, + next_run=next_run.strftime('%d.%m.%Y %H:%M:%S'), ) async def stop_auto_backup(self): @@ -1699,31 +1688,31 @@ class BackupService: if delay > 0: logger.info( - '⏰ Следующий автоматический бекап запланирован на %s (через %.2f ч)', - next_run.strftime('%d.%m.%Y %H:%M:%S'), - delay / 3600, + '⏰ Следующий автоматический бекап запланирован на (через ч)', + next_run=next_run.strftime('%d.%m.%Y %H:%M:%S'), + delay=delay / 3600, ) await asyncio.sleep(delay) else: logger.info( - '⏰ Время автоматического бекапа %s уже наступило, запускаем немедленно', - next_run.strftime('%d.%m.%Y %H:%M:%S'), + '⏰ Время автоматического бекапа уже наступило, запускаем немедленно', + next_run=next_run.strftime('%d.%m.%Y %H:%M:%S'), ) logger.info('📄 Запуск автоматического бекапа...') success, message, _ = await self.create_backup() if success: - logger.info(f'✅ Автобекап завершен: {message}') + logger.info('✅ Автобекап завершен', message=message) else: - logger.error(f'❌ Ошибка автобекапа: {message}') + logger.error('❌ Ошибка автобекапа', message=message) next_run = next_run + interval except asyncio.CancelledError: break except Exception as e: - logger.error(f'Ошибка в цикле автобекапов: {e}') + logger.error('Ошибка в цикле автобекапов', error=e) next_run = datetime.now() + interval async def _send_backup_notification(self, event_type: str, message: str, file_path: str = None): @@ -1747,10 +1736,10 @@ class BackupService: admin_service = AdminNotificationService(self.bot) await admin_service._send_message(notification_text) except Exception as e: - logger.error(f'Ошибка отправки уведомления через AdminNotificationService: {e}') + logger.error('Ошибка отправки уведомления через AdminNotificationService', error=e) except Exception as e: - logger.error(f'Ошибка отправки уведомления о бекапе: {e}') + logger.error('Ошибка отправки уведомления о бекапе', error=e) async def _send_backup_file_to_chat(self, file_path: str): try: @@ -1786,22 +1775,22 @@ class BackupService: send_kwargs['message_thread_id'] = settings.BACKUP_SEND_TOPIC_ID await self.bot.send_document(**send_kwargs) - logger.info(f'Бекап отправлен в чат {chat_id}') + logger.info('Бекап отправлен в чат', chat_id=chat_id) if temp_zip_path and Path(temp_zip_path).exists(): try: Path(temp_zip_path).unlink() except Exception as cleanup_error: - logger.warning(f'Не удалось удалить временный архив: {cleanup_error}') + logger.warning('Не удалось удалить временный архив', cleanup_error=cleanup_error) except Exception as e: - logger.error(f'Ошибка отправки бекапа в чат: {e}') + logger.error('Ошибка отправки бекапа в чат', error=e) async def _create_password_protected_archive(self, file_path: str, password: str) -> str | None: try: source_path = Path(file_path) if not source_path.exists(): - logger.error(f'Исходный файл бекапа не найден: {file_path}') + logger.error('Исходный файл бекапа не найден', file_path=file_path) return None zip_filename = source_path.stem + '.zip' @@ -1815,11 +1804,11 @@ class BackupService: zf.write(source_path, arcname=source_path.name) await asyncio.to_thread(create_zip) - logger.info(f'Создан защищённый паролем архив: {zip_path}') + logger.info('Создан защищённый паролем архив', zip_path=zip_path) return str(zip_path) except Exception as e: - logger.error(f'Ошибка создания защищённого архива: {e}') + logger.error('Ошибка создания защищённого архива', error=e) return None diff --git a/app/services/ban_notification_service.py b/app/services/ban_notification_service.py index 730bbbe1..dfe04c4c 100644 --- a/app/services/ban_notification_service.py +++ b/app/services/ban_notification_service.py @@ -2,8 +2,7 @@ Сервис для отправки уведомлений от ban системы пользователям """ -import logging - +import structlog from aiogram import Bot from aiogram.exceptions import TelegramAPIError from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup @@ -19,7 +18,7 @@ from app.services.notification_delivery_service import ( from app.services.remnawave_service import remnawave_service -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def get_delete_keyboard() -> InlineKeyboardMarkup: @@ -60,7 +59,7 @@ class BanNotificationService: if user: return user except Exception as e: - logger.warning(f'Не удалось получить telegram_id через remnawave: {e}') + logger.warning('Не удалось получить telegram_id через remnawave', error=e) # Если не нашли через remnawave, пытаемся искать по email в подписках # (это может быть полезно если у пользователя есть подписка с таким email) @@ -75,7 +74,7 @@ class BanNotificationService: if user: return user except Exception as e: - logger.warning(f'Ошибка поиска пользователя по email в подписках: {e}') + logger.warning('Ошибка поиска пользователя по email в подписках', error=e) return None @@ -101,7 +100,7 @@ class BanNotificationService: # Находим пользователя user = await self._find_user_by_identifier(db, user_identifier) if not user: - logger.warning(f'Пользователь {user_identifier} не найден в базе данных') + logger.warning('Пользователь не найден в базе данных', user_identifier=user_identifier) return False, f'Пользователь не найден: {user_identifier}', None # Формируем информацию о ноде (заметно выделяем) @@ -129,7 +128,7 @@ class BanNotificationService: reason=reason, ) if success: - logger.info(f'Email уведомление о бане отправлено пользователю {user.id}') + logger.info('Email уведомление о бане отправлено пользователю', user_id=user.id) return True, 'Email уведомление отправлено', None return False, 'Не удалось отправить email уведомление', None @@ -138,11 +137,20 @@ class BanNotificationService: await self._bot.send_message( chat_id=user.telegram_id, text=message_text, parse_mode='HTML', reply_markup=get_delete_keyboard() ) - logger.info(f'Уведомление о бане отправлено пользователю {username} (telegram_id: {user.telegram_id})') + logger.info( + 'Уведомление о бане отправлено пользователю (telegram_id: )', + username=username, + telegram_id=user.telegram_id, + ) return True, 'Уведомление отправлено', user.telegram_id except TelegramAPIError as e: - logger.error(f'Ошибка отправки уведомления пользователю {username} (telegram_id: {user.telegram_id}): {e}') + logger.error( + 'Ошибка отправки уведомления пользователю (telegram_id: )', + username=username, + telegram_id=user.telegram_id, + error=e, + ) return False, f'Ошибка Telegram API: {e!s}', user.telegram_id async def send_enabled_notification( @@ -160,7 +168,7 @@ class BanNotificationService: # Находим пользователя user = await self._find_user_by_identifier(db, user_identifier) if not user: - logger.warning(f'Пользователь {user_identifier} не найден в базе данных') + logger.warning('Пользователь не найден в базе данных', user_identifier=user_identifier) return False, f'Пользователь не найден: {user_identifier}', None # Формируем сообщение из настроек @@ -170,7 +178,7 @@ class BanNotificationService: if not user.telegram_id: success = await notification_delivery_service.notify_unban(user=user) if success: - logger.info(f'Email уведомление о разбане отправлено пользователю {user.id}') + logger.info('Email уведомление о разбане отправлено пользователю', user_id=user.id) return True, 'Email уведомление отправлено', None return False, 'Не удалось отправить email уведомление', None @@ -179,11 +187,20 @@ class BanNotificationService: await self._bot.send_message( chat_id=user.telegram_id, text=message_text, parse_mode='HTML', reply_markup=get_delete_keyboard() ) - logger.info(f'Уведомление о разбане отправлено пользователю {username} (telegram_id: {user.telegram_id})') + logger.info( + 'Уведомление о разбане отправлено пользователю (telegram_id: )', + username=username, + telegram_id=user.telegram_id, + ) return True, 'Уведомление отправлено', user.telegram_id except TelegramAPIError as e: - logger.error(f'Ошибка отправки уведомления пользователю {username} (telegram_id: {user.telegram_id}): {e}') + logger.error( + 'Ошибка отправки уведомления пользователю (telegram_id: )', + username=username, + telegram_id=user.telegram_id, + error=e, + ) return False, f'Ошибка Telegram API: {e!s}', user.telegram_id async def send_warning_notification( @@ -201,7 +218,7 @@ class BanNotificationService: # Находим пользователя user = await self._find_user_by_identifier(db, user_identifier) if not user: - logger.warning(f'Пользователь {user_identifier} не найден в базе данных') + logger.warning('Пользователь не найден в базе данных', user_identifier=user_identifier) return False, f'Пользователь не найден: {user_identifier}', None # Формируем сообщение из настроек @@ -216,7 +233,7 @@ class BanNotificationService: context=context, ) if success: - logger.info(f'Email предупреждение отправлено пользователю {user.id}') + logger.info('Email предупреждение отправлено пользователю', user_id=user.id) return True, 'Email предупреждение отправлено', None return False, 'Не удалось отправить email предупреждение', None @@ -225,12 +242,19 @@ class BanNotificationService: await self._bot.send_message( chat_id=user.telegram_id, text=message_text, parse_mode='HTML', reply_markup=get_delete_keyboard() ) - logger.info(f'Предупреждение отправлено пользователю {username} (telegram_id: {user.telegram_id})') + logger.info( + 'Предупреждение отправлено пользователю (telegram_id: )', + username=username, + telegram_id=user.telegram_id, + ) return True, 'Предупреждение отправлено', user.telegram_id except TelegramAPIError as e: logger.error( - f'Ошибка отправки предупреждения пользователю {username} (telegram_id: {user.telegram_id}): {e}' + 'Ошибка отправки предупреждения пользователю (telegram_id: )', + username=username, + telegram_id=user.telegram_id, + error=e, ) return False, f'Ошибка Telegram API: {e!s}', user.telegram_id @@ -255,14 +279,14 @@ class BanNotificationService: # Находим пользователя user = await self._find_user_by_identifier(db, user_identifier) if not user: - logger.warning(f'Пользователь {user_identifier} не найден в базе данных') + logger.warning('Пользователь не найден в базе данных', user_identifier=user_identifier) return False, f'Пользователь не найден: {user_identifier}', None # Формируем сообщение из настроек (заметно выделяем) network_info = f'├ 🌐 Сеть: {network_type}\n' if network_type else '' node_info = f'🖥 Нода: {node_name}' if node_name else '' - logger.info(f'WiFi notification: node_name={node_name!r}, node_info={node_info!r}') + logger.info('WiFi notification: node_name=, node_info', node_name=repr(node_name), node_info=repr(node_info)) # Безопасное форматирование format_vars = {'ban_minutes': ban_minutes, 'network_info': network_info, 'node_info': node_info} @@ -287,7 +311,7 @@ class BanNotificationService: reason=reason, ) if success: - logger.info(f'Email WiFi уведомление отправлено пользователю {user.id}') + logger.info('Email WiFi уведомление отправлено пользователю', user_id=user.id) return True, 'Email уведомление отправлено', None return False, 'Не удалось отправить email уведомление', None @@ -296,12 +320,19 @@ class BanNotificationService: await self._bot.send_message( chat_id=user.telegram_id, text=message_text, parse_mode='HTML', reply_markup=get_delete_keyboard() ) - logger.info(f'Уведомление о WiFi бане отправлено пользователю {username} (telegram_id: {user.telegram_id})') + logger.info( + 'Уведомление о WiFi бане отправлено пользователю (telegram_id: )', + username=username, + telegram_id=user.telegram_id, + ) return True, 'Уведомление отправлено', user.telegram_id except TelegramAPIError as e: logger.error( - f'Ошибка отправки WiFi уведомления пользователю {username} (telegram_id: {user.telegram_id}): {e}' + 'Ошибка отправки WiFi уведомления пользователю (telegram_id: )', + username=username, + telegram_id=user.telegram_id, + error=e, ) return False, f'Ошибка Telegram API: {e!s}', user.telegram_id @@ -326,7 +357,7 @@ class BanNotificationService: # Находим пользователя user = await self._find_user_by_identifier(db, user_identifier) if not user: - logger.warning(f'Пользователь {user_identifier} не найден в базе данных') + logger.warning('Пользователь не найден в базе данных', user_identifier=user_identifier) return False, f'Пользователь не найден: {user_identifier}', None # Формируем сообщение из настроек (заметно выделяем) @@ -355,7 +386,7 @@ class BanNotificationService: reason=reason, ) if success: - logger.info(f'Email Mobile уведомление отправлено пользователю {user.id}') + logger.info('Email Mobile уведомление отправлено пользователю', user_id=user.id) return True, 'Email уведомление отправлено', None return False, 'Не удалось отправить email уведомление', None @@ -365,13 +396,18 @@ class BanNotificationService: chat_id=user.telegram_id, text=message_text, parse_mode='HTML', reply_markup=get_delete_keyboard() ) logger.info( - f'Уведомление о Mobile бане отправлено пользователю {username} (telegram_id: {user.telegram_id})' + 'Уведомление о Mobile бане отправлено пользователю (telegram_id: )', + username=username, + telegram_id=user.telegram_id, ) return True, 'Уведомление отправлено', user.telegram_id except TelegramAPIError as e: logger.error( - f'Ошибка отправки Mobile уведомления пользователю {username} (telegram_id: {user.telegram_id}): {e}' + 'Ошибка отправки Mobile уведомления пользователю (telegram_id: )', + username=username, + telegram_id=user.telegram_id, + error=e, ) return False, f'Ошибка Telegram API: {e!s}', user.telegram_id diff --git a/app/services/blacklist_service.py b/app/services/blacklist_service.py index 2b5a8803..a9fc7026 100644 --- a/app/services/blacklist_service.py +++ b/app/services/blacklist_service.py @@ -4,16 +4,16 @@ """ import asyncio -import logging import time from datetime import datetime, timedelta import aiohttp +import structlog from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class BlacklistService: @@ -72,7 +72,7 @@ class BlacklistService: # Получаем содержимое файла async with aiohttp.ClientSession() as session, session.get(raw_url) as response: if response.status != 200: - logger.error(f'Ошибка при получении черного списка: статус {response.status}') + logger.error('Ошибка при получении черного списка: статус', status=response.status) return False content = await response.text() @@ -116,20 +116,22 @@ class BlacklistService: except ValueError: # Если не удается преобразовать в число, это не ID logger.warning( - f'Неверный формат строки {line_num} в черном списке - первое значение не является числом: {line}' + 'Неверный формат строки в черном списке первое значение не является числом', + line_num=line_num, + line=line, ) self.blacklist_data = blacklist_data self.last_update = datetime.utcnow() self._check_cache.clear() - logger.info(f'Черный список успешно обновлен. Найдено {len(blacklist_data)} записей') + logger.info('Черный список успешно обновлен. Найдено записей', blacklist_data_count=len(blacklist_data)) return True except ValueError as e: - logger.error(f'Ошибка при парсинге ID из черного списка: {e}') + logger.error('Ошибка при парсинге ID из черного списка', error=e) return False except Exception as e: - logger.error(f'Ошибка при обновлении черного списка: {e}') + logger.error('Ошибка при обновлении черного списка', error=e) return False async def is_user_blacklisted(self, telegram_id: int, username: str | None = None) -> tuple[bool, str | None]: @@ -168,7 +170,7 @@ class BlacklistService: # Проверяем по Telegram ID for bl_id, bl_username, bl_reason in self.blacklist_data: if bl_id == telegram_id: - logger.info(f'Пользователь {telegram_id} найден в черном списке по ID: {bl_reason}') + logger.info('Пользователь найден в черном списке по ID', telegram_id=telegram_id, bl_reason=bl_reason) self._check_cache[telegram_id] = (True, bl_reason, now) return True, bl_reason @@ -178,7 +180,10 @@ class BlacklistService: for bl_id, bl_username, bl_reason in self.blacklist_data: if bl_username and bl_username.lower().lstrip('@') == username_lower: logger.info( - f'Пользователь {username} ({telegram_id}) найден в черном списке по username: {bl_reason}' + 'Пользователь найден в черном списке по username', + username=username, + telegram_id=telegram_id, + bl_reason=bl_reason, ) self._check_cache[telegram_id] = (True, bl_reason, now) return True, bl_reason diff --git a/app/services/blocked_users_service.py b/app/services/blocked_users_service.py index 1df8ed64..5b398846 100644 --- a/app/services/blocked_users_service.py +++ b/app/services/blocked_users_service.py @@ -6,12 +6,12 @@ """ import asyncio -import logging from collections.abc import Callable from dataclasses import dataclass, field from datetime import UTC, datetime from enum import Enum +import structlog from aiogram import Bot from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError from sqlalchemy import delete, select @@ -56,7 +56,7 @@ from app.database.models import ( from app.services.remnawave_service import RemnaWaveService -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class BlockCheckStatus(Enum): @@ -148,13 +148,13 @@ class BlockedUsersService: if 'chat not found' in error_lower or 'user not found' in error_lower: # Пользователь удалил аккаунт или никогда не начинал диалог return BlockCheckStatus.BLOCKED - logger.warning(f'TelegramBadRequest при проверке {telegram_id}: {e}') + logger.warning('TelegramBadRequest при проверке', telegram_id=telegram_id, error=e) return BlockCheckStatus.ERROR except TelegramAPIError as e: - logger.warning(f'TelegramAPIError при проверке {telegram_id}: {e}') + logger.warning('TelegramAPIError при проверке', telegram_id=telegram_id, error=e) return BlockCheckStatus.ERROR except Exception as e: - logger.error(f'Неожиданная ошибка при проверке {telegram_id}: {e}') + logger.error('Неожиданная ошибка при проверке', telegram_id=telegram_id, error=e) return BlockCheckStatus.ERROR async def _check_single_user(self, user: User) -> BlockCheckResult: @@ -214,7 +214,7 @@ class BlockedUsersService: all_users = users_result.scalars().all() total_users = len(all_users) - logger.info(f'Начинаем проверку {total_users} пользователей на блокировку бота') + logger.info('Начинаем проверку пользователей на блокировку бота', total_users=total_users) # Проверяем пользователей батчами с ограничением параллелизма semaphore = asyncio.Semaphore(self.MAX_CONCURRENT_CHECKS) @@ -234,7 +234,7 @@ class BlockedUsersService: for check_result in batch_results: if isinstance(check_result, Exception): result.errors += 1 - logger.error(f'Ошибка при проверке пользователя: {check_result}') + logger.error('Ошибка при проверке пользователя', check_result=check_result) continue result.total_checked += 1 @@ -255,8 +255,10 @@ class BlockedUsersService: result.scan_duration_seconds = (datetime.now(tz=UTC) - start_time).total_seconds() logger.info( - f'Сканирование завершено: {result.blocked_count} заблокированных ' - f'из {result.total_checked} проверенных за {result.scan_duration_seconds:.1f}с' + 'Сканирование завершено: заблокированных из проверенных за с', + blocked_count=result.blocked_count, + total_checked=result.total_checked, + scan_duration_seconds=round(result.scan_duration_seconds, 1), ) return result @@ -273,14 +275,14 @@ class BlockedUsersService: async with self.remnawave_service.get_api_client() as api: await api.delete_user(remnawave_uuid) - logger.info(f'Удален пользователь {remnawave_uuid} из Remnawave') + logger.info('Удален пользователь из Remnawave', remnawave_uuid=remnawave_uuid) return True except Exception as e: error_msg = str(e).lower() if 'not found' in error_msg or '404' in error_msg: - logger.info(f'Пользователь {remnawave_uuid} уже удален из Remnawave') + logger.info('Пользователь уже удален из Remnawave', remnawave_uuid=remnawave_uuid) return True - logger.error(f'Ошибка удаления {remnawave_uuid} из Remnawave: {e}') + logger.error('Ошибка удаления из Remnawave', remnawave_uuid=remnawave_uuid, error=e) return False async def delete_user_from_db(self, db: AsyncSession, user_id: int) -> bool: @@ -295,7 +297,7 @@ class BlockedUsersService: user = user_result.scalar_one_or_none() if not user: - logger.warning(f'Пользователь {user_id} не найден в БД') + logger.warning('Пользователь не найден в БД', user_id=user_id) return False user_display = user.telegram_id or user.email or f'#{user.id}' @@ -360,11 +362,11 @@ class BlockedUsersService: await db.delete(user) await db.commit() - logger.info(f'Пользователь {user_display} полностью удален из БД') + logger.info('Пользователь полностью удален из БД', user_display=user_display) return True except Exception as e: - logger.error(f'Ошибка удаления пользователя {user_id} из БД: {e}') + logger.error('Ошибка удаления пользователя из БД', user_id=user_id, error=e) await db.rollback() return False @@ -381,11 +383,11 @@ class BlockedUsersService: user.updated_at = datetime.now(tz=UTC) await db.commit() - logger.info(f'Пользователь {user.telegram_id or user.id} помечен как заблокированный') + logger.info('Пользователь помечен как заблокированный', telegram_id=user.telegram_id or user.id) return True except Exception as e: - logger.error(f'Ошибка пометки пользователя {user_id}: {e}') + logger.error('Ошибка пометки пользователя', user_id=user_id, error=e) await db.rollback() return False diff --git a/app/services/broadcast_service.py b/app/services/broadcast_service.py index 91310361..a2e3aee5 100644 --- a/app/services/broadcast_service.py +++ b/app/services/broadcast_service.py @@ -1,11 +1,11 @@ from __future__ import annotations import asyncio -import logging from dataclasses import dataclass from datetime import datetime from typing import TYPE_CHECKING +import structlog from aiogram import Bot from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter from aiogram.types import InlineKeyboardMarkup @@ -24,7 +24,7 @@ if TYPE_CHECKING: from app.cabinet.services.email_service import EmailService -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) VALID_MEDIA_TYPES = {'photo', 'video', 'document'} @@ -103,7 +103,7 @@ class BroadcastService: async def start_broadcast(self, broadcast_id: int, config: BroadcastConfig) -> None: if self._bot is None: - logger.error('Невозможно запустить рассылку %s: бот не инициализирован', broadcast_id) + logger.error('Невозможно запустить рассылку : бот не инициализирован', broadcast_id=broadcast_id) await self._mark_failed(broadcast_id) return @@ -111,7 +111,7 @@ class BroadcastService: async with self._lock: if broadcast_id in self._tasks and not self._tasks[broadcast_id].task.done(): - logger.warning('Рассылка %s уже запущена', broadcast_id) + logger.warning('Рассылка уже запущена', broadcast_id=broadcast_id) return task = asyncio.create_task( @@ -147,7 +147,7 @@ class BroadcastService: async with AsyncSessionLocal() as session: broadcast = await session.get(BroadcastHistory, broadcast_id) if not broadcast: - logger.error('Запись рассылки %s не найдена в БД', broadcast_id) + logger.error('Запись рассылки не найдена в БД', broadcast_id=broadcast_id) return broadcast.status = 'in_progress' @@ -161,7 +161,7 @@ class BroadcastService: async with AsyncSessionLocal() as session: broadcast = await session.get(BroadcastHistory, broadcast_id) if not broadcast: - logger.error('Запись рассылки %s удалена до запуска', broadcast_id) + logger.error('Запись рассылки удалена до запуска', broadcast_id=broadcast_id) return broadcast.total_count = len(recipient_ids) @@ -172,18 +172,18 @@ class BroadcastService: return if not recipient_ids: - logger.info('Рассылка %s: получатели не найдены', broadcast_id) + logger.info('Рассылка : получатели не найдены', broadcast_id=broadcast_id) await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False) return keyboard = self._build_keyboard(config.selected_buttons) logger.info( - 'Рассылка %s: начинаем отправку %d получателям (batch=%d, delay=%.1fs)', - broadcast_id, - len(recipient_ids), - _TG_BATCH_SIZE, - _TG_BATCH_DELAY, + 'Рассылка : начинаем отправку получателям (batch delay=s)', + broadcast_id=broadcast_id, + recipient_ids_count=len(recipient_ids), + TG_BATCH_SIZE=_TG_BATCH_SIZE, + TG_BATCH_DELAY=_TG_BATCH_DELAY, ) sent_count, failed_count, cancelled_during_run = await self._send_batched( @@ -196,15 +196,15 @@ class BroadcastService: if cancelled_during_run: logger.info( - 'Рассылка %s была отменена во время выполнения, финальный статус уже установлен', - broadcast_id, + 'Рассылка была отменена во время выполнения, финальный статус уже установлен', + broadcast_id=broadcast_id, ) return if cancel_event.is_set(): logger.info( - 'Запрос на отмену рассылки %s пришел после завершения отправки, фиксируем итоговый статус', - broadcast_id, + 'Запрос на отмену рассылки пришел после завершения отправки, фиксируем итоговый статус', + broadcast_id=broadcast_id, ) await self._mark_finished( @@ -218,7 +218,7 @@ class BroadcastService: await self._mark_cancelled(broadcast_id, sent_count, failed_count) raise except Exception as exc: - logger.exception('Критическая ошибка при выполнении рассылки %s: %s', broadcast_id, exc) + logger.exception('Критическая ошибка при выполнении рассылки', broadcast_id=broadcast_id, exc=exc) await self._mark_failed(broadcast_id, sent_count, failed_count) async def _fetch_recipients(self, target: str) -> list[int]: @@ -277,12 +277,12 @@ class BroadcastService: wait_seconds = e.retry_after + 1 flood_wait_until = asyncio.get_event_loop().time() + wait_seconds logger.warning( - 'FloodWait рассылки %s: Telegram просит %d сек (user=%d, попытка %d/%d)', - broadcast_id, - e.retry_after, - telegram_id, - attempt + 1, - _TG_MAX_RETRIES, + 'FloodWait рассылки : Telegram просит сек (user попытка /)', + broadcast_id=broadcast_id, + retry_after=e.retry_after, + telegram_id=telegram_id, + attempt=attempt + 1, + TG_MAX_RETRIES=_TG_MAX_RETRIES, ) await asyncio.sleep(wait_seconds) @@ -294,12 +294,12 @@ class BroadcastService: except Exception as exc: logger.error( - 'Ошибка отправки рассылки %s пользователю %d (попытка %d/%d): %s', - broadcast_id, - telegram_id, - attempt + 1, - _TG_MAX_RETRIES, - exc, + 'Ошибка отправки рассылки пользователю (попытка /)', + broadcast_id=broadcast_id, + telegram_id=telegram_id, + attempt=attempt + 1, + TG_MAX_RETRIES=_TG_MAX_RETRIES, + exc=exc, ) if attempt < _TG_MAX_RETRIES - 1: await asyncio.sleep(0.5 * (attempt + 1)) @@ -325,7 +325,7 @@ class BroadcastService: failed_count += 1 elif isinstance(result, Exception): failed_count += 1 - logger.error('Необработанное исключение в рассылке %s: %s', broadcast_id, result) + logger.error('Необработанное исключение в рассылке', broadcast_id=broadcast_id, result=result) # Обновляем прогресс в БД периодически processed = sent_count + failed_count @@ -474,14 +474,14 @@ class BroadcastService: except InterfaceError as exc: attempts += 1 logger.warning( - 'Проблемы с соединением при обновлении статуса рассылки %s: %s. Повтор %s/2', - broadcast_id, - exc, - attempts, + 'Проблемы с соединением при обновлении статуса рассылки : . Повтор /2', + broadcast_id=broadcast_id, + exc=exc, + attempts=attempts, ) await asyncio.sleep(0.2) except SQLAlchemyError: - logger.exception('Не удалось обновить статус рассылки %s', broadcast_id) + logger.exception('Не удалось обновить статус рассылки', broadcast_id=broadcast_id) return @@ -508,12 +508,12 @@ class EmailBroadcastService: async def start_broadcast(self, broadcast_id: int, config: EmailBroadcastConfig) -> None: """Start email broadcast in background.""" if self._email_service is None: - logger.error('Cannot start email broadcast %s: email service not initialized', broadcast_id) + logger.error('Cannot start email broadcast : email service not initialized', broadcast_id=broadcast_id) await self._mark_failed(broadcast_id) return if not self._email_service.is_configured(): - logger.error('Cannot start email broadcast %s: SMTP not configured', broadcast_id) + logger.error('Cannot start email broadcast : SMTP not configured', broadcast_id=broadcast_id) await self._mark_failed(broadcast_id) return @@ -521,7 +521,7 @@ class EmailBroadcastService: async with self._lock: if broadcast_id in self._tasks and not self._tasks[broadcast_id].task.done(): - logger.warning('Email broadcast %s is already running', broadcast_id) + logger.warning('Email broadcast is already running', broadcast_id=broadcast_id) return task = asyncio.create_task( @@ -560,7 +560,7 @@ class EmailBroadcastService: async with AsyncSessionLocal() as session: broadcast = await session.get(BroadcastHistory, broadcast_id) if not broadcast: - logger.error('Broadcast record %s not found', broadcast_id) + logger.error('Broadcast record not found', broadcast_id=broadcast_id) return broadcast.status = 'in_progress' @@ -575,7 +575,7 @@ class EmailBroadcastService: async with AsyncSessionLocal() as session: broadcast = await session.get(BroadcastHistory, broadcast_id) if not broadcast: - logger.error('Broadcast record %s deleted before start', broadcast_id) + logger.error('Broadcast record deleted before start', broadcast_id=broadcast_id) return broadcast.total_count = len(recipients) @@ -586,7 +586,7 @@ class EmailBroadcastService: return if not recipients: - logger.info('Email broadcast %s: no recipients found', broadcast_id) + logger.info('Email broadcast : no recipients found', broadcast_id=broadcast_id) await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False) return @@ -599,7 +599,7 @@ class EmailBroadcastService: ) if was_cancelled: - logger.info('Email broadcast %s was cancelled during execution', broadcast_id) + logger.info('Email broadcast was cancelled during execution', broadcast_id=broadcast_id) return await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False) @@ -608,7 +608,7 @@ class EmailBroadcastService: await self._mark_cancelled(broadcast_id, sent_count, failed_count) raise except Exception as exc: - logger.exception('Critical error in email broadcast %s: %s', broadcast_id, exc) + logger.exception('Critical error in email broadcast', broadcast_id=broadcast_id, exc=exc) await self._mark_failed(broadcast_id, sent_count, failed_count) async def _fetch_email_recipients(self, target: str) -> list[_EmailRecipient]: @@ -672,7 +672,7 @@ class EmailBroadcastService: ) else: - logger.warning('Unknown email target filter: %s', target) + logger.warning('Unknown email target filter', target=target) return [] # Загружаем батчами и извлекаем скаляры сразу @@ -748,10 +748,7 @@ class EmailBroadcastService: return success except Exception as exc: logger.error( - 'Ошибка отправки email рассылки %s на %s: %s', - broadcast_id, - recipient.email, - exc, + 'Ошибка отправки email рассылки на', broadcast_id=broadcast_id, email=recipient.email, exc=exc ) return False @@ -875,14 +872,14 @@ class EmailBroadcastService: except InterfaceError as exc: attempts += 1 logger.warning( - 'Connection issue updating email broadcast %s: %s. Retry %s/2', - broadcast_id, - exc, - attempts, + 'Connection issue updating email broadcast : . Retry /2', + broadcast_id=broadcast_id, + exc=exc, + attempts=attempts, ) await asyncio.sleep(0.2) except SQLAlchemyError: - logger.exception('Failed to update email broadcast status %s', broadcast_id) + logger.exception('Failed to update email broadcast status', broadcast_id=broadcast_id) return diff --git a/app/services/bulk_ban_service.py b/app/services/bulk_ban_service.py index 7884201a..4956ff71 100644 --- a/app/services/bulk_ban_service.py +++ b/app/services/bulk_ban_service.py @@ -2,8 +2,7 @@ Модуль для массовой блокировки пользователей по списку Telegram ID """ -import logging - +import structlog from aiogram import Bot from sqlalchemy.ext.asyncio import AsyncSession @@ -13,7 +12,7 @@ from app.services.admin_notification_service import AdminNotificationService from app.services.user_service import UserService -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class BulkBanService: @@ -59,13 +58,13 @@ class BulkBanService: user = await get_user_by_telegram_id(db, telegram_id) if not user: - logger.warning(f'Пользователь с Telegram ID {telegram_id} не найден') + logger.warning('Пользователь с Telegram ID не найден', telegram_id=telegram_id) not_found_users.append(telegram_id) continue # Проверяем, что пользователь не заблокирован уже if user.status == UserStatus.BLOCKED.value: - logger.info(f'Пользователь {telegram_id} уже заблокирован') + logger.info('Пользователь уже заблокирован', telegram_id=telegram_id) continue # Блокируем пользователя @@ -73,7 +72,7 @@ class BulkBanService: if ban_success: successfully_banned += 1 - logger.info(f'Пользователь {telegram_id} успешно заблокирован') + logger.info('Пользователь успешно заблокирован', telegram_id=telegram_id) # Отправляем уведомление пользователю, если возможно if bot: @@ -89,13 +88,15 @@ class BulkBanService: parse_mode='HTML', ) except Exception as e: - logger.warning(f'Не удалось отправить уведомление пользователю {telegram_id}: {e}') + logger.warning( + 'Не удалось отправить уведомление пользователю', telegram_id=telegram_id, error=e + ) else: - logger.error(f'Не удалось заблокировать пользователя {telegram_id}') + logger.error('Не удалось заблокировать пользователя', telegram_id=telegram_id) error_ids.append(telegram_id) except Exception as e: - logger.error(f'Ошибка при блокировке пользователя {telegram_id}: {e}') + logger.error('Ошибка при блокировке пользователя', telegram_id=telegram_id, error=e) error_ids.append(telegram_id) # Отправляем уведомление администратору @@ -106,11 +107,13 @@ class BulkBanService: admin_user_id, successfully_banned, len(not_found_users), len(error_ids), admin_name ) except Exception as e: - logger.error(f'Ошибка при отправке уведомления администратору: {e}') + logger.error('Ошибка при отправке уведомления администратору', error=e) logger.info( - f'Массовая блокировка завершена: успешно={successfully_banned}, ' - f'не найдено={len(not_found_users)}, ошибки={len(error_ids)}' + 'Массовая блокировка завершена: успешно=, не найдено=, ошибки', + successfully_banned=successfully_banned, + not_found_users_count=len(not_found_users), + error_ids_count=len(error_ids), ) return successfully_banned, len(not_found_users), error_ids diff --git a/app/services/campaign_service.py b/app/services/campaign_service.py index ffb9658c..68db2c48 100644 --- a/app/services/campaign_service.py +++ b/app/services/campaign_service.py @@ -1,6 +1,6 @@ -import logging from dataclasses import dataclass +import structlog from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -15,7 +15,7 @@ from app.database.models import AdvertisingCampaign, User from app.services.subscription_service import SubscriptionService -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _format_user_log(user: User) -> str: @@ -53,7 +53,7 @@ class AdvertisingCampaignService: campaign: AdvertisingCampaign, ) -> CampaignBonusResult: if not campaign.is_active: - logger.warning('⚠️ Попытка выдать бонус по неактивной кампании %s', campaign.id) + logger.warning('⚠️ Попытка выдать бонус по неактивной кампании', campaign_id=campaign.id) return CampaignBonusResult(success=False) if campaign.is_balance_bonus: @@ -68,7 +68,7 @@ class AdvertisingCampaignService: if campaign.is_tariff_bonus: return await self._apply_tariff_bonus(db, user, campaign) - logger.error('❌ Неизвестный тип бонуса кампании: %s', campaign.bonus_type) + logger.error('❌ Неизвестный тип бонуса кампании', bonus_type=campaign.bonus_type) return CampaignBonusResult(success=False) async def _apply_balance_bonus( @@ -79,7 +79,7 @@ class AdvertisingCampaignService: ) -> CampaignBonusResult: amount = campaign.balance_bonus_kopeks or 0 if amount <= 0: - logger.info('ℹ️ Кампания %s не имеет бонуса на баланс', campaign.id) + logger.info('ℹ️ Кампания не имеет бонуса на баланс', campaign_id=campaign.id) return CampaignBonusResult(success=False) description = f"Бонус за регистрацию по кампании '{campaign.name}'" @@ -102,10 +102,10 @@ class AdvertisingCampaignService: ) logger.info( - '💰 Пользователю %s начислен бонус %s₽ по кампании %s', - _format_user_log(user), - amount / 100, - campaign.id, + '💰 Пользователю начислен бонус ₽ по кампании', + format_user_log=_format_user_log(user), + amount=amount / 100, + campaign_id=campaign.id, ) return CampaignBonusResult( @@ -123,18 +123,15 @@ class AdvertisingCampaignService: existing_subscription = await get_subscription_by_user_id(db, user.id) if existing_subscription: logger.warning( - '⚠️ У пользователя %s уже есть подписка, бонус кампании %s пропущен', - _format_user_log(user), - campaign.id, + '⚠️ У пользователя уже есть подписка, бонус кампании пропущен', + format_user_log=_format_user_log(user), + campaign_id=campaign.id, ) return CampaignBonusResult(success=False) duration_days = campaign.subscription_duration_days or 0 if duration_days <= 0: - logger.info( - 'ℹ️ Кампания %s не содержит корректной длительности подписки', - campaign.id, - ) + logger.info('ℹ️ Кампания не содержит корректной длительности подписки', campaign_id=campaign.id) return CampaignBonusResult(success=False) traffic_limit = campaign.subscription_traffic_gb @@ -151,11 +148,7 @@ class AdvertisingCampaignService: if trial_uuid: squads = [trial_uuid] except Exception as error: - logger.error( - 'Не удалось подобрать сквад для кампании %s: %s', - campaign.id, - error, - ) + logger.error('Не удалось подобрать сквад для кампании', campaign_id=campaign.id, error=error) squads[0] if squads else None @@ -173,11 +166,7 @@ class AdvertisingCampaignService: try: await self.subscription_service.create_remnawave_user(db, new_subscription) except Exception as error: - logger.error( - '❌ Ошибка синхронизации RemnaWave для кампании %s: %s', - campaign.id, - error, - ) + logger.error('❌ Ошибка синхронизации RemnaWave для кампании', campaign_id=campaign.id, error=error) await record_campaign_registration( db, @@ -188,10 +177,10 @@ class AdvertisingCampaignService: ) logger.info( - '🎁 Пользователю %s выдана подписка по кампании %s на %s дней', - _format_user_log(user), - campaign.id, - duration_days, + '🎁 Пользователю выдана подписка по кампании на дней', + format_user_log=_format_user_log(user), + campaign_id=campaign.id, + duration_days=duration_days, ) return CampaignBonusResult( @@ -218,9 +207,9 @@ class AdvertisingCampaignService: ) logger.info( - '📊 Пользователь %s зарегистрирован по ссылке кампании %s (без награды)', - _format_user_log(user), - campaign.id, + '📊 Пользователь зарегистрирован по ссылке кампании (без награды)', + format_user_log=_format_user_log(user), + campaign_id=campaign.id, ) return CampaignBonusResult( @@ -238,43 +227,29 @@ class AdvertisingCampaignService: existing_subscription = await get_subscription_by_user_id(db, user.id) if existing_subscription: logger.warning( - '⚠️ У пользователя %s уже есть подписка, бонус тарифа кампании %s пропущен', - _format_user_log(user), - campaign.id, + '⚠️ У пользователя уже есть подписка, бонус тарифа кампании пропущен', + format_user_log=_format_user_log(user), + campaign_id=campaign.id, ) return CampaignBonusResult(success=False) if not campaign.tariff_id: - logger.error( - '❌ Кампания %s не имеет указанного тарифа для выдачи', - campaign.id, - ) + logger.error('❌ Кампания не имеет указанного тарифа для выдачи', campaign_id=campaign.id) return CampaignBonusResult(success=False) duration_days = campaign.tariff_duration_days or 0 if duration_days <= 0: - logger.error( - '❌ Кампания %s не имеет указанной длительности тарифа', - campaign.id, - ) + logger.error('❌ Кампания не имеет указанной длительности тарифа', campaign_id=campaign.id) return CampaignBonusResult(success=False) # Получаем тариф для извлечения параметров tariff = await get_tariff_by_id(db, campaign.tariff_id) if not tariff: - logger.error( - '❌ Тариф %s не найден для кампании %s', - campaign.tariff_id, - campaign.id, - ) + logger.error('❌ Тариф не найден для кампании', tariff_id=campaign.tariff_id, campaign_id=campaign.id) return CampaignBonusResult(success=False) if not tariff.is_active: - logger.warning( - '⚠️ Тариф %s неактивен, бонус кампании %s пропущен', - tariff.id, - campaign.id, - ) + logger.warning('⚠️ Тариф неактивен, бонус кампании пропущен', tariff_id=tariff.id, campaign_id=campaign.id) return CampaignBonusResult(success=False) traffic_limit = tariff.traffic_limit_gb @@ -289,11 +264,7 @@ class AdvertisingCampaignService: if trial_uuid: squads = [trial_uuid] except Exception as error: - logger.error( - 'Не удалось подобрать сквад для тарифа кампании %s: %s', - campaign.id, - error, - ) + logger.error('Не удалось подобрать сквад для тарифа кампании', campaign_id=campaign.id, error=error) # Создаём подписку как платную (не trial) с привязкой к тарифу new_subscription = await create_paid_subscription( @@ -311,11 +282,7 @@ class AdvertisingCampaignService: try: await self.subscription_service.create_remnawave_user(db, new_subscription) except Exception as error: - logger.error( - '❌ Ошибка синхронизации RemnaWave для тарифа кампании %s: %s', - campaign.id, - error, - ) + logger.error('❌ Ошибка синхронизации RemnaWave для тарифа кампании', campaign_id=campaign.id, error=error) await record_campaign_registration( db, @@ -327,11 +294,11 @@ class AdvertisingCampaignService: ) logger.info( - "🎁 Пользователю %s выдан тариф '%s' по кампании %s на %s дней", - _format_user_log(user), - tariff.name, - campaign.id, - duration_days, + "🎁 Пользователю выдан тариф '' по кампании на дней", + format_user_log=_format_user_log(user), + tariff_name=tariff.name, + campaign_id=campaign.id, + duration_days=duration_days, ) return CampaignBonusResult( diff --git a/app/services/cloudpayments_service.py b/app/services/cloudpayments_service.py index ac157036..7173cb01 100644 --- a/app/services/cloudpayments_service.py +++ b/app/services/cloudpayments_service.py @@ -5,17 +5,17 @@ from __future__ import annotations import base64 import hashlib import hmac -import logging import time from typing import Any from urllib.parse import unquote_plus import httpx +import structlog from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class CloudPaymentsAPIError(RuntimeError): @@ -83,13 +83,13 @@ class CloudPaymentsService: data = response.json() if response.status_code >= 400: - logger.error('CloudPayments API error %s: %s', response.status_code, data) + logger.error('CloudPayments API error', status_code=response.status_code, data=data) raise CloudPaymentsAPIError(f'CloudPayments API returned status {response.status_code}') return data except httpx.RequestError as error: - logger.error('Error communicating with CloudPayments API: %s', error) + logger.error('Error communicating with CloudPayments API', error=error) raise CloudPaymentsAPIError('Failed to communicate with CloudPayments API') from error @staticmethod @@ -167,21 +167,17 @@ class CloudPaymentsService: if not response.get('Success'): error_message = response.get('Message', 'Unknown error') - logger.error('CloudPayments orders/create failed: %s', error_message) + logger.error('CloudPayments orders/create failed', error_message=error_message) raise CloudPaymentsAPIError(f'Failed to create order: {error_message}') model = response.get('Model', {}) payment_url = model.get('Url') if not payment_url: - logger.error('CloudPayments orders/create returned no URL: %s', response) + logger.error('CloudPayments orders/create returned no URL', response=response) raise CloudPaymentsAPIError('CloudPayments API returned no payment URL') - logger.info( - 'CloudPayments order created: id=%s, url=%s', - model.get('Id'), - payment_url, - ) + logger.info('CloudPayments order created: id url', model=model.get('Id'), payment_url=payment_url) return payment_url @@ -316,10 +312,10 @@ class CloudPaymentsService: pass logger.warning( - 'CloudPayments signature mismatch: expected_raw=%s..., expected_decoded=%s..., got=%s...', - calculated_raw[:20], - calculated_decoded[:20] if calculated_decoded else 'N/A', - signature[:20], + 'CloudPayments signature mismatch: expected_raw=..., expected_decoded=..., got=...', + calculated_raw=calculated_raw[:20], + calculated_decoded=calculated_decoded[:20] if calculated_decoded else 'N/A', + signature=signature[:20], ) return False diff --git a/app/services/contest_rotation_service.py b/app/services/contest_rotation_service.py index 9bd5ab2f..105f9f8f 100644 --- a/app/services/contest_rotation_service.py +++ b/app/services/contest_rotation_service.py @@ -1,8 +1,8 @@ import asyncio -import logging from datetime import UTC, datetime, time, timedelta from zoneinfo import ZoneInfo +import structlog from aiogram import Bot from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession @@ -20,7 +20,7 @@ from app.services.contests.enums import GameType, PrizeType from app.services.contests.games import get_game_strategy -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # Legacy aliases for backward compatibility GAME_QUEST = GameType.QUEST_BUTTONS.value @@ -166,7 +166,7 @@ class ContestRotationService: try: await upsert_template(db, **tpl) except Exception as exc: - logger.error('Не удалось создать шаблон %s: %s', tpl['slug'], exc) + logger.error('Не удалось создать шаблон', tpl=tpl['slug'], exc=exc) async def _loop(self) -> None: try: @@ -176,7 +176,7 @@ class ContestRotationService: except asyncio.CancelledError: raise except Exception as exc: - logger.error('Ошибка в ротации конкурсов: %s', exc) + logger.error('Ошибка в ротации конкурсов', exc=exc) await asyncio.sleep(self._interval_seconds) except asyncio.CancelledError: logger.info('Сервис ротации конкурсов остановлен') @@ -234,14 +234,14 @@ class ContestRotationService: ends_at=ends_at_utc, payload=payload, ) - logger.info('Создан раунд %s для шаблона %s', round_obj.id, tpl.slug) + logger.info('Создан раунд для шаблона', round_obj_id=round_obj.id, slug=tpl.slug) def _get_timezone(self) -> ZoneInfo: tz_name = settings.TIMEZONE or 'UTC' try: return ZoneInfo(tz_name) except Exception: - logger.warning('Не удалось загрузить TZ %s, используем UTC', tz_name) + logger.warning('Не удалось загрузить TZ , используем UTC', tz_name=tz_name) return ZoneInfo('UTC') def _build_payload_for_template(self, tpl: ContestTemplate) -> dict: @@ -315,7 +315,7 @@ class ContestRotationService: reply_markup=keyboard, ) except Exception as exc: - logger.error('Не удалось отправить анонс в канал %s: %s', channel_id_raw, exc) + logger.error('Не удалось отправить анонс в канал', channel_id_raw=channel_id_raw, exc=exc) async def _broadcast_to_users(self, text: str) -> None: """Отправляет анонс всем пользователям с активной/триальной подпиской.""" @@ -364,9 +364,9 @@ class ContestRotationService: await asyncio.gather(*tasks, return_exceptions=True) - logger.info('Анонс игр: отправлено=%s, ошибок=%s', sent, failed) + logger.info('Анонс игр: отправлено ошибок', sent=sent, failed=failed) except Exception as exc: - logger.error('Ошибка рассылки анонса игр пользователям: %s', exc) + logger.error('Ошибка рассылки анонса игр пользователям', exc=exc) async def _load_users_batch(self, db: AsyncSession, offset: int, limit: int) -> list[User]: from app.database.crud.user import get_users_list diff --git a/app/services/contests/attempt_service.py b/app/services/contests/attempt_service.py index 0e4db4bc..dcc06485 100644 --- a/app/services/contests/attempt_service.py +++ b/app/services/contests/attempt_service.py @@ -1,8 +1,8 @@ """Service for atomic contest attempt operations.""" -import logging from dataclasses import dataclass +import structlog from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -15,7 +15,7 @@ from app.services.contests.enums import PrizeType from app.services.contests.games import get_game_strategy -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @dataclass @@ -95,7 +95,13 @@ class ContestAttemptService: is_winner=is_winner, ) - logger.info("Contest attempt: user %s, round %s, pick '%s', winner %s", user_id, round_obj.id, pick, is_winner) + logger.info( + "Contest attempt: user , round , pick '', winner", + user_id=user_id, + round_obj_id=round_obj.id, + pick=pick, + is_winner=is_winner, + ) if is_winner: prize_msg = await self._award_prize(db, user_id, tpl, language) @@ -177,11 +183,11 @@ class ContestAttemptService: await update_attempt(db, attempt, answer=text_answer.strip().upper(), is_winner=is_winner) logger.info( - "Contest text attempt: user %s, round %s, answer '%s', winner %s", - user_id, - round_obj.id, - text_answer, - is_winner, + "Contest text attempt: user , round , answer '', winner", + user_id=user_id, + round_obj_id=round_obj.id, + text_answer=text_answer, + is_winner=is_winner, ) if is_winner: diff --git a/app/services/daily_subscription_service.py b/app/services/daily_subscription_service.py index 1732f61e..80134753 100644 --- a/app/services/daily_subscription_service.py +++ b/app/services/daily_subscription_service.py @@ -5,9 +5,9 @@ """ import asyncio -import logging from datetime import datetime +import structlog from aiogram import Bot from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -29,7 +29,7 @@ from app.services.notification_delivery_service import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class DailySubscriptionService: @@ -84,15 +84,20 @@ class DailySubscriptionService: elif result == 'error': stats['errors'] += 1 except Exception as e: - logger.error(f'Ошибка обработки суточной подписки {subscription.id}: {e}', exc_info=True) + logger.error( + 'Ошибка обработки суточной подписки', + subscription_id=subscription.id, + error=e, + exc_info=True, + ) stats['errors'] += 1 await db.commit() except Exception as e: - logger.error(f'Ошибка при обработке подписок: {e}', exc_info=True) + logger.error('Ошибка при обработке подписок', error=e, exc_info=True) await db.rollback() except Exception as e: - logger.error(f'Ошибка при получении подписок для списания: {e}', exc_info=True) + logger.error('Ошибка при получении подписок для списания', error=e, exc_info=True) return stats @@ -108,17 +113,17 @@ class DailySubscriptionService: user = await get_user_by_id(db, subscription.user_id) if not user: - logger.warning(f'Пользователь не найден для подписки {subscription.id}') + logger.warning('Пользователь не найден для подписки', subscription_id=subscription.id) return 'error' tariff = subscription.tariff if not tariff: - logger.warning(f'Тариф не найден для подписки {subscription.id}') + logger.warning('Тариф не найден для подписки', subscription_id=subscription.id) return 'error' daily_price = tariff.daily_price_kopeks if daily_price <= 0: - logger.warning(f'Некорректная суточная цена для тарифа {tariff.id}') + logger.warning('Некорректная суточная цена для тарифа', tariff_id=tariff.id) return 'error' # Проверяем баланс @@ -131,8 +136,10 @@ class DailySubscriptionService: await self._notify_insufficient_balance(user, subscription, daily_price) logger.info( - f'Подписка {subscription.id} приостановлена: недостаточно средств ' - f'(баланс: {user.balance_kopeks}, требуется: {daily_price})' + 'Подписка приостановлена: недостаточно средств (баланс: требуется: )', + subscription_id=subscription.id, + balance_kopeks=user.balance_kopeks, + daily_price=daily_price, ) return 'suspended' @@ -148,7 +155,7 @@ class DailySubscriptionService: ) if not deducted: - logger.warning(f'Не удалось списать средства для подписки {subscription.id}') + logger.warning('Не удалось списать средства для подписки', subscription_id=subscription.id) return 'error' # Создаём транзакцию @@ -166,8 +173,10 @@ class DailySubscriptionService: user_id_display = user.telegram_id or user.email or f'#{user.id}' logger.info( - f'✅ Суточное списание: подписка {subscription.id}, ' - f'сумма {daily_price} коп., пользователь {user_id_display}' + '✅ Суточное списание: подписка сумма коп., пользователь', + subscription_id=subscription.id, + daily_price=daily_price, + user_id_display=user_id_display, ) # Синхронизируем с Remnawave (обновляем срок подписки) @@ -182,7 +191,7 @@ class DailySubscriptionService: reset_reason=None, ) except Exception as e: - logger.warning(f'Не удалось обновить Remnawave: {e}') + logger.warning('Не удалось обновить Remnawave', error=e) # Уведомляем пользователя if self._bot: @@ -191,7 +200,9 @@ class DailySubscriptionService: return 'charged' except Exception as e: - logger.error(f'Ошибка при списании средств для подписки {subscription.id}: {e}', exc_info=True) + logger.error( + 'Ошибка при списании средств для подписки', subscription_id=subscription.id, error=e, exc_info=True + ) return 'error' async def _notify_daily_charge(self, user, subscription, amount_kopeks: int): @@ -217,7 +228,7 @@ class DailySubscriptionService: telegram_message=message, ) except Exception as e: - logger.warning(f'Не удалось отправить уведомление о списании: {e}') + logger.warning('Не удалось отправить уведомление о списании', error=e) async def _notify_insufficient_balance(self, user, subscription, required_amount: int): """Уведомляет пользователя о недостатке средств.""" @@ -258,7 +269,7 @@ class DailySubscriptionService: telegram_markup=keyboard, ) except Exception as e: - logger.warning(f'Не удалось отправить уведомление о недостатке средств: {e}') + logger.warning('Не удалось отправить уведомление о недостатке средств', error=e) async def process_traffic_resets(self) -> dict: """ @@ -298,15 +309,20 @@ class DailySubscriptionService: await self._reset_subscription_traffic(db, subscription_id, purchases) stats['reset'] += len(purchases) except Exception as e: - logger.error(f'Ошибка сброса трафика подписки {subscription_id}: {e}', exc_info=True) + logger.error( + 'Ошибка сброса трафика подписки', + subscription_id=subscription_id, + error=e, + exc_info=True, + ) stats['errors'] += 1 await db.commit() except Exception as e: - logger.error(f'Ошибка при обработке сброса трафика: {e}', exc_info=True) + logger.error('Ошибка при обработке сброса трафика', error=e, exc_info=True) await db.rollback() except Exception as e: - logger.error(f'Ошибка при получении подписок для сброса трафика: {e}', exc_info=True) + logger.error('Ошибка при получении подписок для сброса трафика', error=e, exc_info=True) return stats @@ -330,9 +346,11 @@ class DailySubscriptionService: # КРИТИЧЕСКАЯ ПРОВЕРКА: защита от некорректных данных if total_expired_gb > old_purchased: logger.error( - f'⚠️ ОШИБКА ДАННЫХ: подписка {subscription.id}, ' - f'истекает {total_expired_gb} ГБ, но purchased_traffic_gb = {old_purchased} ГБ. ' - f'Сбрасываем только {old_purchased} ГБ.' + '⚠️ ОШИБКА ДАННЫХ: подписка истекает ГБ, но purchased_traffic_gb ГБ. Сбрасываем только ГБ.', + subscription_id=subscription.id, + total_expired_gb=total_expired_gb, + old_purchased=old_purchased, + old_purchased_2=old_purchased, ) total_expired_gb = old_purchased @@ -349,8 +367,10 @@ class DailySubscriptionService: # Проверяем, что базовый лимит не отрицательный if base_limit < 0: logger.warning( - f'⚠️ Базовый лимит отрицательный для подписки {subscription.id}: {base_limit} ГБ. ' - f'Используем лимит из тарифа: {tariff_base_limit} ГБ' + '⚠️ Базовый лимит отрицательный для подписки ГБ. Используем лимит из тарифа: ГБ', + subscription_id=subscription.id, + base_limit=base_limit, + tariff_base_limit=tariff_base_limit, ) base_limit = tariff_base_limit @@ -368,8 +388,9 @@ class DailySubscriptionService: # Двойная защита: новый лимит не может быть меньше базового if new_limit < base_limit: logger.error( - f'⚠️ КРИТИЧЕСКАЯ ОШИБКА: новый лимит ({new_limit} ГБ) меньше базового ({base_limit} ГБ). ' - f'Устанавливаем базовый лимит.' + '⚠️ КРИТИЧЕСКАЯ ОШИБКА: новый лимит ( ГБ) меньше базового ( ГБ). Устанавливаем базовый лимит.', + new_limit=new_limit, + base_limit=base_limit, ) new_limit = base_limit new_purchased = 0 @@ -401,10 +422,16 @@ class DailySubscriptionService: await db.commit() logger.info( - f'🔄 Сброс истекших докупок: подписка {subscription.id}, ' - f'было {old_limit} ГБ (базовый: {base_limit} ГБ, докуплено: {old_purchased} ГБ), ' - f'стало {subscription.traffic_limit_gb} ГБ (базовый: {base_limit} ГБ, докуплено: {new_purchased} ГБ), ' - f'убрано {total_expired_gb} ГБ из {len(expired_purchases)} покупок' + '🔄 Сброс истекших докупок: подписка было ГБ (базовый: ГБ, докуплено: ГБ), стало ГБ (базовый: ГБ, докуплено: ГБ), убрано ГБ из покупок', + subscription_id=subscription.id, + old_limit=old_limit, + base_limit=base_limit, + old_purchased=old_purchased, + traffic_limit_gb=subscription.traffic_limit_gb, + base_limit_2=base_limit, + new_purchased=new_purchased, + total_expired_gb=total_expired_gb, + expired_purchases_count=len(expired_purchases), ) # Синхронизируем с RemnaWave @@ -414,7 +441,7 @@ class DailySubscriptionService: subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) except Exception as e: - logger.warning(f'Не удалось синхронизировать с RemnaWave после сброса трафика: {e}') + logger.warning('Не удалось синхронизировать с RemnaWave после сброса трафика', error=e) # Уведомляем пользователя if self._bot and subscription.user_id: @@ -447,14 +474,14 @@ class DailySubscriptionService: telegram_message=message, ) except Exception as e: - logger.warning(f'Не удалось отправить уведомление о сбросе трафика: {e}') + logger.warning('Не удалось отправить уведомление о сбросе трафика', error=e) async def start_monitoring(self): """Запускает периодическую проверку суточных подписок и сброса трафика.""" self._running = True interval_minutes = self.get_check_interval_minutes() - logger.info(f'🔄 Запуск сервиса суточных подписок (интервал: {interval_minutes} мин)') + logger.info('🔄 Запуск сервиса суточных подписок (интервал: мин)', interval_minutes=interval_minutes) while self._running: try: @@ -463,20 +490,24 @@ class DailySubscriptionService: if stats['charged'] > 0 or stats['suspended'] > 0: logger.info( - f'📊 Суточные списания: проверено={stats["checked"]}, ' - f'списано={stats["charged"]}, приостановлено={stats["suspended"]}, ' - f'ошибок={stats["errors"]}' + '📊 Суточные списания: проверено=, списано=, приостановлено=, ошибок', + stats=stats['checked'], + stats_2=stats['charged'], + stats_3=stats['suspended'], + stats_4=stats['errors'], ) # Обработка сброса докупленного трафика traffic_stats = await self.process_traffic_resets() if traffic_stats['reset'] > 0: logger.info( - f'📊 Сброс трафика: проверено={traffic_stats["checked"]}, ' - f'сброшено={traffic_stats["reset"]}, ошибок={traffic_stats["errors"]}' + '📊 Сброс трафика: проверено=, сброшено=, ошибок', + traffic_stats=traffic_stats['checked'], + traffic_stats_2=traffic_stats['reset'], + traffic_stats_3=traffic_stats['errors'], ) except Exception as e: - logger.error(f'Ошибка в цикле проверки суточных подписок: {e}', exc_info=True) + logger.error('Ошибка в цикле проверки суточных подписок', error=e, exc_info=True) await asyncio.sleep(interval_minutes * 60) diff --git a/app/services/disposable_email_service.py b/app/services/disposable_email_service.py index 3bb9a370..ec08640d 100644 --- a/app/services/disposable_email_service.py +++ b/app/services/disposable_email_service.py @@ -1,15 +1,15 @@ """Service for blocking disposable/temporary email domains.""" import asyncio -import logging from datetime import UTC, datetime import aiohttp +import structlog from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class DisposableEmailService: @@ -34,7 +34,7 @@ class DisposableEmailService: """Load domains and start periodic refresh task.""" await self._update_domains() self._task = asyncio.create_task(self._periodic_loop()) - logger.info('DisposableEmailService started (%d domains loaded)', self._domain_count) + logger.info('DisposableEmailService started (domains loaded)', domain_count=self._domain_count) async def stop(self) -> None: """Cancel periodic refresh task.""" @@ -52,10 +52,7 @@ class DisposableEmailService: try: async with aiohttp.ClientSession() as session, session.get(self.DOMAINS_URL) as resp: if resp.status != 200: - logger.error( - 'Failed to fetch disposable domains: HTTP %d', - resp.status, - ) + logger.error('Failed to fetch disposable domains: HTTP', resp_status=resp.status) return text = await resp.text() @@ -67,7 +64,7 @@ class DisposableEmailService: self._domains = domains self._domain_count = len(domains) self._last_updated = datetime.now(UTC) - logger.info('Disposable email domains updated: %d domains', self._domain_count) + logger.info('Disposable email domains updated: domains', domain_count=self._domain_count) except Exception: logger.exception('Error updating disposable email domains') diff --git a/app/services/event_emitter.py b/app/services/event_emitter.py index 83a0a530..3e254340 100644 --- a/app/services/event_emitter.py +++ b/app/services/event_emitter.py @@ -2,17 +2,17 @@ from __future__ import annotations import asyncio import json -import logging from collections.abc import Callable from datetime import datetime from typing import Any +import structlog from sqlalchemy.ext.asyncio import AsyncSession from app.services.webhook_service import webhook_service -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class EventEmitter: @@ -39,12 +39,16 @@ class EventEmitter: def register_websocket(self, websocket: Any) -> None: """Зарегистрировать WebSocket подключение.""" self._websocket_connections.add(websocket) - logger.debug('WebSocket connection registered. Total: %d', len(self._websocket_connections)) + logger.debug( + 'WebSocket connection registered. Total', websocket_connections_count=len(self._websocket_connections) + ) def unregister_websocket(self, websocket: Any) -> None: """Отменить регистрацию WebSocket подключения.""" self._websocket_connections.discard(websocket) - logger.debug('WebSocket connection unregistered. Total: %d', len(self._websocket_connections)) + logger.debug( + 'WebSocket connection unregistered. Total', websocket_connections_count=len(self._websocket_connections) + ) async def emit( self, @@ -68,7 +72,7 @@ class EventEmitter: else: callback(event_data) except Exception as error: - logger.exception('Error in event listener for %s: %s', event_type, error) + logger.exception('Error in event listener for', event_type=event_type, error=error) # Отправляем через WebSocket await self._broadcast_to_websockets(event_data) @@ -89,7 +93,7 @@ class EventEmitter: try: await ws.send_text(message) except Exception as error: - logger.warning('Failed to send WebSocket message: %s', error) + logger.warning('Failed to send WebSocket message', error=error) disconnected.add(ws) # Удаляем отключенные соединения diff --git a/app/services/external_admin_service.py b/app/services/external_admin_service.py index 73cc950b..12a7b797 100644 --- a/app/services/external_admin_service.py +++ b/app/services/external_admin_service.py @@ -2,8 +2,7 @@ from __future__ import annotations -import logging - +import structlog from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError @@ -16,7 +15,7 @@ from app.services.system_settings_service import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def ensure_external_admin_token( @@ -42,7 +41,7 @@ async def ensure_external_admin_token( try: token = settings.build_external_admin_token(normalized_username) except Exception as error: # pragma: no cover - защитный блок - logger.error('❌ Ошибка генерации токена внешней админки: %s', error) + logger.error('❌ Ошибка генерации токена внешней админки', error=error) return None try: @@ -62,8 +61,8 @@ async def ensure_external_admin_token( existing_bot_id = int(existing_bot_id_raw) except (TypeError, ValueError): # pragma: no cover - защита от мусорных значений logger.warning( - '⚠️ Не удалось разобрать сохраненный идентификатор бота внешней админки: %s', - existing_bot_id_raw, + '⚠️ Не удалось разобрать сохраненный идентификатор бота внешней админки', + existing_bot_id_raw=existing_bot_id_raw, ) if existing_token == token and existing_bot_id == bot_id: @@ -75,9 +74,9 @@ async def ensure_external_admin_token( if existing_bot_id is not None and bot_id is not None and existing_bot_id != bot_id: logger.error( - '❌ Обнаружено несовпадение ID бота для токена внешней админки: сохранен %s, текущий %s', - existing_bot_id, - bot_id, + '❌ Обнаружено несовпадение ID бота для токена внешней админки: сохранен , текущий', + existing_bot_id=existing_bot_id, + bot_id=bot_id, ) try: @@ -98,8 +97,8 @@ async def ensure_external_admin_token( except Exception as cleanup_error: # pragma: no cover - защитный блок await session.rollback() logger.error( - '❌ Не удалось очистить токен внешней админки после обнаружения подмены: %s', - cleanup_error, + '❌ Не удалось очистить токен внешней админки после обнаружения подмены', + cleanup_error=cleanup_error, ) finally: settings.EXTERNAL_ADMIN_TOKEN = None @@ -133,10 +132,7 @@ async def ensure_external_admin_token( force=True, ) await session.commit() - logger.info( - '✅ Токен внешней админки синхронизирован для @%s', - normalized_username, - ) + logger.info('✅ Токен внешней админки синхронизирован для @', normalized_username=normalized_username) except ReadOnlySettingError: # pragma: no cover - force=True предотвращает исключение await session.rollback() logger.warning( @@ -146,5 +142,5 @@ async def ensure_external_admin_token( return token except SQLAlchemyError as error: - logger.error('❌ Ошибка сохранения токена внешней админки: %s', error) + logger.error('❌ Ошибка сохранения токена внешней админки', error=error) return None diff --git a/app/services/faq_service.py b/app/services/faq_service.py index 4e8b712f..8abea999 100644 --- a/app/services/faq_service.py +++ b/app/services/faq_service.py @@ -1,5 +1,4 @@ -import logging - +import structlog from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -16,7 +15,7 @@ from app.database.crud.faq import ( from app.database.models import FaqPage, FaqSetting -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class FaqService: diff --git a/app/services/freekassa_service.py b/app/services/freekassa_service.py index 1b90fe1a..e091c1da 100644 --- a/app/services/freekassa_service.py +++ b/app/services/freekassa_service.py @@ -4,17 +4,17 @@ import asyncio import hashlib import hmac import json -import logging import time import urllib.request from typing import Any import aiohttp +import structlog from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # Кэш для публичного IP _cached_public_ip: str | None = None @@ -71,15 +71,15 @@ async def get_public_ip() -> str: # Простая валидация IPv4 if ip and len(ip.split('.')) == 4: _cached_public_ip = ip - logger.info(f'Определён публичный IP сервера: {ip}') + logger.info('Определён публичный IP сервера', ip=ip) return ip except Exception as e: - logger.debug(f'Не удалось получить IP от {service_url}: {e}') + logger.debug('Не удалось получить IP от', service_url=service_url, error=e) continue # Fallback на известный рабочий IP если ничего не получилось fallback_ip = '185.92.183.173' - logger.warning(f'Не удалось определить публичный IP, используем fallback: {fallback_ip}') + logger.warning('Не удалось определить публичный IP, используем fallback', fallback_ip=fallback_ip) _cached_public_ip = fallback_ip return fallback_ip @@ -206,7 +206,7 @@ class FreekassaService: # Генерация подписи params['signature'] = self._generate_api_signature(params) - logger.info(f'Freekassa synchronous build_payment_url for 44: {params}') + logger.info('Freekassa synchronous build_payment_url for 44', params=params) data_json = json.dumps(params).encode('utf-8') req = urllib.request.Request( @@ -218,7 +218,7 @@ class FreekassaService: data = json.loads(resp_body) if data.get('type') == 'error': - logger.error(f'Freekassa build_payment_url error: {data}') + logger.error('Freekassa build_payment_url error', data=data) # Fallback to standard flow if error? Or raise? # User wants it to work. Raise to see error is safer. # raise Exception(f"Freekassa API Error: {data.get('message')}") @@ -228,7 +228,7 @@ class FreekassaService: if data.get('location'): return data.get('location') except Exception as e: - logger.error(f'Failed to create order 44 via sync API: {e}') + logger.error('Failed to create order 44 via sync API', error=e) # Если не получилось, попробуем сгенерировать обычную ссылку как fallback signature = self.generate_form_signature(final_amount, currency, order_id) @@ -297,7 +297,7 @@ class FreekassaService: # Генерируем подпись HMAC-SHA256 params['signature'] = self._generate_api_signature(params) - logger.info(f'Freekassa API create_order params: {params}') + logger.info('Freekassa API create_order params', params=params) try: async with ( @@ -310,19 +310,19 @@ class FreekassaService: ) as response, ): text = await response.text() - logger.info(f'Freekassa API response: {text}') + logger.info('Freekassa API response', text=text) data = await response.json() # Проверяем на ошибку - API может вернуть error или type=error error_msg = data.get('error') or data.get('message') if response.status != 200 or data.get('type') == 'error' or error_msg: - logger.error(f'Freekassa create_order error: {data}') + logger.error('Freekassa create_order error', data=data) raise Exception(f'Freekassa API error: {error_msg or "Unknown error"}') return data except aiohttp.ClientError as e: - logger.exception(f'Freekassa API connection error: {e}') + logger.exception('Freekassa API connection error', error=e) raise async def create_order_and_get_url( @@ -363,7 +363,7 @@ class FreekassaService: } params['signature'] = self._generate_api_signature(params) - logger.debug(f'Freekassa get_order_status params: {params}') + logger.debug('Freekassa get_order_status params', params=params) try: async with ( @@ -376,10 +376,10 @@ class FreekassaService: ) as response, ): text = await response.text() - logger.debug(f'Freekassa get_order_status response: {text}') + logger.debug('Freekassa get_order_status response', text=text) return await response.json() except aiohttp.ClientError as e: - logger.exception(f'Freekassa API connection error: {e}') + logger.exception('Freekassa API connection error', error=e) raise async def get_balance(self) -> dict[str, Any]: @@ -402,7 +402,7 @@ class FreekassaService: ): return await response.json() except aiohttp.ClientError as e: - logger.exception(f'Freekassa API connection error: {e}') + logger.exception('Freekassa API connection error', error=e) raise async def get_payment_systems(self) -> dict[str, Any]: @@ -425,7 +425,7 @@ class FreekassaService: ): return await response.json() except aiohttp.ClientError as e: - logger.exception(f'Freekassa API connection error: {e}') + logger.exception('Freekassa API connection error', error=e) raise diff --git a/app/services/kassa_ai_service.py b/app/services/kassa_ai_service.py index b219791f..3b37f838 100644 --- a/app/services/kassa_ai_service.py +++ b/app/services/kassa_ai_service.py @@ -3,16 +3,16 @@ import asyncio import hashlib import hmac -import logging import time from typing import Any import aiohttp +import structlog from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # Кэш для публичного IP _cached_public_ip: str | None = None @@ -56,14 +56,14 @@ async def get_public_ip() -> str: ip = (await response.text()).strip() if ip and len(ip.split('.')) == 4: _cached_public_ip = ip - logger.info(f'KassaAI: определён публичный IP сервера: {ip}') + logger.info('KassaAI: определён публичный IP сервера', ip=ip) return ip except Exception as e: - logger.debug(f'KassaAI: не удалось получить IP от {service_url}: {e}') + logger.debug('KassaAI: не удалось получить IP от', service_url=service_url, error=e) continue fallback_ip = '127.0.0.1' - logger.warning(f'KassaAI: не удалось определить публичный IP, используем fallback: {fallback_ip}') + logger.warning('KassaAI: не удалось определить публичный IP, используем fallback', fallback_ip=fallback_ip) _cached_public_ip = fallback_ip return fallback_ip @@ -122,7 +122,7 @@ class KassaAiService: return expected_sign.lower() == sign.lower() except Exception as e: - logger.error(f'KassaAI webhook verify error: {e}') + logger.error('KassaAI webhook verify error', error=e) return False async def create_order( @@ -170,7 +170,11 @@ class KassaAiService: params['signature'] = self._generate_hmac_signature(params) logger.info( - f'KassaAI API create_order: shop_id={self.shop_id}, order_id={order_id}, amount={final_amount}, ps_id={ps_id}' + 'KassaAI API create_order: shop_id=, order_id=, amount=, ps_id', + shop_id=self.shop_id, + order_id=order_id, + final_amount=final_amount, + ps_id=ps_id, ) try: @@ -184,14 +188,14 @@ class KassaAiService: ) as response, ): text = await response.text() - logger.info(f'KassaAI API response: {text}') + logger.info('KassaAI API response', text=text) data = await response.json() # Проверяем на ошибку if data.get('type') == 'error': error_msg = data.get('error') or data.get('message') or 'Unknown error' - logger.error(f'KassaAI create_order error: {error_msg}') + logger.error('KassaAI create_order error', error_msg=error_msg) raise Exception(f'KassaAI API error: {error_msg}') if data.get('type') == 'success': @@ -202,11 +206,11 @@ class KassaAiService: } # Неизвестный формат ответа - logger.error(f'KassaAI unexpected response: {data}') + logger.error('KassaAI unexpected response', data=data) raise Exception('KassaAI unexpected response format') except aiohttp.ClientError as e: - logger.exception(f'KassaAI API connection error: {e}') + logger.exception('KassaAI API connection error', error=e) raise async def create_order_and_get_url( @@ -246,7 +250,7 @@ class KassaAiService: } params['signature'] = self._generate_hmac_signature(params) - logger.info(f'KassaAI get_order_status: order_id={order_id}') + logger.info('KassaAI get_order_status: order_id', order_id=order_id) try: async with ( @@ -259,10 +263,10 @@ class KassaAiService: ) as response, ): text = await response.text() - logger.info(f'KassaAI get_order_status response: {text}') + logger.info('KassaAI get_order_status response', text=text) return await response.json() except aiohttp.ClientError as e: - logger.exception(f'KassaAI API connection error: {e}') + logger.exception('KassaAI API connection error', error=e) raise async def get_balance(self) -> dict[str, Any]: @@ -285,7 +289,7 @@ class KassaAiService: ): return await response.json() except aiohttp.ClientError as e: - logger.exception(f'KassaAI API connection error: {e}') + logger.exception('KassaAI API connection error', error=e) raise diff --git a/app/services/log_rotation_service.py b/app/services/log_rotation_service.py index 5331f9d9..c956be42 100644 --- a/app/services/log_rotation_service.py +++ b/app/services/log_rotation_service.py @@ -18,6 +18,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from pathlib import Path +import structlog from aiogram import Bot from aiogram.types import FSInputFile @@ -25,7 +26,7 @@ from app.config import settings from app.utils.timezone import get_local_timezone -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @dataclass @@ -113,9 +114,9 @@ class LogRotationService: if wait_seconds > 0: logger.info( - 'Следующая ротация логов: %s (через %.1f часов)', - next_rotation.strftime('%Y-%m-%d %H:%M'), - wait_seconds / 3600, + 'Следующая ротация логов: (через часов)', + next_rotation=next_rotation.strftime('%Y-%m-%d %H:%M'), + wait_seconds=wait_seconds / 3600, ) try: await asyncio.sleep(wait_seconds) @@ -135,7 +136,7 @@ class LogRotationService: hours, minutes = map(int, time_str.split(':')) except ValueError: hours, minutes = 0, 0 - logger.warning("Некорректное LOG_ROTATION_TIME='%s', используем 00:00", time_str) + logger.warning("Некорректное LOG_ROTATION_TIME='', используем 00:00", time_str=time_str) next_rotation = now.replace(hour=hours, minute=minutes, second=0, microsecond=0) @@ -234,11 +235,11 @@ class LogRotationService: tar.add(file_path, arcname=arcname) await asyncio.to_thread(_create_tar) - logger.debug('Создан архив: %s', archive_path) + logger.debug('Создан архив', archive_path=archive_path) return archive_path except Exception as error: - logger.error('Ошибка создания архива: %s', error) + logger.error('Ошибка создания архива', error=error) return None async def _cleanup_old_archives(self) -> None: @@ -267,7 +268,7 @@ class LogRotationService: if file_date < cutoff_date: archive_file.unlink() - logger.info('Удален старый архив логов: %s', archive_file.name) + logger.info('Удален старый архив логов', archive_file_name=archive_file.name) except ValueError: # Пропускаем файлы с некорректным форматом имени pass @@ -305,10 +306,10 @@ class LogRotationService: send_kwargs['message_thread_id'] = topic_id await self.bot.send_document(**send_kwargs) - logger.info('Архив логов отправлен: %s', archive_path.name) + logger.info('Архив логов отправлен', archive_path_name=archive_path.name) except Exception as error: - logger.error('Ошибка отправки архива %s: %s', archive_path.name, error) + logger.error('Ошибка отправки архива', archive_path_name=archive_path.name, error=error) # === Ручные операции === diff --git a/app/services/maintenance_service.py b/app/services/maintenance_service.py index 58a72e5f..987c2699 100644 --- a/app/services/maintenance_service.py +++ b/app/services/maintenance_service.py @@ -1,16 +1,17 @@ import asyncio -import logging from dataclasses import dataclass from datetime import datetime from typing import Any +import structlog + from app.config import settings from app.external.remnawave_api import RemnaWaveAPI, test_api_connection from app.utils.cache import cache from app.utils.timezone import format_local_datetime -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @dataclass @@ -81,7 +82,7 @@ class MaintenanceService: return await notification_service._send_message(formatted_message) except Exception as e: - logger.error(f'Ошибка отправки уведомления через AdminNotificationService: {e}') + logger.error('Ошибка отправки уведомления через AdminNotificationService', error=e) return False async def _notify_admins(self, message: str, alert_type: str = 'info'): @@ -119,10 +120,10 @@ class MaintenanceService: await asyncio.sleep(0.1) except Exception as e: - logger.error(f'Ошибка отправки уведомления админу {admin_id}: {e}') + logger.error('Ошибка отправки уведомления админу', admin_id=admin_id, error=e) if success_count > 0: - logger.info(f'Уведомление отправлено {success_count} администраторам') + logger.info('Уведомление отправлено администраторам', success_count=success_count) await cache.set(cache_key, True, expire=300) else: logger.error('Не удалось отправить уведомления ни одному администратору') @@ -151,11 +152,11 @@ class MaintenanceService: await self._notify_admins(notification_msg, 'warning' if auto else 'info') - logger.warning(f'🔧 Режим техработ ВКЛЮЧЕН. Причина: {self._status.reason}') + logger.warning('🔧 Режим техработ ВКЛЮЧЕН. Причина', reason=self._status.reason) return True except Exception as e: - logger.error(f'Ошибка включения режима техработ: {e}') + logger.error('Ошибка включения режима техработ', error=e) return False async def disable_maintenance(self) -> bool: @@ -201,7 +202,7 @@ class MaintenanceService: return True except Exception as e: - logger.error(f'Ошибка выключения режима техработ: {e}') + logger.error('Ошибка выключения режима техработ', error=e) return False async def start_monitoring(self) -> bool: @@ -214,9 +215,9 @@ class MaintenanceService: self._check_task = asyncio.create_task(self._monitoring_loop()) logger.info( - '🔄 Запущен мониторинг API Remnawave (интервал: %sс, попыток: %s)', - settings.get_maintenance_check_interval(), - settings.get_maintenance_retry_attempts(), + '🔄 Запущен мониторинг API Remnawave (интервал: с, попыток:)', + get_maintenance_check_interval=settings.get_maintenance_check_interval(), + get_maintenance_retry_attempts=settings.get_maintenance_retry_attempts(), ) # Сообщение о запуске мониторинга убрано - теперь используется @@ -225,7 +226,7 @@ class MaintenanceService: return True except Exception as e: - logger.error(f'Ошибка запуска мониторинга: {e}') + logger.error('Ошибка запуска мониторинга', error=e) return False async def stop_monitoring(self) -> bool: @@ -242,7 +243,7 @@ class MaintenanceService: return True except Exception as e: - logger.error(f'Ошибка остановки мониторинга: {e}') + logger.error('Ошибка остановки мониторинга', error=e) return False async def check_api_status(self) -> bool: @@ -292,7 +293,7 @@ class MaintenanceService: if is_connected: if attempt > 1: - logger.info('API Remnawave ответило с %s попытки', attempt) + logger.info('API Remnawave ответило с попытки', attempt=attempt) if not self._status.api_status: recovery_time = format_local_datetime(self._status.last_check, '%H:%M:%S %Z') @@ -317,11 +318,7 @@ API снова отвечает на запросы.""", return True if attempt < attempts: - logger.warning( - 'API Remnawave недоступно (попытка %s/%s)', - attempt, - attempts, - ) + logger.warning('API Remnawave недоступно (попытка /)', attempt=attempt, attempts=attempts) await asyncio.sleep(1) was_available = self._status.api_status @@ -356,7 +353,7 @@ API снова отвечает на запросы.""", return False except Exception as e: - logger.error(f'Ошибка проверки API: {e}') + logger.error('Ошибка проверки API', error=e) if self._status.api_status: error_time = format_local_datetime(datetime.utcnow(), '%H:%M:%S %Z') @@ -387,7 +384,7 @@ API снова отвечает на запросы.""", logger.info('Мониторинг отменен') break except Exception as e: - logger.error(f'Ошибка в цикле мониторинга: {e}') + logger.error('Ошибка в цикле мониторинга', error=e) await asyncio.sleep(30) async def _save_status_to_cache(self): @@ -404,7 +401,7 @@ API снова отвечает на запросы.""", await cache.set('maintenance_status', status_data, expire=3600) except Exception as e: - logger.error(f'Ошибка сохранения состояния в кеш: {e}') + logger.error('Ошибка сохранения состояния в кеш', error=e) async def _load_status_from_cache(self): try: @@ -423,10 +420,10 @@ API снова отвечает на запросы.""", if status_data.get('last_check'): self._status.last_check = datetime.fromisoformat(status_data['last_check']) - logger.info(f'🔥 Состояние техработ загружено из кеша: активен={self._status.is_active}') + logger.info('🔥 Состояние техработ загружено из кеша: активен', is_active=self._status.is_active) except Exception as e: - logger.error(f'Ошибка загрузки состояния из кеша: {e}') + logger.error('Ошибка загрузки состояния из кеша', error=e) def get_status_info(self) -> dict[str, Any]: return { @@ -488,11 +485,11 @@ API снова отвечает на запросы.""", alert_type = 'error' if status in ['offline', 'error'] else 'info' await self._notify_admins(message, alert_type) - logger.info(f'Отправлено уведомление о статусе Remnawave: {status}') + logger.info('Отправлено уведомление о статусе Remnawave', status=status) return True except Exception as e: - logger.error(f'Ошибка отправки уведомления о статусе Remnawave: {e}') + logger.error('Ошибка отправки уведомления о статусе Remnawave', error=e) return False diff --git a/app/services/menu_layout/service.py b/app/services/menu_layout/service.py index b85b0be8..2b184e4f 100644 --- a/app/services/menu_layout/service.py +++ b/app/services/menu_layout/service.py @@ -5,10 +5,10 @@ from __future__ import annotations import asyncio import copy import json -import logging from datetime import UTC, datetime from typing import Any +import structlog from aiogram import types from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy import select @@ -31,7 +31,7 @@ from .history_service import MenuLayoutHistoryService from .stats_service import MenuLayoutStatsService -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class MenuLayoutService: @@ -289,7 +289,9 @@ class MenuLayoutService: for key in buttons.keys(): if key == 'connect' or buttons[key].get('builtin_id') == 'connect': actual_button_id = key - logger.info(f"🔗 Найдена кнопка connect по ID '{button_id}' -> '{actual_button_id}'") + logger.info( + '🔗 Найдена кнопка connect по ID', button_id=button_id, actual_button_id=actual_button_id + ) break else: # Если не нашли, пробуем найти по builtin_id @@ -300,7 +302,9 @@ class MenuLayoutService: ): actual_button_id = key logger.info( - f"🔗 Найдена кнопка connect по builtin_id '{button_id}' -> '{actual_button_id}'" + '🔗 Найдена кнопка connect по builtin_id', + button_id=button_id, + actual_button_id=actual_button_id, ) break else: @@ -314,10 +318,11 @@ class MenuLayoutService: # Логирование для отладки if 'connect' in actual_button_id.lower() or button.get('builtin_id') == 'connect': logger.info( - f'🔗 Обновление кнопки connect (ID: {actual_button_id}): ' - f'open_mode={updates.get("open_mode")}, ' - f'action={updates.get("action")}, ' - f'webapp_url={updates.get("webapp_url")}' + '🔗 Обновление кнопки connect (ID: ): open_mode=, action=, webapp_url', + actual_button_id=actual_button_id, + get=updates.get('open_mode'), + get_2=updates.get('action'), + get_3=updates.get('webapp_url'), ) # Применяем обновления @@ -1012,10 +1017,12 @@ class MenuLayoutService: if is_connect_button: logger.info( - f'🔗 Построение кнопки connect: ' - f'button_id={effective_button_id}, type={button_type}, ' - f'open_mode={open_mode}, action={action}, ' - f'webapp_url={webapp_url}' + '🔗 Построение кнопки connect: button_id=, type=, open_mode=, action=, webapp_url', + effective_button_id=effective_button_id, + button_type=button_type, + open_mode=open_mode, + action=action, + webapp_url=webapp_url, ) # Получаем текст @@ -1054,26 +1061,27 @@ class MenuLayoutService: subscription_url = get_display_subscription_link(context.subscription) if subscription_url: url = subscription_url - logger.info(f'🔗 Кнопка connect: получен URL из подписки: {url[:50]}...') + logger.info('🔗 Кнопка connect: получен URL из подписки: ...', url=url[:50]) # Если все еще нет URL, пробуем использовать настройку MINIAPP_CUSTOM_URL if not url or not (url.startswith('http://') or url.startswith('https://')): if settings.MINIAPP_CUSTOM_URL: url = settings.MINIAPP_CUSTOM_URL - logger.info(f'🔗 Кнопка connect: использован MINIAPP_CUSTOM_URL: {url[:50]}...') + logger.info('🔗 Кнопка connect: использован MINIAPP_CUSTOM_URL: ...', url=url[:50]) # Проверяем, что это действительно URL if url and (url.startswith('http://') or url.startswith('https://')): - logger.info(f'🔗 Кнопка connect: open_mode=direct, используем URL: {url[:50]}...') + logger.info('🔗 Кнопка connect: open_mode=direct, используем URL: ...', url=url[:50]) return InlineKeyboardButton(text=text, web_app=types.WebAppInfo(url=url)) logger.warning( - f'🔗 Кнопка connect: open_mode=direct, но URL не найден. ' - f'webapp_url={webapp_url}, action={action}, ' - f'subscription_url={"есть" if context.subscription else "нет"}' + '🔗 Кнопка connect: open_mode=direct, но URL не найден. webapp_url=, action=, subscription_url', + webapp_url=webapp_url, + action=action, + value='есть' if context.subscription else 'нет', ) # Fallback на callback_data return InlineKeyboardButton(text=text, callback_data=action) # Стандартный callback_data - logger.debug(f'Кнопка connect: open_mode={open_mode}, используем callback_data: {action}') + logger.debug('Кнопка connect: open_mode=, используем callback_data', open_mode=open_mode, action=action) return InlineKeyboardButton(text=text, callback_data=action) # --- Построение клавиатуры --- diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 3286aaa3..379a7878 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -1,9 +1,9 @@ import asyncio -import logging from datetime import datetime, timedelta from pathlib import Path from typing import Any +import structlog from aiogram.enums import ChatMemberStatus from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError from sqlalchemy import and_, or_, select @@ -73,7 +73,7 @@ from app.utils.timezone import format_local_datetime AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS: int = 21600 -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) LOGO_PATH = Path(settings.LOGO_FILE) @@ -120,9 +120,9 @@ class MonitoringService: return result except TelegramBadRequest as exc: logger.warning( - 'Не удалось отправить сообщение с логотипом пользователю %s: %s. Отправляем текстовое сообщение.', - chat_id, - exc, + 'Не удалось отправить сообщение с логотипом пользователю : . Отправляем текстовое сообщение.', + chat_id=chat_id, + exc=exc, ) return await self.bot.send_message( @@ -149,19 +149,12 @@ class MonitoringService: def _handle_unreachable_user(self, user: User, error: Exception, context: str) -> bool: if isinstance(error, TelegramForbiddenError): logger.warning( - '⚠️ Пользователь %s недоступен (%s): бот заблокирован', - user.telegram_id, - context, + '⚠️ Пользователь недоступен : бот заблокирован', telegram_id=user.telegram_id, context=context ) return True if isinstance(error, TelegramBadRequest) and self._is_unreachable_error(error): - logger.warning( - '⚠️ Пользователь %s недоступен (%s): %s', - user.telegram_id, - context, - error, - ) + logger.warning('⚠️ Пользователь недоступен', telegram_id=user.telegram_id, context=context, error=error) return True return False @@ -178,7 +171,7 @@ class MonitoringService: if not self._sla_task or self._sla_task.done(): self._sla_task = asyncio.create_task(self._sla_loop()) except Exception as e: - logger.error(f'Не удалось запустить SLA-мониторинг: {e}') + logger.error('Не удалось запустить SLA-мониторинг', error=e) while self.is_running: try: @@ -186,7 +179,7 @@ class MonitoringService: await asyncio.sleep(settings.MONITORING_INTERVAL * 60) except Exception as e: - logger.error(f'Ошибка в цикле мониторинга: {e}') + logger.error('Ошибка в цикле мониторинга', error=e) await asyncio.sleep(60) def stop_monitoring(self): @@ -205,18 +198,20 @@ class MonitoringService: expired_offers = await deactivate_expired_offers(db) if expired_offers: - logger.info(f'🧹 Деактивировано {expired_offers} просроченных скидочных предложений') + logger.info('🧹 Деактивировано просроченных скидочных предложений', expired_offers=expired_offers) expired_active_discounts = await cleanup_expired_promo_offer_discounts(db) if expired_active_discounts: logger.info( - '🧹 Сброшено %s активных скидок промо-предложений с истекшим сроком', - expired_active_discounts, + '🧹 Сброшено активных скидок промо-предложений с истекшим сроком', + expired_active_discounts=expired_active_discounts, ) cleaned_test_access = await promo_offer_service.cleanup_expired_test_access(db) if cleaned_test_access: - logger.info(f'🧹 Отозвано {cleaned_test_access} истекших тестовых доступов к сквадам') + logger.info( + '🧹 Отозвано истекших тестовых доступов к сквадам', cleaned_test_access=cleaned_test_access + ) await self._check_expired_subscriptions(db) await self._check_expiring_subscriptions(db) @@ -237,7 +232,7 @@ class MonitoringService: await db.commit() except Exception as e: - logger.error(f'Ошибка в цикле мониторинга: {e}') + logger.error('Ошибка в цикле мониторинга', error=e) try: await self._log_monitoring_event( db, @@ -257,7 +252,7 @@ class MonitoringService: old_count = len(self._notified_users) self._notified_users.clear() self._last_cleanup = current_time - logger.info(f'🧹 Очищен кеш уведомлений ({old_count} записей)') + logger.info('🧹 Очищен кеш уведомлений ( записей)', old_count=old_count) async def _check_expired_subscriptions(self, db: AsyncSession): try: @@ -268,8 +263,7 @@ class MonitoringService: for subscription in expired_subscriptions: if is_recently_updated_by_webhook(subscription): logger.debug( - 'Пропуск expire подписки %s: обновлена вебхуком недавно', - subscription.id, + 'Пропуск expire подписки : обновлена вебхуком недавно', subscription_id=subscription.id ) continue @@ -281,7 +275,9 @@ class MonitoringService: if user and self.bot: await self._send_subscription_expired_notification(user) - logger.info(f"🔴 Подписка пользователя {subscription.user_id} истекла и статус изменен на 'expired'") + logger.info( + "🔴 Подписка пользователя истекла и статус изменен на 'expired'", user_id=subscription.user_id + ) if expired_subscriptions: await self._log_monitoring_event( @@ -292,7 +288,7 @@ class MonitoringService: ) except Exception as e: - logger.error(f'Ошибка проверки истёкших подписок: {e}') + logger.error('Ошибка проверки истёкших подписок', error=e) async def update_remnawave_user(self, db: AsyncSession, subscription: Subscription) -> RemnaWaveUser | None: try: @@ -300,14 +296,14 @@ class MonitoringService: if is_recently_updated_by_webhook(subscription): logger.debug( - 'Пропуск RemnaWave обновления подписки %s: обновлена вебхуком недавно', - subscription.id, + 'Пропуск RemnaWave обновления подписки : обновлена вебхуком недавно', + subscription_id=subscription.id, ) return None user = await get_user_by_id(db, subscription.user_id) if not user or not user.remnawave_uuid: - logger.error(f'RemnaWave UUID не найден для пользователя {subscription.user_id}') + logger.error('RemnaWave UUID не найден для пользователя', user_id=subscription.user_id) return None # Обновляем subscription в сессии, чтобы избежать detached instance @@ -319,8 +315,8 @@ class MonitoringService: # Re-check guard after refresh (webhook could have committed between first check and refresh) if is_recently_updated_by_webhook(subscription): logger.debug( - 'Пропуск RemnaWave обновления подписки %s: обновлена вебхуком недавно (после refresh)', - subscription.id, + 'Пропуск RemnaWave обновления подписки : обновлена вебхуком недавно (после refresh)', + subscription_id=subscription.id, ) return None @@ -331,12 +327,11 @@ class MonitoringService: subscription.status = SubscriptionStatus.EXPIRED.value await db.commit() is_active = False - logger.info(f"📝 Статус подписки {subscription.id} обновлен на 'expired'") + logger.info("📝 Статус подписки обновлен на 'expired'", subscription_id=subscription.id) if not self.subscription_service.is_configured: logger.warning( - 'RemnaWave API не настроен. Пропускаем обновление пользователя %s', - subscription.user_id, + 'RemnaWave API не настроен. Пропускаем обновление пользователя', user_id=subscription.user_id ) return None @@ -365,14 +360,18 @@ class MonitoringService: await db.commit() status_text = 'активным' if is_active else 'истёкшим' - logger.info(f'✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}') + logger.info( + '✅ Обновлен RemnaWave пользователь со статусом', + remnawave_uuid=user.remnawave_uuid, + status_text=status_text, + ) return updated_user except RemnaWaveAPIError as e: - logger.error(f'Ошибка обновления RemnaWave пользователя: {e}') + logger.error('Ошибка обновления RemnaWave пользователя', error=e) return None except Exception as e: - logger.error(f'Ошибка обновления RemnaWave пользователя: {e}') + logger.error('Ошибка обновления RemnaWave пользователя', error=e) return None async def _check_expiring_subscriptions(self, db: AsyncSession): @@ -397,7 +396,11 @@ class MonitoringService: await notification_sent(db, user.id, subscription.id, 'expiring', days) or user_key in all_processed_users ): - logger.debug(f'🔄 Пропускаем дублирование для пользователя {user_identifier} на {days} дней') + logger.debug( + '🔄 Пропускаем дублирование для пользователя на дней', + user_identifier=user_identifier, + days=days, + ) continue should_send = True @@ -407,7 +410,10 @@ class MonitoringService: if any(s.user_id == user.id for s in other_subs): should_send = False logger.debug( - f'🎯 Пропускаем уведомление на {days} дней для пользователя {user_identifier}, есть более срочное на {other_days} дней' + '🎯 Пропускаем уведомление на дней для пользователя есть более срочное на дней', + days=days, + user_identifier=user_identifier, + other_days=other_days, ) break @@ -426,7 +432,9 @@ class MonitoringService: all_processed_users.add(user_key) sent_count += 1 logger.info( - f'✅ Email-пользователю {user.id} отправлено уведомление об истечении подписки через {days} дней' + '✅ Email-пользователю отправлено уведомление об истечении подписки через дней', + user_id=user.id, + days=days, ) continue @@ -437,10 +445,14 @@ class MonitoringService: all_processed_users.add(user_key) sent_count += 1 logger.info( - f'✅ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней' + '✅ Пользователю отправлено уведомление об истечении подписки через дней', + telegram_id=user.telegram_id, + days=days, ) else: - logger.warning(f'❌ Не удалось отправить уведомление пользователю {user.telegram_id}') + logger.warning( + '❌ Не удалось отправить уведомление пользователю', telegram_id=user.telegram_id + ) if sent_count > 0: await self._log_monitoring_event( @@ -451,7 +463,7 @@ class MonitoringService: ) except Exception as e: - logger.error(f'Ошибка проверки истекающих подписок: {e}') + logger.error('Ошибка проверки истекающих подписок', error=e) async def _check_trial_expiring_soon(self, db: AsyncSession): try: @@ -489,7 +501,8 @@ class MonitoringService: if success: await record_notification(db, user.id, subscription.id, 'trial_2h') logger.info( - f'🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа' + '🎁 Пользователю отправлено уведомление об окончании тестовой подписки через 2 часа', + telegram_id=user.telegram_id, ) if trial_expiring: @@ -501,7 +514,7 @@ class MonitoringService: ) except Exception as e: - logger.error(f'Ошибка проверки истекающих тестовых подписок: {e}') + logger.error('Ошибка проверки истекающих тестовых подписок', error=e) async def _check_trial_channel_subscriptions(self, db: AsyncSession): from app.database.crud.subscription import is_recently_updated_by_webhook @@ -569,41 +582,41 @@ class MonitoringService: ) except TelegramForbiddenError as error: logger.error( - '❌ Не удалось проверить подписку пользователя %s на канал %s: бот заблокирован (%s)', - user.telegram_id, - channel_id, - error, + '❌ Не удалось проверить подписку пользователя на канал : бот заблокирован', + telegram_id=user.telegram_id, + channel_id=channel_id, + error=error, ) continue except TelegramBadRequest as error: # PARTICIPANT_ID_INVALID - пользователь никогда не был в канале, это нормально logger.warning( - '⚠️ Ошибка Telegram при проверке подписки пользователя %s: %s', - user.telegram_id, - error, + '⚠️ Ошибка Telegram при проверке подписки пользователя', + telegram_id=user.telegram_id, + error=error, ) continue except Exception as error: logger.error( - '❌ Неожиданная ошибка при проверке подписки пользователя %s: %s', - user.telegram_id, - error, + '❌ Неожиданная ошибка при проверке подписки пользователя', + telegram_id=user.telegram_id, + error=error, ) continue if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.is_trial and not is_member: if is_recently_updated_by_webhook(subscription): logger.debug( - 'Пропуск деактивации trial подписки %s: обновлена вебхуком недавно', - subscription.id, + 'Пропуск деактивации trial подписки : обновлена вебхуком недавно', + subscription_id=subscription.id, ) continue subscription = await deactivate_subscription(db, subscription) disabled_count += 1 logger.info( - '🚫 Триальная подписка пользователя %s (ID %s) отключена из-за отписки от канала', - user.telegram_id, - subscription.id, + '🚫 Триальная подписка пользователя (ID) отключена из-за отписки от канала', + telegram_id=user.telegram_id, + subscription_id=subscription.id, ) if user.remnawave_uuid: @@ -611,9 +624,9 @@ class MonitoringService: await self.subscription_service.disable_remnawave_user(user.remnawave_uuid) except Exception as api_error: logger.error( - '❌ Не удалось отключить пользователя RemnaWave %s: %s', - user.remnawave_uuid, - api_error, + '❌ Не удалось отключить пользователя RemnaWave', + remnawave_uuid=user.remnawave_uuid, + api_error=api_error, ) if notifications_allowed: @@ -634,8 +647,8 @@ class MonitoringService: elif subscription.status == SubscriptionStatus.DISABLED.value and subscription.is_trial and is_member: if is_recently_updated_by_webhook(subscription): logger.debug( - 'Пропуск реактивации trial подписки %s: обновлена вебхуком недавно', - subscription.id, + 'Пропуск реактивации trial подписки : обновлена вебхуком недавно', + subscription_id=subscription.id, ) continue subscription.status = SubscriptionStatus.ACTIVE.value @@ -645,9 +658,9 @@ class MonitoringService: restored_count += 1 logger.info( - '✅ Триальная подписка пользователя %s (ID %s) восстановлена после повторной подписки на канал', - user.telegram_id, - subscription.id, + '✅ Триальная подписка пользователя (ID) восстановлена после повторной подписки на канал', + telegram_id=user.telegram_id, + subscription_id=subscription.id, ) try: @@ -657,9 +670,9 @@ class MonitoringService: await self.subscription_service.create_remnawave_user(db, subscription) except Exception as api_error: logger.error( - '❌ Не удалось обновить RemnaWave пользователя %s: %s', - user.telegram_id, - api_error, + '❌ Не удалось обновить RemnaWave пользователя', + telegram_id=user.telegram_id, + api_error=api_error, ) await clear_notification_by_type( @@ -684,7 +697,7 @@ class MonitoringService: ) except Exception as error: - logger.error(f'Ошибка проверки подписки на канал для триальных пользователей: {error}') + logger.error('Ошибка проверки подписки на канал для триальных пользователей', error=error) async def _check_expired_subscription_followups(self, db: AsyncSession): if not NotificationSettingsService.are_notifications_globally_enabled(): @@ -812,7 +825,7 @@ class MonitoringService: ) except Exception as e: - logger.error(f'Ошибка проверки напоминаний об истекшей подписке: {e}') + logger.error('Ошибка проверки напоминаний об истекшей подписке', error=e) async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> list[Subscription]: current_time = datetime.utcnow() @@ -834,9 +847,9 @@ class MonitoringService: ) ) - logger.debug(f'🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней') - logger.debug(f'📅 Текущее время: {current_time}') - logger.debug(f'📅 Пороговая дата: {threshold_date}') + logger.debug('🔍 Поиск платных подписок, истекающих в ближайшие дней', days_before=days_before) + logger.debug('📅 Текущее время', current_time=current_time) + logger.debug('📅 Пороговая дата', threshold_date=threshold_date) all_subscriptions = result.scalars().all() @@ -847,9 +860,9 @@ class MonitoringService: excluded_count = len(all_subscriptions) - len(subscriptions) if excluded_count > 0: - logger.debug(f'🔄 Исключено {excluded_count} суточных подписок из уведомлений') + logger.debug('🔄 Исключено суточных подписок из уведомлений', excluded_count=excluded_count) - logger.info(f'📊 Найдено {len(subscriptions)} платных подписок для уведомлений') + logger.info('📊 Найдено платных подписок для уведомлений', subscriptions_count=len(subscriptions)) return subscriptions @@ -887,9 +900,7 @@ class MonitoringService: offer = await get_latest_claimed_offer_for_user(db, user.id, source) except Exception as lookup_error: # pragma: no cover - defensive logging logger.warning( - 'Failed to resolve latest claimed promo offer for user %s: %s', - user.id, - lookup_error, + 'Failed to resolve latest claimed promo offer for user', user_id=user.id, lookup_error=lookup_error ) offer = None @@ -919,17 +930,12 @@ class MonitoringService: details={'reason': 'autopay_consumed'}, ) except Exception as log_error: # pragma: no cover - defensive logging - logger.warning( - 'Failed to record promo offer autopay log for user %s: %s', - user.id, - log_error, - ) + logger.warning('Failed to record promo offer autopay log for user', user_id=user.id, log_error=log_error) try: await db.rollback() except Exception as rollback_error: # pragma: no cover - defensive logging logger.warning( - 'Failed to rollback session after promo offer autopay log failure: %s', - rollback_error, + 'Failed to rollback session after promo offer autopay log failure', rollback_error=rollback_error ) async def _process_autopayments(self, db: AsyncSession): @@ -961,9 +967,7 @@ class MonitoringService: # (DailySubscriptionService), глобальный autopay на них не распространяется if sub.tariff and getattr(sub.tariff, 'is_daily', False): logger.debug( - 'Пропускаем суточную подписку %s (тариф %s) в глобальном autopay', - sub.id, - sub.tariff.name, + 'Пропускаем суточную подписку (тариф) в глобальном autopay', sub_id=sub.id, name=sub.tariff.name ) continue @@ -979,8 +983,7 @@ class MonitoringService: if is_recently_updated_by_webhook(subscription): logger.debug( - 'Пропуск автоплатежа подписки %s: обновлена вебхуком недавно', - subscription.id, + 'Пропуск автоплатежа подписки : обновлена вебхуком недавно', subscription_id=subscription.id ) continue @@ -1035,10 +1038,10 @@ class MonitoringService: processed_count += 1 self._notified_users.add(autopay_key) logger.info( - '💳 Автопродление подписки пользователя %s успешно (списано %s, скидка %s%%)', - user_identifier, - charge_amount, - promo_discount_percent, + '💳 Автопродление подписки пользователя успешно (списано , скидка %)', + user_identifier=user_identifier, + charge_amount=charge_amount, + promo_discount_percent=promo_discount_percent, ) else: failed_count += 1 @@ -1049,7 +1052,9 @@ class MonitoringService: user=user, reason='Ошибка списания средств', ) - logger.warning(f'💳 Ошибка списания средств для автопродления пользователя {user_identifier}') + logger.warning( + '💳 Ошибка списания средств для автопродления пользователя', user_identifier=user_identifier + ) else: failed_count += 1 @@ -1062,15 +1067,15 @@ class MonitoringService: if await cache.exists(cooldown_key): should_notify = False logger.debug( - '💳 Пропуск уведомления о недостаточном балансе для пользователя %s — кулдаун активен', - user_identifier, + '💳 Пропуск уведомления о недостаточном балансе для пользователя — кулдаун активен', + user_identifier=user_identifier, ) except Exception as redis_err: # Fallback: если Redis недоступен — отправляем уведомление logger.warning( - '⚠️ Ошибка проверки кулдауна в Redis для пользователя %s: %s. Отправляем уведомление.', - user_identifier, - redis_err, + '⚠️ Ошибка проверки кулдауна в Redis для пользователя : . Отправляем уведомление.', + user_identifier=user_identifier, + redis_err=redis_err, ) if should_notify: @@ -1091,12 +1096,14 @@ class MonitoringService: ) except Exception as redis_err: logger.warning( - '⚠️ Не удалось установить кулдаун в Redis для пользователя %s: %s', - user_identifier, - redis_err, + '⚠️ Не удалось установить кулдаун в Redis для пользователя', + user_identifier=user_identifier, + redis_err=redis_err, ) - logger.warning(f'💳 Недостаточно средств для автопродления у пользователя {user_identifier}') + logger.warning( + '💳 Недостаточно средств для автопродления у пользователя', user_identifier=user_identifier + ) if processed_count > 0 or failed_count > 0: await self._log_monitoring_event( @@ -1107,7 +1114,7 @@ class MonitoringService: ) except Exception as e: - logger.error(f'Ошибка обработки автоплатежей: {e}') + logger.error('Ошибка обработки автоплатежей', error=e) async def _send_subscription_expired_notification(self, user: User) -> bool: try: @@ -1140,16 +1147,14 @@ class MonitoringService: if self._handle_unreachable_user(user, exc, 'уведомление об истечении подписки'): return True logger.error( - 'Ошибка Telegram API при отправке уведомления об истечении подписки пользователю %s: %s', - user.telegram_id, - exc, + 'Ошибка Telegram API при отправке уведомления об истечении подписки пользователю', + telegram_id=user.telegram_id, + exc=exc, ) return False except Exception as e: logger.error( - 'Ошибка отправки уведомления об истечении подписки пользователю %s: %s', - user.telegram_id, - e, + 'Ошибка отправки уведомления об истечении подписки пользователю', telegram_id=user.telegram_id, e=e ) return False @@ -1209,23 +1214,19 @@ class MonitoringService: if self._handle_unreachable_user(user, exc, 'уведомление об истекающей подписке'): return True logger.error( - 'Ошибка Telegram API при отправке уведомления об истечении подписки пользователю %s: %s', - user.telegram_id, - exc, + 'Ошибка Telegram API при отправке уведомления об истечении подписки пользователю', + telegram_id=user.telegram_id, + exc=exc, ) return False except TelegramNetworkError as e: logger.warning( - 'Таймаут отправки уведомления об истечении подписки пользователю %s: %s', - user.telegram_id, - e, + 'Таймаут отправки уведомления об истечении подписки пользователю', telegram_id=user.telegram_id, e=e ) return False except Exception as e: logger.error( - 'Ошибка отправки уведомления об истечении подписки пользователю %s: %s', - user.telegram_id, - e, + 'Ошибка отправки уведомления об истечении подписки пользователю', telegram_id=user.telegram_id, e=e ) return False @@ -1265,23 +1266,23 @@ class MonitoringService: if self._handle_unreachable_user(user, exc, 'уведомление о завершении тестовой подписки'): return True logger.error( - 'Ошибка Telegram API при отправке уведомления о завершении тестовой подписки пользователю %s: %s', - user.telegram_id, - exc, + 'Ошибка Telegram API при отправке уведомления о завершении тестовой подписки пользователю', + telegram_id=user.telegram_id, + exc=exc, ) return False except TelegramNetworkError as e: logger.warning( - 'Таймаут отправки уведомления об окончании тестовой подписки пользователю %s: %s', - user.telegram_id, - e, + 'Таймаут отправки уведомления об окончании тестовой подписки пользователю', + telegram_id=user.telegram_id, + e=e, ) return False except Exception as e: logger.error( - 'Ошибка отправки уведомления об окончании тестовой подписки пользователю %s: %s', - user.telegram_id, - e, + 'Ошибка отправки уведомления об окончании тестовой подписки пользователю', + telegram_id=user.telegram_id, + e=e, ) return False @@ -1335,23 +1336,23 @@ class MonitoringService: if self._handle_unreachable_user(user, exc, 'уведомление об отписке от канала'): return True logger.error( - 'Ошибка Telegram API при отправке уведомления об отписке от канала пользователю %s: %s', - user.telegram_id, - exc, + 'Ошибка Telegram API при отправке уведомления об отписке от канала пользователю', + telegram_id=user.telegram_id, + exc=exc, ) return False except TelegramNetworkError as error: logger.warning( - 'Таймаут отправки уведомления об отписке от канала пользователю %s: %s', - user.telegram_id, - error, + 'Таймаут отправки уведомления об отписке от канала пользователю', + telegram_id=user.telegram_id, + error=error, ) return False except Exception as error: logger.error( - 'Ошибка отправки уведомления об отписке от канала пользователю %s: %s', - user.telegram_id, - error, + 'Ошибка отправки уведомления об отписке от канала пользователю', + telegram_id=user.telegram_id, + error=error, ) return False @@ -1406,23 +1407,19 @@ class MonitoringService: if self._handle_unreachable_user(user, exc, 'напоминание об истекшей подписке'): return True logger.error( - 'Ошибка Telegram API при отправке напоминания об истекшей подписке пользователю %s: %s', - user.telegram_id, - exc, + 'Ошибка Telegram API при отправке напоминания об истекшей подписке пользователю', + telegram_id=user.telegram_id, + exc=exc, ) return False except TelegramNetworkError as e: logger.warning( - 'Таймаут отправки напоминания об истекшей подписке пользователю %s: %s', - user.telegram_id, - e, + 'Таймаут отправки напоминания об истекшей подписке пользователю', telegram_id=user.telegram_id, e=e ) return False except Exception as e: logger.error( - 'Ошибка отправки напоминания об истекшей подписке пользователю %s: %s', - user.telegram_id, - e, + 'Ошибка отправки напоминания об истекшей подписке пользователю', telegram_id=user.telegram_id, e=e ) return False @@ -1505,24 +1502,16 @@ class MonitoringService: if self._handle_unreachable_user(user, exc, 'скидочное уведомление'): return True logger.error( - 'Ошибка Telegram API при отправке скидочного уведомления пользователю %s: %s', - user.telegram_id, - exc, + 'Ошибка Telegram API при отправке скидочного уведомления пользователю', + telegram_id=user.telegram_id, + exc=exc, ) return False except TelegramNetworkError as e: - logger.warning( - 'Таймаут отправки скидочного уведомления пользователю %s: %s', - user.telegram_id, - e, - ) + logger.warning('Таймаут отправки скидочного уведомления пользователю', telegram_id=user.telegram_id, e=e) return False except Exception as e: - logger.error( - 'Ошибка отправки скидочного уведомления пользователю %s: %s', - user.telegram_id, - e, - ) + logger.error('Ошибка отправки скидочного уведомления пользователю', telegram_id=user.telegram_id, e=e) return False async def _send_autopay_success_notification(self, user: User, amount: int, days: int): @@ -1537,22 +1526,16 @@ class MonitoringService: except (TelegramForbiddenError, TelegramBadRequest) as exc: if not self._handle_unreachable_user(user, exc, 'уведомление об успешном автоплатеже'): logger.error( - 'Ошибка Telegram API при отправке уведомления об автоплатеже пользователю %s: %s', - user.telegram_id, - exc, + 'Ошибка Telegram API при отправке уведомления об автоплатеже пользователю', + telegram_id=user.telegram_id, + exc=exc, ) except TelegramNetworkError as e: logger.warning( - 'Таймаут отправки уведомления об автоплатеже пользователю %s: %s', - user.telegram_id, - e, + 'Таймаут отправки уведомления об автоплатеже пользователю', telegram_id=user.telegram_id, e=e ) except Exception as e: - logger.error( - 'Ошибка отправки уведомления об автоплатеже пользователю %s: %s', - user.telegram_id, - e, - ) + logger.error('Ошибка отправки уведомления об автоплатеже пользователю', telegram_id=user.telegram_id, e=e) async def _send_autopay_failed_notification(self, user: User, balance: int, required: int): try: @@ -1580,21 +1563,17 @@ class MonitoringService: except (TelegramForbiddenError, TelegramBadRequest) as exc: if not self._handle_unreachable_user(user, exc, 'уведомление о неудачном автоплатеже'): logger.error( - 'Ошибка Telegram API при отправке уведомления о неудачном автоплатеже пользователю %s: %s', - user.telegram_id, - exc, + 'Ошибка Telegram API при отправке уведомления о неудачном автоплатеже пользователю', + telegram_id=user.telegram_id, + exc=exc, ) except TelegramNetworkError as e: logger.warning( - 'Таймаут отправки уведомления о неудачном автоплатеже пользователю %s: %s', - user.telegram_id, - e, + 'Таймаут отправки уведомления о неудачном автоплатеже пользователю', telegram_id=user.telegram_id, e=e ) except Exception as e: logger.error( - 'Ошибка отправки уведомления о неудачном автоплатеже пользователю %s: %s', - user.telegram_id, - e, + 'Ошибка отправки уведомления о неудачном автоплатеже пользователю', telegram_id=user.telegram_id, e=e ) async def _cleanup_inactive_users(self, db: AsyncSession): @@ -1619,10 +1598,10 @@ class MonitoringService: f'Удалено {deleted_count} неактивных пользователей', {'deleted_count': deleted_count}, ) - logger.info(f'🗑️ Удалено {deleted_count} неактивных пользователей') + logger.info('🗑️ Удалено неактивных пользователей', deleted_count=deleted_count) except Exception as e: - logger.error(f'Ошибка очистки неактивных пользователей: {e}') + logger.error('Ошибка очистки неактивных пользователей', error=e) async def _sync_with_remnawave(self, db: AsyncSession): try: @@ -1642,7 +1621,7 @@ class MonitoringService: ) except Exception as e: - logger.error(f'Ошибка синхронизации с RemnaWave: {e}') + logger.error('Ошибка синхронизации с RemnaWave', error=e) await self._log_monitoring_event( db, 'remnawave_sync_error', @@ -1732,7 +1711,9 @@ class MonitoringService: # commit after each to persist timestamp and avoid duplicate reminders on crash await db.commit() except Exception as notify_error: - logger.error(f'Ошибка отправки SLA-уведомления по тикету {ticket.id}: {notify_error}') + logger.error( + 'Ошибка отправки SLA-уведомления по тикету', ticket_id=ticket.id, notify_error=notify_error + ) if reminders_sent > 0: await self._log_monitoring_event( @@ -1742,7 +1723,7 @@ class MonitoringService: {'count': reminders_sent}, ) except Exception as e: - logger.error(f'Ошибка проверки SLA тикетов: {e}') + logger.error('Ошибка проверки SLA тикетов', error=e) async def _sla_loop(self): try: @@ -1756,12 +1737,12 @@ class MonitoringService: await self._check_ticket_sla(db) await db.commit() except Exception as e: - logger.error(f'Ошибка в SLA-проверке: {e}') + logger.error('Ошибка в SLA-проверке', error=e) await db.rollback() except asyncio.CancelledError: break except Exception as e: - logger.error(f'Ошибка в SLA-цикле: {e}') + logger.error('Ошибка в SLA-цикле', error=e) await asyncio.sleep(interval_seconds) async def _log_monitoring_event( @@ -1774,7 +1755,7 @@ class MonitoringService: await db.commit() except Exception as e: - logger.error(f'Ошибка логирования события мониторинга: {e}') + logger.error('Ошибка логирования события мониторинга', error=e) async def get_monitoring_status(self, db: AsyncSession) -> dict[str, Any]: try: @@ -1814,7 +1795,7 @@ class MonitoringService: } except Exception as e: - logger.error(f'Ошибка получения статуса мониторинга: {e}') + logger.error('Ошибка получения статуса мониторинга', error=e) return { 'is_running': self.is_running, 'last_update': datetime.utcnow(), @@ -1832,8 +1813,7 @@ class MonitoringService: for subscription in expired_subscriptions: if is_recently_updated_by_webhook(subscription): logger.debug( - 'Пропуск force-check подписки %s: обновлена вебхуком недавно', - subscription.id, + 'Пропуск force-check подписки : обновлена вебхуком недавно', subscription_id=subscription.id ) continue await deactivate_subscription(db, subscription) @@ -1860,7 +1840,7 @@ class MonitoringService: return {'expired': expired_count, 'expiring': expiring_count, 'autopay_ready': autopay_processed} except Exception as e: - logger.error(f'Ошибка принудительной проверки подписок: {e}') + logger.error('Ошибка принудительной проверки подписок', error=e) return {'expired': 0, 'expiring': 0, 'autopay_ready': 0} async def get_monitoring_logs( @@ -1896,7 +1876,7 @@ class MonitoringService: ] except Exception as e: - logger.error(f'Ошибка получения логов мониторинга: {e}') + logger.error('Ошибка получения логов мониторинга', error=e) return [] async def get_monitoring_logs_count(self, db: AsyncSession, event_type: str | None = None) -> int: @@ -1914,7 +1894,7 @@ class MonitoringService: return count or 0 except Exception as e: - logger.error(f'Ошибка получения количества логов: {e}') + logger.error('Ошибка получения количества логов', error=e) return 0 async def get_monitoring_event_types(self, db: AsyncSession) -> list[str]: @@ -1931,7 +1911,7 @@ class MonitoringService: return [row[0] for row in result.fetchall() if row[0]] except Exception as e: - logger.error(f'Ошибка получения списка типов событий мониторинга: {e}') + logger.error('Ошибка получения списка типов событий мониторинга', error=e) return [] async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int: @@ -1948,14 +1928,14 @@ class MonitoringService: await db.commit() if days == 0: - logger.info(f'🗑️ Удалены все логи мониторинга ({deleted_count} записей)') + logger.info('🗑️ Удалены все логи мониторинга ( записей)', deleted_count=deleted_count) else: - logger.info(f'🗑️ Удалено {deleted_count} старых записей логов (старше {days} дней)') + logger.info('🗑️ Удалено старых записей логов (старше дней)', deleted_count=deleted_count, days=days) return deleted_count except Exception as e: - logger.error(f'Ошибка очистки логов: {e}') + logger.error('Ошибка очистки логов', error=e) await db.rollback() return 0 diff --git a/app/services/mulenpay_service.py b/app/services/mulenpay_service.py index 87a66cbe..2957789c 100644 --- a/app/services/mulenpay_service.py +++ b/app/services/mulenpay_service.py @@ -1,15 +1,15 @@ import asyncio import hashlib import json -import logging from typing import Any import aiohttp +import structlog from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class MulenPayService: @@ -65,10 +65,7 @@ class MulenPayService: if response.status >= 400: logger.error( - 'MulenPay API error %s %s: %s', - response.status, - endpoint, - raw_text, + 'MulenPay API error', response_status=response.status, endpoint=endpoint, raw_text=raw_text ) if response.status in self._retryable_statuses and attempt < self._max_retries: await self._sleep_with_backoff(attempt) @@ -78,37 +75,35 @@ class MulenPayService: if data is None: if raw_text: logger.warning( - 'MulenPay returned unexpected payload for %s: %s', - endpoint, - raw_text, + 'MulenPay returned unexpected payload for', endpoint=endpoint, raw_text=raw_text ) return None return data except asyncio.CancelledError: - logger.debug('MulenPay request cancelled: %s %s', method, endpoint) + logger.debug('MulenPay request cancelled', method=method, endpoint=endpoint) raise except TimeoutError as error: last_error = error logger.warning( - 'MulenPay request timeout (%s %s) attempt %s/%s', - method, - endpoint, - attempt, - self._max_retries, + 'MulenPay request timeout attempt /', + method=method, + endpoint=endpoint, + attempt=attempt, + max_retries=self._max_retries, ) except aiohttp.ClientError as error: last_error = error logger.warning( - 'MulenPay client error (%s %s) attempt %s/%s: %s', - method, - endpoint, - attempt, - self._max_retries, - error, + 'MulenPay client error attempt /', + method=method, + endpoint=endpoint, + attempt=attempt, + max_retries=self._max_retries, + error=error, ) except Exception as error: # pragma: no cover - safety - logger.error('Unexpected MulenPay error: %s', error, exc_info=True) + logger.error('Unexpected MulenPay error', error=error, exc_info=True) return None if attempt < self._max_retries: @@ -116,18 +111,18 @@ class MulenPayService: if isinstance(last_error, asyncio.TimeoutError): logger.error( - 'MulenPay request timed out after %s attempts: %s %s', - self._max_retries, - method, - endpoint, + 'MulenPay request timed out after attempts', + max_retries=self._max_retries, + method=method, + endpoint=endpoint, ) elif last_error is not None: logger.error( - 'MulenPay request failed after %s attempts (%s %s): %s', - self._max_retries, - method, - endpoint, - last_error, + 'MulenPay request failed after attempts', + max_retries=self._max_retries, + method=method, + endpoint=endpoint, + last_error=last_error, ) return None @@ -145,11 +140,7 @@ class MulenPayService: try: return json.loads(raw_text), raw_text except json.JSONDecodeError as error: - logger.error( - 'Failed to decode MulenPay JSON response %s: %s', - response.url, - error, - ) + logger.error('Failed to decode MulenPay JSON response', url=response.url, error=error) return None, raw_text return None, raw_text @@ -200,7 +191,7 @@ class MulenPayService: response = await self._request('POST', '/v2/payments', json_data=payload) if not response or not response.get('success'): - logger.error('Failed to create MulenPay payment: %s', response) + logger.error('Failed to create MulenPay payment', response=response) return None return response diff --git a/app/services/nalogo_queue_service.py b/app/services/nalogo_queue_service.py index 9ba07563..25d73917 100644 --- a/app/services/nalogo_queue_service.py +++ b/app/services/nalogo_queue_service.py @@ -5,9 +5,9 @@ """ import asyncio -import logging from datetime import datetime, timedelta +import structlog from aiogram import Bot from dateutil.parser import isoparse @@ -16,7 +16,7 @@ from app.services.nalogo_service import NaloGoService from app.utils.cache import cache -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class NalogoQueueService: @@ -71,8 +71,9 @@ class NalogoQueueService: self._running = True self._task = asyncio.create_task(self._process_queue_loop()) logger.info( - f'Сервис очереди чеков NaloGO запущен ' - f'(интервал: {self._check_interval}с, задержка между чеками: {self._receipt_delay}с)' + 'Сервис очереди чеков NaloGO запущен (интервал: с, задержка между чеками: с)', + _check_interval=self._check_interval, + _receipt_delay=self._receipt_delay, ) async def stop(self) -> None: @@ -116,7 +117,7 @@ class NalogoQueueService: self._last_notification_time = datetime.now() logger.info('Отправлено уведомление о чеках NaloGO') except Exception as error: - logger.error(f'Ошибка отправки уведомления о чеках: {error}') + logger.error('Ошибка отправки уведомления о чеках', error=error) async def _process_queue_loop(self) -> None: """Основной цикл обработки очереди.""" @@ -124,7 +125,7 @@ class NalogoQueueService: try: await self._process_pending_receipts() except Exception as error: - logger.error(f'Ошибка в цикле обработки очереди чеков: {error}') + logger.error('Ошибка в цикле обработки очереди чеков', error=error) await asyncio.sleep(self._check_interval) @@ -137,7 +138,7 @@ class NalogoQueueService: if queue_length == 0: return - logger.info(f'Начинаем обработку очереди чеков: {queue_length} шт.') + logger.info('Начинаем обработку очереди чеков: шт.', queue_length=queue_length) self._had_pending_receipts = True processed = 0 @@ -157,7 +158,7 @@ class NalogoQueueService: # Логируем количество попыток (чек никогда не удаляется из очереди) if attempts >= 10: - logger.warning(f'Чек {payment_id} уже {attempts} попыток, продолжаем пытаться...') + logger.warning('Чек уже попыток, продолжаем пытаться...', payment_id=payment_id, attempts=attempts) # Пытаемся отправить чек try: @@ -172,7 +173,9 @@ class NalogoQueueService: try: operation_time = isoparse(created_at_str) except (ValueError, TypeError) as parse_error: - logger.warning(f"Не удалось распарсить created_at '{created_at_str}': {parse_error}") + logger.warning( + 'Не удалось распарсить created_at', created_at_str=created_at_str, parse_error=parse_error + ) # Формируем описание заново из настроек (если есть данные) if amount_kopeks is not None: @@ -205,8 +208,10 @@ class NalogoQueueService: await cache.delete(queued_key) logger.info( - f'Чек из очереди успешно создан: {receipt_uuid} ' - f'(payment_id={payment_id}, попытка {attempts + 1})' + 'Чек из очереди успешно создан: (payment_id=, попытка )', + receipt_uuid=receipt_uuid, + payment_id=payment_id, + attempts=attempts + 1, ) else: # Вернуть в очередь с увеличенным счетчиком попыток @@ -214,8 +219,10 @@ class NalogoQueueService: failed += 1 service_unavailable = True logger.warning( - f'Не удалось создать чек из очереди (payment_id={payment_id}), ' - f'возвращен в очередь (попытка {attempts + 1}/{self._max_attempts})' + 'Не удалось создать чек из очереди (payment_id=), возвращен в очередь (попытка /)', + payment_id=payment_id, + attempts=attempts + 1, + _max_attempts=self._max_attempts, ) # Если сервис недоступен, прекращаем попытки до следующего цикла break @@ -223,7 +230,7 @@ class NalogoQueueService: except Exception as error: await self._nalogo_service.requeue_receipt(receipt_data) failed += 1 - logger.error(f'Ошибка при создании чека из очереди (payment_id={payment_id}): {error}') + logger.error('Ошибка при создании чека из очереди (payment_id=)', payment_id=payment_id, error=error) # Прекращаем попытки при ошибке break @@ -231,7 +238,12 @@ class NalogoQueueService: await asyncio.sleep(self._receipt_delay) if processed > 0 or failed > 0 or skipped > 0: - logger.info(f'Обработка очереди завершена: успешно={processed}, неудачно={failed}, пропущено={skipped}') + logger.info( + 'Обработка очереди завершена: успешно=, неудачно=, пропущено', + processed=processed, + failed=failed, + skipped=skipped, + ) # Проверяем остаток в очереди remaining = await self._nalogo_service.get_queue_length() diff --git a/app/services/nalogo_service.py b/app/services/nalogo_service.py index 443e0348..0cb8be31 100644 --- a/app/services/nalogo_service.py +++ b/app/services/nalogo_service.py @@ -1,8 +1,9 @@ -import logging from datetime import date, datetime, timedelta from decimal import Decimal from typing import Any +import structlog + from app.config import settings # Используем локальную исправленную версию библиотеки @@ -11,7 +12,7 @@ from app.lib.nalogo.dto.income import IncomeClient, IncomeType from app.utils.cache import cache -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) NALOGO_QUEUE_KEY = 'nalogo:receipt_queue' NALOGO_PENDING_VERIFICATION_KEY = 'nalogo:pending_verification' @@ -49,13 +50,9 @@ class NaloGoService: self.inn = inn self.password = password self.configured = True - logger.info(f'NaloGO клиент инициализирован для ИНН: {inn[:5]}...') + logger.info('NaloGO клиент инициализирован для ИНН: ...', inn=inn[:5]) except Exception as error: - logger.error( - 'Ошибка инициализации NaloGO клиента: %s', - error, - exc_info=True, - ) + logger.error('Ошибка инициализации NaloGO клиента', error=error, exc_info=True) self.configured = False @staticmethod @@ -97,7 +94,11 @@ class NaloGoService: created_key = f'nalogo:created:{payment_id}' already_created = await cache.get(created_key) if already_created: - logger.info(f'Чек для payment_id={payment_id} уже создан ({already_created}), не добавляем в очередь') + logger.info( + 'Чек для payment_id= уже создан не добавляем в очередь', + payment_id=payment_id, + already_created=already_created, + ) return False # Атомарная проверка и установка флага "в очереди" (защита от race condition) @@ -105,7 +106,7 @@ class NaloGoService: lock_acquired = await cache.setnx(queued_key, 'queued', expire=7 * 24 * 3600) if not lock_acquired: # Ключ уже существует — чек уже в очереди - logger.info(f'Чек для payment_id={payment_id} уже в очереди, пропускаем дубликат') + logger.info('Чек для payment_id= уже в очереди, пропускаем дубликат', payment_id=payment_id) return False receipt_data = { @@ -122,7 +123,12 @@ class NaloGoService: success = await cache.lpush(NALOGO_QUEUE_KEY, receipt_data) if success: queue_len = await cache.llen(NALOGO_QUEUE_KEY) - logger.info(f'Чек добавлен в очередь (payment_id={payment_id}, сумма={amount}₽, в очереди: {queue_len})') + logger.info( + 'Чек добавлен в очередь (payment_id=, сумма=₽, в очереди: )', + payment_id=payment_id, + amount=amount, + queue_len=queue_len, + ) # Если не удалось добавить в очередь — удаляем флаг elif payment_id: queued_key = f'nalogo:queued:{payment_id}' @@ -161,8 +167,10 @@ class NaloGoService: if success: count = await cache.llen(NALOGO_PENDING_VERIFICATION_KEY) logger.warning( - f'Чек сохранён для ручной проверки (payment_id={payment_id}, ' - f'сумма={amount}₽, всего ожидают проверки: {count})' + 'Чек сохранён для ручной проверки (payment_id=, сумма=₽, всего ожидают проверки: )', + payment_id=payment_id, + amount=amount, + count=count, ) return success @@ -201,7 +209,7 @@ class NaloGoService: # Сохраняем что чек создан created_key = f'nalogo:created:{payment_id}' await cache.set(created_key, receipt_uuid, expire=30 * 24 * 3600) - logger.info(f'Чек {payment_id} помечен как созданный: {receipt_uuid}') + logger.info('Чек помечен как созданный', payment_id=payment_id, receipt_uuid=receipt_uuid) else: updated_receipts.append(receipt) @@ -210,7 +218,7 @@ class NaloGoService: await cache.delete(NALOGO_PENDING_VERIFICATION_KEY) for r in reversed(updated_receipts): # reversed чтобы сохранить порядок await cache.lpush(NALOGO_PENDING_VERIFICATION_KEY, r) - logger.info(f'Чек {payment_id} удалён из очереди проверки') + logger.info('Чек удалён из очереди проверки', payment_id=payment_id) return removed_receipt @@ -231,7 +239,7 @@ class NaloGoService: break if not target_receipt: - logger.warning(f'Чек {payment_id} не найден в очереди проверки') + logger.warning('Чек не найден в очереди проверки', payment_id=payment_id) return None # Пытаемся создать чек @@ -249,7 +257,7 @@ class NaloGoService: if receipt_uuid: # Удаляем из очереди проверки await self.mark_pending_as_verified(payment_id, receipt_uuid, was_created=True) - logger.info(f'Чек {payment_id} успешно создан после ручной проверки: {receipt_uuid}') + logger.info('Чек успешно создан после ручной проверки', payment_id=payment_id, receipt_uuid=receipt_uuid) return receipt_uuid @@ -258,7 +266,7 @@ class NaloGoService: count = await self.get_pending_verification_count() if count > 0: await cache.delete(NALOGO_PENDING_VERIFICATION_KEY) - logger.info(f'Очередь проверки очищена: удалено {count} чеков') + logger.info('Очередь проверки очищена: удалено чеков', count=count) return count async def authenticate(self) -> bool: @@ -273,9 +281,9 @@ class NaloGoService: return True except Exception as error: if self._is_service_unavailable(error): - logger.warning('NaloGO временно недоступен (техработы): %s', str(error)[:200]) + logger.warning('NaloGO временно недоступен (техработы)', error=str(error)[:200]) else: - logger.error('Ошибка аутентификации в NaloGO: %s', error, exc_info=True) + logger.error('Ошибка аутентификации в NaloGO', error=error, exc_info=True) return False async def create_receipt( @@ -316,7 +324,9 @@ class NaloGoService: already_created = await cache.get(created_key) if already_created: logger.info( - f'Чек для payment_id={payment_id} уже был создан ({already_created}), пропускаем повторное создание' + 'Чек для payment_id= уже был создан пропускаем повторное создание', + payment_id=payment_id, + already_created=already_created, ) return already_created # Возвращаем ранее созданный uuid @@ -336,14 +346,16 @@ class NaloGoService: # Ошибка аутентификации — чек не создавался, безопасно в очередь if self._is_service_unavailable(auth_error): logger.warning( - f'NaloGO недоступен при аутентификации, чек в очередь (payment_id={payment_id}, сумма={amount}₽)' + 'NaloGO недоступен при аутентификации, чек в очередь (payment_id=, сумма=₽)', + payment_id=payment_id, + amount=amount, ) if queue_on_failure: await self._queue_receipt( name, amount, quantity, client_info, payment_id, telegram_user_id, amount_kopeks ) else: - logger.error('Ошибка аутентификации NaloGO: %s', auth_error, exc_info=True) + logger.error('Ошибка аутентификации NaloGO', auth_error=auth_error, exc_info=True) return None # ЭТАП 2: Создание чека @@ -373,7 +385,7 @@ class NaloGoService: receipt_uuid = result.get('approvedReceiptUuid') if receipt_uuid: - logger.info(f'Чек создан успешно: {receipt_uuid} на сумму {amount}₽') + logger.info('Чек создан успешно: на сумму ₽', receipt_uuid=receipt_uuid, amount=amount) # Сохраняем в Redis чтобы предотвратить дубли (TTL 30 дней) if payment_id: @@ -381,7 +393,7 @@ class NaloGoService: await cache.set(created_key, receipt_uuid, expire=30 * 24 * 3600) return receipt_uuid - logger.error(f'Ошибка создания чека: {result}') + logger.error('Ошибка создания чека', result=result) return None except Exception as error: @@ -390,9 +402,9 @@ class NaloGoService: if self._is_service_unavailable(error): error_msg = str(error)[:200] logger.error( - f'⚠️ ТАЙМАУТ после успешной аутентификации! Чек МОГ быть создан! ' - f'(payment_id={payment_id}, сумма={amount}₽). ' - f'Сохраняем в очередь проверки. Проверьте lknpd.nalog.ru' + '⚠️ ТАЙМАУТ после успешной аутентификации! Чек МОГ быть создан! (payment_id=, сумма=₽). Сохраняем в очередь проверки. Проверьте lknpd.nalog.ru', + payment_id=payment_id, + amount=amount, ) # Сохраняем в очередь для ручной проверки await self._save_pending_verification( @@ -406,7 +418,7 @@ class NaloGoService: error_message=error_msg, ) else: - logger.error('Ошибка создания чека в NaloGO: %s', error, exc_info=True) + logger.error('Ошибка создания чека в NaloGO', error=error, exc_info=True) return None async def get_queue_length(self) -> int: @@ -488,19 +500,21 @@ class NaloGoService: receipt_uuid = income.get('approvedReceiptUuid', income.get('receiptUuid')) if receipt_uuid: logger.info( - f'Найден дубликат чека: {receipt_uuid} ' - f'(сумма={income_amount}₽, время={operation_time}, ' - f'разница={time_diff:.0f}с)' + 'Найден дубликат чека: (сумма=₽, время=, разница=с)', + receipt_uuid=receipt_uuid, + income_amount=income_amount, + operation_time=operation_time, + time_diff=round(time_diff, 0), ) return receipt_uuid except Exception as parse_error: - logger.debug(f'Ошибка парсинга времени чека: {parse_error}') + logger.debug('Ошибка парсинга времени чека', parse_error=parse_error) continue return None except Exception as error: - logger.warning(f'Ошибка проверки дубликата чека: {error}') + logger.warning('Ошибка проверки дубликата чека', error=error) return None async def get_incomes( @@ -539,12 +553,12 @@ class NaloGoService: # API возвращает структуру с полем content или items incomes = result.get('content', result.get('items', [])) - logger.info(f'Получено {len(incomes)} доходов из NaloGO') + logger.info('Получено доходов из NaloGO', incomes_count=len(incomes)) return incomes except Exception as error: if self._is_service_unavailable(error): - logger.warning(f'NaloGO временно недоступен: {error}') + logger.warning('NaloGO временно недоступен', error=error) else: - logger.error(f'Ошибка получения списка доходов: {error}', exc_info=True) + logger.error('Ошибка получения списка доходов', error=error, exc_info=True) return None # None = ошибка, [] = нет чеков diff --git a/app/services/notification_delivery_service.py b/app/services/notification_delivery_service.py index d8ac6895..56f5d13f 100644 --- a/app/services/notification_delivery_service.py +++ b/app/services/notification_delivery_service.py @@ -7,17 +7,17 @@ This service handles notification delivery through appropriate channels: """ import asyncio -import logging from enum import Enum from typing import Any +import structlog from aiogram import Bot from app.config import settings from app.database.models import User -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class NotificationType(Enum): @@ -163,23 +163,20 @@ class NotificationDeliveryService: if email_sent or ws_sent: logger.info( - 'Уведомление %s отправлено email-пользователю %s (email=%s, ws=%s)', - notification_type.value, - user.id, - email_sent, - ws_sent, + 'Уведомление отправлено email-пользователю (email ws=)', + notification_type_value=notification_type.value, + user_id=user.id, + email_sent=email_sent, + ws_sent=ws_sent, ) return True logger.warning( - 'Не удалось отправить уведомление %s email-пользователю %s', - notification_type.value, - user.id, + 'Не удалось отправить уведомление email-пользователю', + notification_type_value=notification_type.value, + user_id=user.id, ) return False - logger.debug( - 'Пользователь %s не имеет telegram_id или verified email, пропускаем уведомление', - user.id, - ) + logger.debug('Пользователь не имеет telegram_id или verified email, пропускаем уведомление', user_id=user.id) return False async def _send_telegram_notification( @@ -193,17 +190,14 @@ class NotificationDeliveryService: ) -> bool: """Send notification via Telegram bot.""" if not bot: - logger.warning( - 'Bot instance not provided for Telegram notification to user %s', - user.telegram_id, - ) + logger.warning('Bot instance not provided for Telegram notification to user', telegram_id=user.telegram_id) return False if not message: logger.warning( - 'No Telegram message provided for notification %s to user %s', - notification_type.value, - user.telegram_id, + 'No Telegram message provided for notification to user', + notification_type_value=notification_type.value, + telegram_id=user.telegram_id, ) return False @@ -222,32 +216,19 @@ class NotificationDeliveryService: return True except TimeoutError: - logger.warning( - 'Timeout при отправке Telegram уведомления пользователю %s', - user.telegram_id, - ) + logger.warning('Timeout при отправке Telegram уведомления пользователю', telegram_id=user.telegram_id) return False except TelegramForbiddenError: - logger.warning( - 'Telegram user %s заблокировал бота', - user.telegram_id, - ) + logger.warning('Telegram user заблокировал бота', telegram_id=user.telegram_id) return False except TelegramBadRequest as e: - logger.warning( - 'Ошибка отправки Telegram уведомления пользователю %s: %s', - user.telegram_id, - e, - ) + logger.warning('Ошибка отправки Telegram уведомления пользователю', telegram_id=user.telegram_id, e=e) return False except Exception as e: - logger.error( - 'Неожиданная ошибка при отправке Telegram уведомления: %s', - e, - ) + logger.error('Неожиданная ошибка при отправке Telegram уведомления', e=e) return False async def _send_email_notification( @@ -262,7 +243,7 @@ class NotificationDeliveryService: return False if not user.email or not user.email_verified: - logger.debug('У пользователя %s нет подтверждённого email', user.id) + logger.debug('У пользователя нет подтверждённого email', user_id=user.id) return False try: @@ -283,16 +264,13 @@ class NotificationDeliveryService: 'body_html': full_html, } except Exception as e: - logger.debug('Не удалось проверить override шаблона: %s', e) + logger.debug('Не удалось проверить override шаблона', e=e) if not template: template = self.email_templates.get_template(notification_type, language, context) if not template: - logger.warning( - 'Не найден email шаблон для %s', - notification_type.value, - ) + logger.warning('Не найден email шаблон для', notification_type_value=notification_type.value) return False # Send email @@ -305,20 +283,16 @@ class NotificationDeliveryService: if success: logger.info( - 'Email уведомление %s отправлено пользователю %s (%s)', - notification_type.value, - user.id, - user.email, + 'Email уведомление отправлено пользователю', + notification_type_value=notification_type.value, + user_id=user.id, + email=user.email, ) return success except Exception as e: - logger.error( - 'Ошибка отправки email уведомления пользователю %s: %s', - user.id, - e, - ) + logger.error('Ошибка отправки email уведомления пользователю', user_id=user.id, e=e) return False async def _send_websocket_notification( @@ -338,11 +312,7 @@ class NotificationDeliveryService: return True except Exception as e: - logger.debug( - 'WebSocket уведомление не отправлено пользователю %s: %s', - user.id, - e, - ) + logger.debug('WebSocket уведомление не отправлено пользователю', user_id=user.id, e=e) return False # ============================================================================ diff --git a/app/services/notification_settings_service.py b/app/services/notification_settings_service.py index 46082ad2..df0b4e2b 100644 --- a/app/services/notification_settings_service.py +++ b/app/services/notification_settings_service.py @@ -1,13 +1,14 @@ import json -import logging from copy import deepcopy from pathlib import Path from typing import Any +import structlog + from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class NotificationSettingsService: @@ -38,7 +39,7 @@ class NotificationSettingsService: try: cls._storage_path.parent.mkdir(parents=True, exist_ok=True) except Exception as exc: # pragma: no cover - filesystem guard - logger.error('Failed to create notification settings dir: %s', exc) + logger.error('Failed to create notification settings dir', exc=exc) @classmethod def _load(cls) -> None: @@ -53,7 +54,7 @@ class NotificationSettingsService: else: cls._data = {} except Exception as exc: - logger.error('Failed to load notification settings: %s', exc) + logger.error('Failed to load notification settings', exc=exc) cls._data = {} changed = cls._apply_defaults() @@ -87,7 +88,7 @@ class NotificationSettingsService: ) return True except Exception as exc: - logger.error('Failed to save notification settings: %s', exc) + logger.error('Failed to save notification settings', exc=exc) return False @classmethod diff --git a/app/services/pal24_service.py b/app/services/pal24_service.py index 67a63417..d14524ac 100644 --- a/app/services/pal24_service.py +++ b/app/services/pal24_service.py @@ -2,16 +2,17 @@ from __future__ import annotations -import logging from datetime import datetime, timedelta from decimal import Decimal from typing import Any +import structlog + from app.config import settings from app.external.pal24_client import Pal24APIError, Pal24Client -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class Pal24Service: @@ -57,11 +58,11 @@ class Pal24Service: filtered_payload = {k: v for k, v in extra_payload.items() if v not in (None, {})} logger.info( - 'Создаем Pal24 счет: user_id=%s, order_id=%s, amount=%s, ttl=%s', - user_id, - order_id, - amount_decimal, - ttl_seconds, + 'Создаем Pal24 счет: user_id order_id amount ttl', + user_id=user_id, + order_id=order_id, + amount_decimal=amount_decimal, + ttl_seconds=ttl_seconds, ) response = await self.client.create_bill( @@ -73,21 +74,21 @@ class Pal24Service: **filtered_payload, ) - logger.info('Pal24 счет создан: %s', response) + logger.info('Pal24 счет создан', response=response) return response async def get_bill_status(self, bill_id: str) -> dict[str, Any]: - logger.debug('Запрашиваем статус Pal24 счета %s', bill_id) + logger.debug('Запрашиваем статус Pal24 счета', bill_id=bill_id) return await self.client.get_bill_status(bill_id) async def get_payment_status(self, payment_id: str) -> dict[str, Any]: - logger.debug('Запрашиваем статус Pal24 платежа %s', payment_id) + logger.debug('Запрашиваем статус Pal24 платежа', payment_id=payment_id) return await self.client.get_payment_status(payment_id) async def get_bill_payments(self, bill_id: str) -> dict[str, Any]: """Возвращает список платежей, связанных со счетом.""" - logger.debug('Запрашиваем платежи Pal24 счёта %s', bill_id) + logger.debug('Запрашиваем платежи Pal24 счёта', bill_id=bill_id) return await self.client.get_bill_payments(bill_id) @staticmethod @@ -105,10 +106,10 @@ class Pal24Service: raise Pal24APIError('Pal24 callback signature mismatch') logger.info( - 'Получен Pal24 callback: InvId=%s, Status=%s, TrsId=%s', - inv_id, - payload.get('Status'), - payload.get('TrsId'), + 'Получен Pal24 callback: InvId Status TrsId', + inv_id=inv_id, + payload=payload.get('Status'), + payload_2=payload.get('TrsId'), ) return payload diff --git a/app/services/partner_stats_service.py b/app/services/partner_stats_service.py index 51e91001..2282645e 100644 --- a/app/services/partner_stats_service.py +++ b/app/services/partner_stats_service.py @@ -2,10 +2,10 @@ from __future__ import annotations -import logging from datetime import datetime, timedelta from typing import Any +import structlog from sqlalchemy import and_, case, desc, func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -17,7 +17,7 @@ from app.database.models import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class PartnerStatsService: diff --git a/app/services/payment/cloudpayments.py b/app/services/payment/cloudpayments.py index 9c82a471..0f75c54c 100644 --- a/app/services/payment/cloudpayments.py +++ b/app/services/payment/cloudpayments.py @@ -53,17 +53,17 @@ class CloudPaymentsPaymentMixin: if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS: logger.warning( - 'Сумма CloudPayments меньше минимальной: %s < %s', - amount_kopeks, - settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS, + 'Сумма CloudPayments меньше минимальной: <', + amount_kopeks=amount_kopeks, + CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS=settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS, ) return None if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS: logger.warning( - 'Сумма CloudPayments больше максимальной: %s > %s', - amount_kopeks, - settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS, + 'Сумма CloudPayments больше максимальной: >', + amount_kopeks=amount_kopeks, + CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS=settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS, ) return None @@ -83,10 +83,10 @@ class CloudPaymentsPaymentMixin: email=email, ) except CloudPaymentsAPIError as error: - logger.error('Ошибка создания CloudPayments платежа: %s', error) + logger.error('Ошибка создания CloudPayments платежа', error=error) return None except Exception as error: - logger.exception('Непредвиденная ошибка при создании CloudPayments платежа: %s', error) + logger.exception('Непредвиденная ошибка при создании CloudPayments платежа', error=error) return None metadata = { @@ -111,10 +111,10 @@ class CloudPaymentsPaymentMixin: return None logger.info( - 'Создан CloudPayments платёж: invoice=%s, amount=%s₽, user=%s', - invoice_id, - amount_kopeks / 100, - user_id, + 'Создан CloudPayments платёж: invoice amount=₽, user', + invoice_id=invoice_id, + amount_kopeks=amount_kopeks / 100, + user_id=user_id, ) return { @@ -156,10 +156,7 @@ class CloudPaymentsPaymentMixin: payment = await payment_module.get_cloudpayments_payment_by_invoice_id(db, invoice_id) if not payment: - logger.warning( - 'CloudPayments платёж не найден: invoice=%s, создаём новый', - invoice_id, - ) + logger.warning('CloudPayments платёж не найден: invoice создаём новый', invoice_id=invoice_id) # Try to extract user_id from account_id (we now use user_id as AccountId) try: user_id = int(account_id) if account_id else None @@ -167,7 +164,7 @@ class CloudPaymentsPaymentMixin: user_id = None if not user_id: - logger.error('Не удалось определить user_id из account_id: %s', account_id) + logger.error('Не удалось определить user_id из account_id', account_id=account_id) return False # Get user by ID @@ -175,7 +172,7 @@ class CloudPaymentsPaymentMixin: user = await get_user_by_id(db, user_id) if not user: - logger.error('Пользователь не найден: id=%s', user_id) + logger.error('Пользователь не найден: id', user_id=user_id) return False # Create payment record @@ -194,7 +191,7 @@ class CloudPaymentsPaymentMixin: # Check if already processed if payment.is_paid: - logger.info('CloudPayments платёж уже обработан: invoice=%s', invoice_id) + logger.info('CloudPayments платёж уже обработан: invoice', invoice_id=invoice_id) return True # Update payment record @@ -219,7 +216,7 @@ class CloudPaymentsPaymentMixin: user = await get_user_by_id(db, payment.user_id) if not user: - logger.error('Пользователь не найден: id=%s', payment.user_id) + logger.error('Пользователь не найден: id', user_id=payment.user_id) return False # Add balance (без автоматической транзакции - создадим ниже с external_id) @@ -245,10 +242,10 @@ class CloudPaymentsPaymentMixin: user_id_display = user.telegram_id or user.email or f'#{user.id}' logger.info( - 'CloudPayments платёж успешно обработан: invoice=%s, amount=%s₽, user=%s', - invoice_id, - amount_kopeks / 100, - user_id_display, + 'CloudPayments платёж успешно обработан: invoice amount=₽, user', + invoice_id=invoice_id, + amount_kopeks=amount_kopeks / 100, + user_id_display=user_id_display, ) # Send notification to user @@ -259,13 +256,13 @@ class CloudPaymentsPaymentMixin: transaction=transaction, ) except Exception as error: - logger.exception('Ошибка отправки уведомления CloudPayments: %s', error) + logger.exception('Ошибка отправки уведомления CloudPayments', error=error) # Auto-purchase if enabled try: await auto_purchase_saved_cart_after_topup(db, user, bot=getattr(self, 'bot', None)) except Exception as error: - logger.exception('Ошибка автопокупки после CloudPayments: %s', error) + logger.exception('Ошибка автопокупки после CloudPayments', error=error) return True @@ -305,10 +302,10 @@ class CloudPaymentsPaymentMixin: await db.commit() logger.info( - 'CloudPayments платёж неуспешен: invoice=%s, reason=%s (code=%s)', - invoice_id, - reason, - reason_code, + 'CloudPayments платёж неуспешен: invoice reason= (code=)', + invoice_id=invoice_id, + reason=reason, + reason_code=reason_code, ) # Notify user about failed payment (account_id now contains user_id, not telegram_id) @@ -328,7 +325,7 @@ class CloudPaymentsPaymentMixin: message=card_holder_message, ) except Exception as error: - logger.exception('Ошибка отправки уведомления о неуспешном платеже: %s', error) + logger.exception('Ошибка отправки уведомления о неуспешном платеже', error=error) return True @@ -353,7 +350,7 @@ class CloudPaymentsPaymentMixin: # Skip email-only users (no telegram_id) if not user.telegram_id: - logger.debug('Skipping CloudPayments notification for email-only user %s', user.id) + logger.debug('Skipping CloudPayments notification for email-only user', user_id=user.id) return texts = get_texts(user.language) @@ -387,7 +384,7 @@ class CloudPaymentsPaymentMixin: reply_markup=keyboard, ) except Exception as error: - logger.warning('Не удалось отправить уведомление пользователю %s: %s', user.telegram_id, error) + logger.warning('Не удалось отправить уведомление пользователю', telegram_id=user.telegram_id, error=error) async def _send_cloudpayments_fail_notification( self, @@ -415,7 +412,7 @@ class CloudPaymentsPaymentMixin: parse_mode='HTML', ) except Exception as error: - logger.warning('Не удалось отправить уведомление пользователю %s: %s', telegram_id, error) + logger.warning('Не удалось отправить уведомление пользователю', telegram_id=telegram_id, error=error) async def get_cloudpayments_payment_status( self, @@ -437,7 +434,7 @@ class CloudPaymentsPaymentMixin: # Get local payment record payment = await payment_module.get_cloudpayments_payment_by_id(db, local_payment_id) if not payment: - logger.warning('CloudPayments payment not found: id=%s', local_payment_id) + logger.warning('CloudPayments payment not found: id', local_payment_id=local_payment_id) return None # If already paid, return current state @@ -454,10 +451,7 @@ class CloudPaymentsPaymentMixin: api_response = await self.cloudpayments_service.find_payment(payment.invoice_id) if not api_response.get('Success'): - logger.debug( - 'CloudPayments API: payment not found or error for invoice=%s', - payment.invoice_id, - ) + logger.debug('CloudPayments API: payment not found or error for invoice', invoice_id=payment.invoice_id) return {'payment': payment, 'status': payment.status} model = api_response.get('Model', {}) @@ -493,8 +487,6 @@ class CloudPaymentsPaymentMixin: except Exception as error: logger.error( - 'Error checking CloudPayments payment status: id=%s, error=%s', - local_payment_id, - error, + 'Error checking CloudPayments payment status: id error', local_payment_id=local_payment_id, error=error ) return {'payment': payment, 'status': payment.status} diff --git a/app/services/payment/common.py b/app/services/payment/common.py index e9969c13..5e2cff06 100644 --- a/app/services/payment/common.py +++ b/app/services/payment/common.py @@ -65,15 +65,15 @@ class PaymentCommonMixin: has_active_subscription = bool(is_active and not row.is_trial) except Exception as db_error: logger.warning( - 'Не удалось загрузить подписку пользователя %s из БД: %s', - getattr(user, 'id', None), - db_error, + 'Не удалось загрузить подписку пользователя из БД', + getattr=getattr(user, 'id', None), + db_error=db_error, ) except Exception as error: # pragma: no cover - защитный код logger.error( - 'Ошибка загрузки подписки пользователя %s при построении клавиатуры после пополнения: %s', - getattr(user, 'id', None), - error, + 'Ошибка загрузки подписки пользователя при построении клавиатуры после пополнения', + getattr=getattr(user, 'id', None), + error=error, ) # Создаем основную кнопку: если есть активная подписка - продлить, иначе купить @@ -92,9 +92,9 @@ class PaymentCommonMixin: has_saved_cart = await user_cart_service.has_user_cart(user.id) except Exception as cart_error: logger.warning( - 'Не удалось проверить наличие сохраненной корзины у пользователя %s: %s', - user.id, - cart_error, + 'Не удалось проверить наличие сохраненной корзины у пользователя', + user_id=user.id, + cart_error=cart_error, ) has_saved_cart = False @@ -166,9 +166,9 @@ class PaymentCommonMixin: ) except Exception as ws_error: logger.warning( - 'Не удалось отправить WS уведомление о пополнении баланса для user_id=%s: %s', - user_id, - ws_error, + 'Не удалось отправить WS уведомление о пополнении баланса для user_id', + user_id=user_id, + ws_error=ws_error, ) if not getattr(self, 'bot', None): @@ -208,11 +208,7 @@ class PaymentCommonMixin: reply_markup=keyboard, ) except Exception as error: - logger.error( - 'Ошибка отправки уведомления пользователю %s: %s', - telegram_id, - error, - ) + logger.error('Ошибка отправки уведомления пользователю', telegram_id=telegram_id, error=error) async def _ensure_user_snapshot( self, @@ -260,9 +256,9 @@ class PaymentCommonMixin: return _build_snapshot(fetched_user) except Exception as fetch_error: logger.warning( - 'Не удалось обновить пользователя %s из переданной сессии: %s', - telegram_id, - fetch_error, + 'Не удалось обновить пользователя из переданной сессии', + telegram_id=telegram_id, + fetch_error=fetch_error, ) try: @@ -271,9 +267,7 @@ class PaymentCommonMixin: return _build_snapshot(fetched_user) except Exception as fetch_error: logger.warning( - 'Не удалось получить пользователя %s для уведомления: %s', - telegram_id, - fetch_error, + 'Не удалось получить пользователя для уведомления', telegram_id=telegram_id, fetch_error=fetch_error ) return None @@ -288,13 +282,13 @@ class PaymentCommonMixin: """Общая точка учёта успешных платежей (используется провайдерами при необходимости).""" try: logger.info( - 'Обработан успешный платеж: %s, %s₽, пользователь %s, метод %s', - payment_id, - amount_kopeks / 100, - user_id, - payment_method, + 'Обработан успешный платеж ₽, пользователь , метод', + payment_id=payment_id, + amount_kopeks=amount_kopeks / 100, + user_id=user_id, + payment_method=payment_method, ) return True except Exception as error: - logger.error('Ошибка обработки платежа %s: %s', payment_id, error) + logger.error('Ошибка обработки платежа', payment_id=payment_id, error=error) return False diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py index 01ba3eaf..2c395450 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -109,11 +109,11 @@ class CryptoBotPaymentMixin: ) logger.info( - 'Создан CryptoBot платеж %s на %s %s для пользователя %s', - invoice_data['invoice_id'], - amount_str, - asset, - user_id, + 'Создан CryptoBot платеж на для пользователя', + invoice_data=invoice_data['invoice_id'], + amount_str=amount_str, + asset=asset, + user_id=user_id, ) return { @@ -129,7 +129,7 @@ class CryptoBotPaymentMixin: } except Exception as error: - logger.error('Ошибка создания CryptoBot платежа: %s', error) + logger.error('Ошибка создания CryptoBot платежа', error=error) return None async def process_cryptobot_webhook( @@ -142,7 +142,7 @@ class CryptoBotPaymentMixin: update_type = webhook_data.get('update_type') if update_type != 'invoice_paid': - logger.info('Пропуск CryptoBot webhook с типом: %s', update_type) + logger.info('Пропуск CryptoBot webhook с типом', update_type=update_type) return True payload = webhook_data.get('payload', {}) @@ -157,12 +157,12 @@ class CryptoBotPaymentMixin: payment = await cryptobot_crud.get_cryptobot_payment_by_invoice_id(db, invoice_id) if not payment: logger.warning( - 'CryptoBot платеж не найден в БД: %s (возвращаем 200 чтобы остановить ретраи)', invoice_id + 'CryptoBot платеж не найден в БД: (возвращаем 200 чтобы остановить ретраи)', invoice_id=invoice_id ) return True if payment.status == 'paid': - logger.info('CryptoBot платеж %s уже обработан', invoice_id) + logger.info('CryptoBot платеж уже обработан', invoice_id=invoice_id) return True paid_at_str = payload.get('paid_at') @@ -215,17 +215,15 @@ class CryptoBotPaymentMixin: amount_kopeks = int(amount_rubles_rounded * 100) conversion_rate = amount_rubles / amount_usd if amount_usd > 0 else 0 logger.info( - 'Конвертация USD->RUB: $%s -> %s₽ (округлено до %s₽, курс: %.2f)', - amount_usd, - amount_rubles, - amount_rubles_rounded, - conversion_rate, + 'Конвертация USD->RUB: $ -> ₽ (округлено до ₽, курс:)', + amount_usd=amount_usd, + amount_rubles=amount_rubles, + amount_rubles_rounded=amount_rubles_rounded, + conversion_rate=conversion_rate, ) except Exception as error: logger.warning( - 'Ошибка конвертации валют для платежа %s, используем курс 1:1: %s', - invoice_id, - error, + 'Ошибка конвертации валют для платежа , используем курс 1:1', invoice_id=invoice_id, error=error ) amount_rubles = amount_usd amount_rubles_rounded = math.ceil(amount_rubles) @@ -234,9 +232,9 @@ class CryptoBotPaymentMixin: if amount_kopeks <= 0: logger.error( - 'Некорректная сумма после конвертации: %s копеек для платежа %s', - amount_kopeks, - invoice_id, + 'Некорректная сумма после конвертации: копеек для платежа', + amount_kopeks=amount_kopeks, + invoice_id=invoice_id, ) return False @@ -261,10 +259,7 @@ class CryptoBotPaymentMixin: get_user_by_id = payment_service_module.get_user_by_id user = await get_user_by_id(db, updated_payment.user_id) if not user: - logger.error( - 'Пользователь с ID %s не найден при пополнении баланса', - updated_payment.user_id, - ) + logger.error('Пользователь с ID не найден при пополнении баланса', user_id=updated_payment.user_id) return False old_balance = user.balance_kopeks @@ -288,10 +283,7 @@ class CryptoBotPaymentMixin: getattr(self, 'bot', None), ) except Exception as error: - logger.error( - 'Ошибка обработки реферального пополнения CryptoBot: %s', - error, - ) + logger.error('Ошибка обработки реферального пополнения CryptoBot', error=error) if was_first_topup and not user.has_made_first_topup: user.has_made_first_topup = True @@ -332,10 +324,7 @@ class CryptoBotPaymentMixin: asset=updated_payment.asset, ) except Exception as error: - logger.error( - 'Ошибка подготовки уведомления о пополнении CryptoBot: %s', - error, - ) + logger.error('Ошибка подготовки уведомления о пополнении CryptoBot', error=error) # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: @@ -354,9 +343,9 @@ class CryptoBotPaymentMixin: ) except Exception as auto_error: logger.error( - 'Ошибка автоматической покупки подписки для пользователя %s: %s', - user.id, - auto_error, + 'Ошибка автоматической покупки подписки для пользователя', + user_id=user.id, + auto_error=auto_error, exc_info=True, ) @@ -397,9 +386,9 @@ class CryptoBotPaymentMixin: ) except Exception as error: logger.error( - 'Ошибка при работе с сохраненной корзиной для пользователя %s: %s', - user.id, - error, + 'Ошибка при работе с сохраненной корзиной для пользователя', + user_id=user.id, + error=error, exc_info=True, ) @@ -415,7 +404,7 @@ class CryptoBotPaymentMixin: return True except Exception as error: - logger.error('Ошибка обработки CryptoBot webhook: %s', error, exc_info=True) + logger.error('Ошибка обработки CryptoBot webhook', error=error, exc_info=True) return False async def _process_subscription_renewal_payment( @@ -430,25 +419,25 @@ class CryptoBotPaymentMixin: user = await payment_service_module.get_user_by_id(db, payment.user_id) except Exception as error: logger.error( - 'Не удалось загрузить пользователя %s для продления через CryptoBot: %s', - getattr(payment, 'user_id', None), - error, + 'Не удалось загрузить пользователя для продления через CryptoBot', + getattr=getattr(payment, 'user_id', None), + error=error, ) return False if not user: logger.error( - 'Пользователь %s не найден при обработке продления через CryptoBot', - getattr(payment, 'user_id', None), + 'Пользователь не найден при обработке продления через CryptoBot', + getattr=getattr(payment, 'user_id', None), ) return False subscription = getattr(user, 'subscription', None) if not subscription or subscription.id != descriptor.subscription_id: logger.warning( - 'Продление через CryptoBot отклонено: подписка %s не совпадает с ожидаемой %s', - getattr(subscription, 'id', None), - descriptor.subscription_id, + 'Продление через CryptoBot отклонено: подписка не совпадает с ожидаемой', + getattr=getattr(subscription, 'id', None), + subscription_id=descriptor.subscription_id, ) return False @@ -458,9 +447,9 @@ class CryptoBotPaymentMixin: pricing_model = SubscriptionRenewalPricing.from_payload(descriptor.pricing_snapshot) except Exception as error: logger.warning( - 'Не удалось восстановить сохраненную стоимость продления из payload %s: %s', - payment.invoice_id, - error, + 'Не удалось восстановить сохраненную стоимость продления из payload', + invoice_id=payment.invoice_id, + error=error, ) if pricing_model is None: @@ -473,18 +462,18 @@ class CryptoBotPaymentMixin: ) except Exception as error: logger.error( - 'Не удалось пересчитать стоимость продления для CryptoBot %s: %s', - payment.invoice_id, - error, + 'Не удалось пересчитать стоимость продления для CryptoBot', + invoice_id=payment.invoice_id, + error=error, ) return False if pricing_model.final_total != descriptor.total_amount_kopeks: logger.warning( - 'Сумма продления через CryptoBot %s изменилась (ожидалось %s, получено %s)', - payment.invoice_id, - descriptor.total_amount_kopeks, - pricing_model.final_total, + 'Сумма продления через CryptoBot изменилась (ожидалось , получено)', + invoice_id=payment.invoice_id, + total_amount_kopeks=descriptor.total_amount_kopeks, + final_total=pricing_model.final_total, ) pricing_model.final_total = descriptor.total_amount_kopeks pricing_model.per_month = ( @@ -507,10 +496,10 @@ class CryptoBotPaymentMixin: current_balance = getattr(user, 'balance_kopeks', 0) if current_balance < required_balance: logger.warning( - 'Недостаточно средств на балансе пользователя %s для завершения продления: нужно %s, доступно %s', - user.id, - required_balance, - current_balance, + 'Недостаточно средств на балансе пользователя для завершения продления: нужно , доступно', + user_id=user.id, + required_balance=required_balance, + current_balance=current_balance, ) return False @@ -528,17 +517,14 @@ class CryptoBotPaymentMixin: ) except SubscriptionRenewalChargeError as error: logger.error( - 'Списание баланса не выполнено при продлении через CryptoBot %s: %s', - payment.invoice_id, - error, + 'Списание баланса не выполнено при продлении через CryptoBot', + invoice_id=payment.invoice_id, + error=error, ) return False except Exception as error: logger.error( - 'Ошибка завершения продления через CryptoBot %s: %s', - payment.invoice_id, - error, - exc_info=True, + 'Ошибка завершения продления через CryptoBot', invoice_id=payment.invoice_id, error=error, exc_info=True ) return False @@ -552,21 +538,21 @@ class CryptoBotPaymentMixin: ) except Exception as error: logger.warning( - 'Не удалось связать платеж CryptoBot %s с транзакцией %s: %s', - payment.invoice_id, - transaction.id, - error, + 'Не удалось связать платеж CryptoBot с транзакцией', + invoice_id=payment.invoice_id, + transaction_id=transaction.id, + error=error, ) external_amount_label = settings.format_price(descriptor.missing_amount_kopeks) balance_amount_label = settings.format_price(required_balance) logger.info( - 'Подписка %s продлена через CryptoBot invoice %s (внешний платеж %s, списано с баланса %s)', - subscription.id, - payment.invoice_id, - external_amount_label, - balance_amount_label, + 'Подписка продлена через CryptoBot invoice (внешний платеж , списано с баланса)', + subscription_id=subscription.id, + invoice_id=payment.invoice_id, + external_amount_label=external_amount_label, + balance_amount_label=balance_amount_label, ) return True @@ -582,9 +568,7 @@ class CryptoBotPaymentMixin: from app.services.admin_notification_service import AdminNotificationService except Exception as error: logger.error( - 'Не удалось импортировать зависимости для админ-уведомления CryptoBot: %s', - error, - exc_info=True, + 'Не удалось импортировать зависимости для админ-уведомления CryptoBot', error=error, exc_info=True ) return @@ -593,19 +577,15 @@ class CryptoBotPaymentMixin: user = await get_user_by_id(session, context.user_id) transaction = await get_transaction_by_id(session, context.transaction_id) except Exception as error: - logger.error( - 'Ошибка загрузки данных для админ-уведомления CryptoBot: %s', - error, - exc_info=True, - ) + logger.error('Ошибка загрузки данных для админ-уведомления CryptoBot', error=error, exc_info=True) await session.rollback() return if not user or not transaction: logger.warning( - 'Пропущена отправка админ-уведомления CryptoBot: user=%s transaction=%s', - bool(user), - bool(transaction), + 'Пропущена отправка админ-уведомления CryptoBot: user= transaction', + user=bool(user), + transaction=bool(transaction), ) return @@ -622,11 +602,7 @@ class CryptoBotPaymentMixin: db=session, ) except Exception as error: - logger.error( - 'Ошибка отправки админ-уведомления о пополнении CryptoBot: %s', - error, - exc_info=True, - ) + logger.error('Ошибка отправки админ-уведомления о пополнении CryptoBot', error=error, exc_info=True) async def _deliver_user_topup_notification(self, payload: _UserNotificationPayload) -> None: bot_instance = getattr(self, 'bot', None) @@ -652,10 +628,7 @@ class CryptoBotPaymentMixin: payload.asset, ) except Exception as error: - logger.error( - 'Ошибка отправки уведомления о пополнении CryptoBot: %s', - error, - ) + logger.error('Ошибка отправки уведомления о пополнении CryptoBot', error=error) async def _deliver_saved_cart_reminder(self, payload: _SavedCartNotificationPayload) -> None: bot_instance = getattr(self, 'bot', None) @@ -674,14 +647,13 @@ class CryptoBotPaymentMixin: reply_markup=payload.reply_markup, ) logger.info( - 'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s', - payload.user_id, + 'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю', user_id=payload.user_id ) except Exception as error: logger.error( - 'Ошибка отправки уведомления о сохраненной корзине для пользователя %s: %s', - payload.user_id, - error, + 'Ошибка отправки уведомления о сохраненной корзине для пользователя', + user_id=payload.user_id, + error=error, exc_info=True, ) @@ -695,7 +667,7 @@ class CryptoBotPaymentMixin: cryptobot_crud = import_module('app.database.crud.cryptobot') payment = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id) if not payment: - logger.warning('CryptoBot платеж %s не найден', local_payment_id) + logger.warning('CryptoBot платеж не найден', local_payment_id=local_payment_id) return None if not self.cryptobot_service: @@ -706,11 +678,7 @@ class CryptoBotPaymentMixin: try: invoices = await self.cryptobot_service.get_invoices(invoice_ids=[invoice_id]) except Exception as error: # pragma: no cover - network errors - logger.error( - 'Ошибка запроса статуса CryptoBot invoice %s: %s', - invoice_id, - error, - ) + logger.error('Ошибка запроса статуса CryptoBot invoice', invoice_id=invoice_id, error=error) return {'payment': payment} remote_invoice: dict[str, Any] | None = None @@ -721,10 +689,7 @@ class CryptoBotPaymentMixin: break if not remote_invoice: - logger.info( - 'CryptoBot invoice %s не найден через API при ручной проверке', - invoice_id, - ) + logger.info('CryptoBot invoice не найден через API при ручной проверке', invoice_id=invoice_id) refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id) return {'payment': refreshed or payment} diff --git a/app/services/payment/freekassa.py b/app/services/payment/freekassa.py index d8974dfe..10683c54 100644 --- a/app/services/payment/freekassa.py +++ b/app/services/payment/freekassa.py @@ -54,17 +54,17 @@ class FreekassaPaymentMixin: # Валидация лимитов if amount_kopeks < settings.FREEKASSA_MIN_AMOUNT_KOPEKS: logger.warning( - 'Freekassa: сумма %s меньше минимальной %s', - amount_kopeks, - settings.FREEKASSA_MIN_AMOUNT_KOPEKS, + 'Freekassa: сумма меньше минимальной', + amount_kopeks=amount_kopeks, + FREEKASSA_MIN_AMOUNT_KOPEKS=settings.FREEKASSA_MIN_AMOUNT_KOPEKS, ) return None if amount_kopeks > settings.FREEKASSA_MAX_AMOUNT_KOPEKS: logger.warning( - 'Freekassa: сумма %s больше максимальной %s', - amount_kopeks, - settings.FREEKASSA_MAX_AMOUNT_KOPEKS, + 'Freekassa: сумма больше максимальной', + amount_kopeks=amount_kopeks, + FREEKASSA_MAX_AMOUNT_KOPEKS=settings.FREEKASSA_MAX_AMOUNT_KOPEKS, ) return None @@ -96,11 +96,7 @@ class FreekassaPaymentMixin: email=email, payment_system_id=settings.FREEKASSA_PAYMENT_SYSTEM_ID, ) - logger.info( - 'Freekassa API: создан заказ order_id=%s, url=%s', - order_id, - payment_url, - ) + logger.info('Freekassa API: создан заказ order_id url', order_id=order_id, payment_url=payment_url) else: # Генерируем URL для формы оплаты (стандартный способ) payment_url = freekassa_service.build_payment_url( @@ -128,12 +124,12 @@ class FreekassaPaymentMixin: ) logger.info( - 'Freekassa: создан платеж order_id=%s, user_id=%s, amount=%s %s, use_api=%s', - order_id, - user_id, - amount_rubles, - currency, - settings.FREEKASSA_USE_API, + 'Freekassa: создан платеж order_id user_id amount use_api', + order_id=order_id, + user_id=user_id, + amount_rubles=amount_rubles, + currency=currency, + FREEKASSA_USE_API=settings.FREEKASSA_USE_API, ) return { @@ -147,7 +143,7 @@ class FreekassaPaymentMixin: } except Exception as e: - logger.exception('Freekassa: ошибка создания платежа: %s', e) + logger.exception('Freekassa: ошибка создания платежа', e=e) return None async def process_freekassa_webhook( @@ -181,12 +177,12 @@ class FreekassaPaymentMixin: try: # Проверка IP if not freekassa_service.verify_webhook_ip(client_ip): - logger.warning('Freekassa webhook: недоверенный IP %s', client_ip) + logger.warning('Freekassa webhook: недоверенный IP', client_ip=client_ip) return False # Проверка подписи if not freekassa_service.verify_webhook_signature(merchant_id, amount, order_id, sign): - logger.warning('Freekassa webhook: неверная подпись для order_id=%s', order_id) + logger.warning('Freekassa webhook: неверная подпись для order_id', order_id=order_id) return False # Импортируем CRUD модуль @@ -195,21 +191,21 @@ class FreekassaPaymentMixin: # Получаем платеж из БД payment = await freekassa_crud.get_freekassa_payment_by_order_id(db, order_id) if not payment: - logger.warning('Freekassa webhook: платеж не найден order_id=%s', order_id) + logger.warning('Freekassa webhook: платеж не найден order_id', order_id=order_id) return False # Проверка дублирования if payment.is_paid: - logger.info('Freekassa webhook: платеж уже обработан order_id=%s', order_id) + logger.info('Freekassa webhook: платеж уже обработан order_id', order_id=order_id) return True # Проверка суммы expected_amount = payment.amount_kopeks / 100 if abs(amount - expected_amount) > 0.01: logger.warning( - 'Freekassa webhook: несоответствие суммы ожидалось=%s, получено=%s', - expected_amount, - amount, + 'Freekassa webhook: несоответствие суммы ожидалось получено', + expected_amount=expected_amount, + amount=amount, ) return False @@ -236,7 +232,7 @@ class FreekassaPaymentMixin: return await self._finalize_freekassa_payment(db, payment, intid=intid, trigger='webhook') except Exception as e: - logger.exception('Freekassa webhook: ошибка обработки: %s', e) + logger.exception('Freekassa webhook: ошибка обработки', e=e) return False async def _finalize_freekassa_payment( @@ -252,9 +248,7 @@ class FreekassaPaymentMixin: if payment.transaction_id: logger.info( - 'Freekassa платеж %s уже привязан к транзакции (trigger=%s)', - payment.order_id, - trigger, + 'Freekassa платеж уже привязан к транзакции (trigger=)', order_id=payment.order_id, trigger=trigger ) return True @@ -262,10 +256,10 @@ class FreekassaPaymentMixin: user = await payment_module.get_user_by_id(db, payment.user_id) if not user: logger.error( - 'Пользователь %s не найден для Freekassa платежа %s (trigger=%s)', - payment.user_id, - payment.order_id, - trigger, + 'Пользователь не найден для Freekassa платежа (trigger=)', + user_id=payment.user_id, + order_id=payment.order_id, + trigger=trigger, ) return False @@ -311,7 +305,7 @@ class FreekassaPaymentMixin: await process_referral_topup(db, user.id, payment.amount_kopeks, getattr(self, 'bot', None)) except Exception as error: - logger.error('Ошибка обработки реферального пополнения Freekassa: %s', error) + logger.error('Ошибка обработки реферального пополнения Freekassa', error=error) if was_first_topup and not user.has_made_first_topup: user.has_made_first_topup = True @@ -339,7 +333,7 @@ class FreekassaPaymentMixin: db=db, ) except Exception as error: - logger.error('Ошибка отправки админ уведомления Freekassa: %s', error) + logger.error('Ошибка отправки админ уведомления Freekassa', error=error) # Отправка уведомления пользователю if getattr(self, 'bot', None) and user.telegram_id: @@ -359,7 +353,7 @@ class FreekassaPaymentMixin: reply_markup=keyboard, ) except Exception as error: - logger.error('Ошибка отправки уведомления пользователю Freekassa: %s', error) + logger.error('Ошибка отправки уведомления пользователю Freekassa', error=error) # Автопокупка подписки try: @@ -379,9 +373,9 @@ class FreekassaPaymentMixin: ) except Exception as auto_error: logger.error( - 'Ошибка автоматической покупки подписки для пользователя %s: %s', - user.id, - auto_error, + 'Ошибка автоматической покупки подписки для пользователя', + user_id=user.id, + auto_error=auto_error, exc_info=True, ) @@ -424,17 +418,14 @@ class FreekassaPaymentMixin: ) except Exception as error: logger.error( - 'Ошибка при работе с сохраненной корзиной для пользователя %s: %s', - user.id, - error, - exc_info=True, + 'Ошибка при работе с сохраненной корзиной для пользователя', user_id=user.id, error=error, exc_info=True ) logger.info( - '✅ Обработан Freekassa платеж %s для пользователя %s (trigger=%s)', - payment.order_id, - payment.user_id, - trigger, + '✅ Обработан Freekassa платеж для пользователя (trigger=)', + order_id=payment.order_id, + user_id=payment.user_id, + trigger=trigger, ) return True @@ -458,7 +449,7 @@ class FreekassaPaymentMixin: status_data = await freekassa_service.get_order_status(order_id) return status_data except Exception as e: - logger.exception('Freekassa: ошибка проверки статуса: %s', e) + logger.exception('Freekassa: ошибка проверки статуса', e=e) return None async def get_freekassa_payment_status( @@ -473,7 +464,7 @@ class FreekassaPaymentMixin: payment = await freekassa_crud.get_freekassa_payment_by_id(db, local_payment_id) if not payment: - logger.warning('Freekassa payment not found: id=%s', local_payment_id) + logger.warning('Freekassa payment not found: id', local_payment_id=local_payment_id) return None if payment.is_paid: @@ -512,7 +503,7 @@ class FreekassaPaymentMixin: fk_status = int(target_order.get('status', 0)) if fk_status == 1: - logger.info('Freekassa payment %s confirmed via API', payment.order_id) + logger.info('Freekassa payment confirmed via API', order_id=payment.order_id) callback_payload = { 'check_source': 'api', @@ -541,7 +532,7 @@ class FreekassaPaymentMixin: trigger='api_check', ) except Exception as e: - logger.error('Error checking Freekassa payment status: %s', e) + logger.error('Error checking Freekassa payment status', e=e) return { 'payment': payment, diff --git a/app/services/payment/heleket.py b/app/services/payment/heleket.py index 0d77e14a..605b1a8b 100644 --- a/app/services/payment/heleket.py +++ b/app/services/payment/heleket.py @@ -36,7 +36,7 @@ class HeleketPaymentMixin: return None if amount_kopeks <= 0: - logger.error('Сумма Heleket должна быть положительной: %s', amount_kopeks) + logger.error('Сумма Heleket должна быть положительной', amount_kopeks=amount_kopeks) return None amount_rubles = amount_kopeks / 100 @@ -52,7 +52,7 @@ class HeleketPaymentMixin: if rounded != 0: discount_percent = -rounded except (TypeError, ValueError): - logger.warning('Некорректная наценка Heleket: %s', markup_percent) + logger.warning('Некорректная наценка Heleket', markup_percent=markup_percent) payload: dict[str, Any] = { 'amount': amount_str, @@ -89,7 +89,7 @@ class HeleketPaymentMixin: try: response = await self.heleket_service.create_payment(payload) # type: ignore[union-attr] except Exception as error: # pragma: no cover - safety net - logger.exception('Ошибка создания Heleket платежа: %s', error) + logger.exception('Ошибка создания Heleket платежа', error=error) return None if not response: @@ -98,7 +98,7 @@ class HeleketPaymentMixin: payment_result = response.get('result') if isinstance(response, dict) else None if not payment_result: - logger.error('Некорректный ответ Heleket API: %s', response) + logger.error('Некорректный ответ Heleket API', response=response) return None uuid = str(payment_result.get('uuid')) @@ -150,12 +150,7 @@ class HeleketPaymentMixin: metadata={'raw_response': payment_result, **metadata}, ) - logger.info( - 'Создан Heleket платеж %s на %s₽ для пользователя %s', - uuid, - amount_str, - user_id, - ) + logger.info('Создан Heleket платеж на ₽ для пользователя', uuid=uuid, amount_str=amount_str, user_id=user_id) return { 'local_payment_id': local_payment.id, @@ -179,7 +174,7 @@ class HeleketPaymentMixin: metadata_key: str, ) -> HeleketPayment | None: if not isinstance(payload, dict): - logger.error('Heleket webhook payload не является словарём: %s', payload) + logger.error('Heleket webhook payload не является словарём', payload=payload) return None heleket_crud = import_module('app.database.crud.heleket') @@ -190,7 +185,7 @@ class HeleketPaymentMixin: status = payload.get('status') or payload.get('payment_status') if not uuid and not order_id: - logger.error('Heleket webhook без uuid/order_id: %s', payload) + logger.error('Heleket webhook без uuid/order_id', payload=payload) return None payment = None @@ -200,11 +195,7 @@ class HeleketPaymentMixin: payment = await heleket_crud.get_heleket_payment_by_order_id(db, order_id) if not payment: - logger.error( - 'Heleket платеж не найден (uuid=%s order_id=%s)', - uuid, - order_id, - ) + logger.error('Heleket платеж не найден (uuid= order_id=)', uuid=uuid, order_id=order_id) return None payer_amount = payload.get('payer_amount') or payload.get('payment_amount') @@ -270,11 +261,7 @@ class HeleketPaymentMixin: try: await self.bot.delete_message(chat_id, message_id) except Exception as delete_error: # pragma: no cover - depends on rights - logger.warning( - 'Не удалось удалить счёт Heleket %s: %s', - message_id, - delete_error, - ) + logger.warning('Не удалось удалить счёт Heleket', message_id=message_id, delete_error=delete_error) else: metadata.pop('invoice_message', None) invoice_message_removed = True @@ -290,26 +277,25 @@ class HeleketPaymentMixin: ) updated_payment.metadata_json = metadata except Exception as error: # pragma: no cover - diagnostics - logger.warning( - 'Не удалось обновить метаданные Heleket после удаления счёта: %s', - error, - ) + logger.warning('Не удалось обновить метаданные Heleket после удаления счёта', error=error) if updated_payment.transaction_id: logger.info( - 'Heleket платеж %s уже связан с транзакцией %s', - updated_payment.uuid, - updated_payment.transaction_id, + 'Heleket платеж уже связан с транзакцией', + uuid=updated_payment.uuid, + transaction_id=updated_payment.transaction_id, ) return updated_payment if status_normalized not in {'paid', 'paid_over'}: - logger.info('Heleket платеж %s в статусе %s, зачисление не требуется', updated_payment.uuid, status) + logger.info('Heleket платеж в статусе , зачисление не требуется', uuid=updated_payment.uuid, status=status) return updated_payment amount_kopeks = updated_payment.amount_kopeks if amount_kopeks <= 0: - logger.error('Heleket платеж %s имеет некорректную сумму: %s', updated_payment.uuid, updated_payment.amount) + logger.error( + 'Heleket платеж имеет некорректную сумму', uuid=updated_payment.uuid, amount=updated_payment.amount + ) return None transaction = await payment_module.create_transaction( @@ -339,7 +325,7 @@ class HeleketPaymentMixin: get_user_by_id = payment_module.get_user_by_id user = await get_user_by_id(db, updated_payment.user_id) if not user: - logger.error('Пользователь %s не найден для Heleket платежа', updated_payment.user_id) + logger.error('Пользователь не найден для Heleket платежа', user_id=updated_payment.user_id) return None old_balance = user.balance_kopeks @@ -361,7 +347,7 @@ class HeleketPaymentMixin: getattr(self, 'bot', None), ) except Exception as error: # pragma: no cover - defensive - logger.error('Ошибка реферального начисления Heleket: %s', error) + logger.error('Ошибка реферального начисления Heleket', error=error) if was_first_topup and not user.has_made_first_topup: user.has_made_first_topup = True @@ -393,7 +379,7 @@ class HeleketPaymentMixin: db=db, ) except Exception as error: # pragma: no cover - logger.error('Ошибка отправки админ-уведомления Heleket: %s', error) + logger.error('Ошибка отправки админ-уведомления Heleket', error=error) # Отправляем уведомление только Telegram-пользователям if user.telegram_id: @@ -426,9 +412,9 @@ class HeleketPaymentMixin: reply_markup=keyboard, ) except Exception as error: # pragma: no cover - logger.error('Ошибка отправки уведомления пользователю Heleket: %s', error) + logger.error('Ошибка отправки уведомления пользователю Heleket', error=error) else: - logger.info(f'Пропуск Telegram-уведомления Heleket для email-пользователя {user.id}') + logger.info('Пропуск Telegram-уведомления Heleket для email-пользователя', user_id=user.id) # Автопокупка из сохранённой корзины и умная автоактивация try: @@ -445,9 +431,9 @@ class HeleketPaymentMixin: ) except Exception as auto_error: logger.error( - 'Ошибка автоматической покупки подписки для пользователя %s: %s', - user.id, - auto_error, + 'Ошибка автоматической покупки подписки для пользователя', + user_id=user.id, + auto_error=auto_error, exc_info=True, ) @@ -456,10 +442,7 @@ class HeleketPaymentMixin: except Exception as error: logger.error( - 'Ошибка при работе с автоактивацией для пользователя %s: %s', - user.id, - error, - exc_info=True, + 'Ошибка при работе с автоактивацией для пользователя', user_id=user.id, error=error, exc_info=True ) return updated_payment @@ -491,7 +474,7 @@ class HeleketPaymentMixin: payment = await heleket_crud.get_heleket_payment_by_id(db, local_payment_id) if not payment: - logger.error('Heleket платеж с id=%s не найден', local_payment_id) + logger.error('Heleket платеж с id= не найден', local_payment_id=local_payment_id) return None payload: dict[str, Any] | None = None @@ -501,11 +484,7 @@ class HeleketPaymentMixin: order_id=payment.order_id, ) except Exception as error: # pragma: no cover - defensive - logger.exception( - 'Ошибка получения статуса Heleket платежа %s: %s', - payment.uuid, - error, - ) + logger.exception('Ошибка получения статуса Heleket платежа', uuid=payment.uuid, error=error) else: if response: result = response.get('result') if isinstance(response, dict) else None @@ -513,18 +492,13 @@ class HeleketPaymentMixin: payload = dict(result) else: logger.error( - 'Некорректный ответ Heleket API при проверке платежа %s: %s', - payment.uuid, - response, + 'Некорректный ответ Heleket API при проверке платежа', uuid=payment.uuid, response=response ) if payload is None: fallback = await self._lookup_heleket_payment_history(payment) if not fallback: - logger.warning( - 'Heleket API не вернул информацию по платежу %s', - payment.uuid, - ) + logger.warning('Heleket API не вернул информацию по платежу', uuid=payment.uuid) return payment payload = dict(fallback) diff --git a/app/services/payment/kassa_ai.py b/app/services/payment/kassa_ai.py index 5f49f111..d256bb92 100644 --- a/app/services/payment/kassa_ai.py +++ b/app/services/payment/kassa_ai.py @@ -54,17 +54,17 @@ class KassaAiPaymentMixin: # Валидация лимитов if amount_kopeks < settings.KASSA_AI_MIN_AMOUNT_KOPEKS: logger.warning( - 'KassaAI: сумма %s меньше минимальной %s', - amount_kopeks, - settings.KASSA_AI_MIN_AMOUNT_KOPEKS, + 'KassaAI: сумма меньше минимальной', + amount_kopeks=amount_kopeks, + KASSA_AI_MIN_AMOUNT_KOPEKS=settings.KASSA_AI_MIN_AMOUNT_KOPEKS, ) return None if amount_kopeks > settings.KASSA_AI_MAX_AMOUNT_KOPEKS: logger.warning( - 'KassaAI: сумма %s больше максимальной %s', - amount_kopeks, - settings.KASSA_AI_MAX_AMOUNT_KOPEKS, + 'KassaAI: сумма больше максимальной', + amount_kopeks=amount_kopeks, + KASSA_AI_MAX_AMOUNT_KOPEKS=settings.KASSA_AI_MAX_AMOUNT_KOPEKS, ) return None @@ -105,11 +105,7 @@ class KassaAiPaymentMixin: logger.error('KassaAI API не вернул URL платежа') return None - logger.info( - 'KassaAI API: создан заказ order_id=%s, url=%s', - order_id, - payment_url, - ) + logger.info('KassaAI API: создан заказ order_id url', order_id=order_id, payment_url=payment_url) # Импортируем CRUD модуль kassa_ai_crud = import_module('app.database.crud.kassa_ai') @@ -129,11 +125,11 @@ class KassaAiPaymentMixin: ) logger.info( - 'KassaAI: создан платеж order_id=%s, user_id=%s, amount=%s %s', - order_id, - user_id, - amount_rubles, - currency, + 'KassaAI: создан платеж order_id user_id amount', + order_id=order_id, + user_id=user_id, + amount_rubles=amount_rubles, + currency=currency, ) return { @@ -147,7 +143,7 @@ class KassaAiPaymentMixin: } except Exception as e: - logger.exception('KassaAI: ошибка создания платежа: %s', e) + logger.exception('KassaAI: ошибка создания платежа', e=e) return None async def process_kassa_ai_webhook( @@ -179,7 +175,7 @@ class KassaAiPaymentMixin: try: # Проверка подписи if not kassa_ai_service.verify_webhook_signature(merchant_id, amount, order_id, sign): - logger.warning('KassaAI webhook: неверная подпись для order_id=%s', order_id) + logger.warning('KassaAI webhook: неверная подпись для order_id', order_id=order_id) return False # Импортируем CRUD модуль @@ -188,21 +184,21 @@ class KassaAiPaymentMixin: # Получаем платеж из БД payment = await kassa_ai_crud.get_kassa_ai_payment_by_order_id(db, order_id) if not payment: - logger.warning('KassaAI webhook: платеж не найден order_id=%s', order_id) + logger.warning('KassaAI webhook: платеж не найден order_id', order_id=order_id) return False # Проверка дублирования if payment.is_paid: - logger.info('KassaAI webhook: платеж уже обработан order_id=%s', order_id) + logger.info('KassaAI webhook: платеж уже обработан order_id', order_id=order_id) return True # Проверка суммы expected_amount = payment.amount_kopeks / 100 if abs(amount - expected_amount) > 0.01: logger.warning( - 'KassaAI webhook: несоответствие суммы ожидалось=%s, получено=%s', - expected_amount, - amount, + 'KassaAI webhook: несоответствие суммы ожидалось получено', + expected_amount=expected_amount, + amount=amount, ) return False @@ -229,7 +225,7 @@ class KassaAiPaymentMixin: return await self._finalize_kassa_ai_payment(db, payment, intid=intid, trigger='webhook') except Exception as e: - logger.exception('KassaAI webhook: ошибка обработки: %s', e) + logger.exception('KassaAI webhook: ошибка обработки', e=e) return False async def _finalize_kassa_ai_payment( @@ -245,9 +241,7 @@ class KassaAiPaymentMixin: if payment.transaction_id: logger.info( - 'KassaAI платеж %s уже привязан к транзакции (trigger=%s)', - payment.order_id, - trigger, + 'KassaAI платеж уже привязан к транзакции (trigger=)', order_id=payment.order_id, trigger=trigger ) return True @@ -255,10 +249,10 @@ class KassaAiPaymentMixin: user = await payment_module.get_user_by_id(db, payment.user_id) if not user: logger.error( - 'Пользователь %s не найден для KassaAI платежа %s (trigger=%s)', - payment.user_id, - payment.order_id, - trigger, + 'Пользователь не найден для KassaAI платежа (trigger=)', + user_id=payment.user_id, + order_id=payment.order_id, + trigger=trigger, ) return False @@ -304,7 +298,7 @@ class KassaAiPaymentMixin: await process_referral_topup(db, user.id, payment.amount_kopeks, getattr(self, 'bot', None)) except Exception as error: - logger.error('Ошибка обработки реферального пополнения KassaAI: %s', error) + logger.error('Ошибка обработки реферального пополнения KassaAI', error=error) if was_first_topup and not user.has_made_first_topup: user.has_made_first_topup = True @@ -332,7 +326,7 @@ class KassaAiPaymentMixin: db=db, ) except Exception as error: - logger.error('Ошибка отправки админ уведомления KassaAI: %s', error) + logger.error('Ошибка отправки админ уведомления KassaAI', error=error) # Отправка уведомления пользователю (только Telegram-пользователям) if getattr(self, 'bot', None) and user.telegram_id: @@ -355,7 +349,7 @@ class KassaAiPaymentMixin: reply_markup=keyboard, ) except Exception as error: - logger.error('Ошибка отправки уведомления пользователю KassaAI: %s', error) + logger.error('Ошибка отправки уведомления пользователю KassaAI', error=error) # Автопокупка подписки try: @@ -375,9 +369,9 @@ class KassaAiPaymentMixin: ) except Exception as auto_error: logger.error( - 'Ошибка автоматической покупки подписки для пользователя %s: %s', - user.id, - auto_error, + 'Ошибка автоматической покупки подписки для пользователя', + user_id=user.id, + auto_error=auto_error, exc_info=True, ) @@ -420,17 +414,14 @@ class KassaAiPaymentMixin: ) except Exception as error: logger.error( - 'Ошибка при работе с сохраненной корзиной для пользователя %s: %s', - user.id, - error, - exc_info=True, + 'Ошибка при работе с сохраненной корзиной для пользователя', user_id=user.id, error=error, exc_info=True ) logger.info( - '✅ Обработан KassaAI платеж %s для пользователя %s (trigger=%s)', - payment.order_id, - payment.user_id, - trigger, + '✅ Обработан KassaAI платеж для пользователя (trigger=)', + order_id=payment.order_id, + user_id=payment.user_id, + trigger=trigger, ) return True @@ -454,7 +445,7 @@ class KassaAiPaymentMixin: status_data = await kassa_ai_service.get_order_status(order_id) return status_data except Exception as e: - logger.exception('KassaAI: ошибка проверки статуса: %s', e) + logger.exception('KassaAI: ошибка проверки статуса', e=e) return None async def get_kassa_ai_payment_status( @@ -466,12 +457,12 @@ class KassaAiPaymentMixin: Проверяет статус платежа KassaAI по локальному ID через API. Если платёж оплачен — автоматически начисляет баланс. """ - logger.info('KassaAI: checking payment status for id=%s', local_payment_id) + logger.info('KassaAI: checking payment status for id', local_payment_id=local_payment_id) kassa_ai_crud = import_module('app.database.crud.kassa_ai') payment = await kassa_ai_crud.get_kassa_ai_payment_by_id(db, local_payment_id) if not payment: - logger.warning('KassaAI payment not found: id=%s', local_payment_id) + logger.warning('KassaAI payment not found: id', local_payment_id=local_payment_id) return None if payment.is_paid: @@ -508,7 +499,7 @@ class KassaAiPaymentMixin: kai_status = int(target_order.get('status', 0)) if kai_status == 1: - logger.info('KassaAI payment %s confirmed via API', payment.order_id) + logger.info('KassaAI payment confirmed via API', order_id=payment.order_id) callback_payload = { 'check_source': 'api', @@ -537,7 +528,7 @@ class KassaAiPaymentMixin: trigger='api_check', ) except Exception as e: - logger.error('Error checking KassaAI payment status: %s', e) + logger.error('Error checking KassaAI payment status', e=e) return { 'payment': payment, diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index 423bc8ff..a50a91b5 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -32,24 +32,24 @@ class MulenPayPaymentMixin: display_name = settings.get_mulenpay_display_name() settings.get_mulenpay_display_name_html() if not getattr(self, 'mulenpay_service', None): - logger.error('%s сервис не инициализирован', display_name) + logger.error('сервис не инициализирован', display_name=display_name) return None if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS: logger.warning( - 'Сумма %s меньше минимальной: %s < %s', - display_name, - amount_kopeks, - settings.MULENPAY_MIN_AMOUNT_KOPEKS, + 'Сумма меньше минимальной: <', + display_name=display_name, + amount_kopeks=amount_kopeks, + MULENPAY_MIN_AMOUNT_KOPEKS=settings.MULENPAY_MIN_AMOUNT_KOPEKS, ) return None if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS: logger.warning( - 'Сумма %s больше максимальной: %s > %s', - display_name, - amount_kopeks, - settings.MULENPAY_MAX_AMOUNT_KOPEKS, + 'Сумма больше максимальной: >', + display_name=display_name, + amount_kopeks=amount_kopeks, + MULENPAY_MAX_AMOUNT_KOPEKS=settings.MULENPAY_MAX_AMOUNT_KOPEKS, ) return None @@ -79,7 +79,7 @@ class MulenPayPaymentMixin: ) if not response: - logger.error('Ошибка создания %s платежа', display_name) + logger.error('Ошибка создания платежа', display_name=display_name) return None mulen_payment_id = response.get('id') @@ -105,11 +105,11 @@ class MulenPayPaymentMixin: ) logger.info( - 'Создан %s платеж %s на %s₽ для пользователя %s', - display_name, - mulen_payment_id, - amount_rubles, - user_id, + 'Создан платеж на ₽ для пользователя', + display_name=display_name, + mulen_payment_id=mulen_payment_id, + amount_rubles=amount_rubles, + user_id=user_id, ) return { @@ -122,7 +122,7 @@ class MulenPayPaymentMixin: } except Exception as error: - logger.error('Ошибка создания %s платежа: %s', display_name, error) + logger.error('Ошибка создания платежа', display_name=display_name, error=error) return None async def process_mulenpay_callback( @@ -149,15 +149,15 @@ class MulenPayPaymentMixin: mulen_payment_id_int = None amount_value = callback_data.get('amount') logger.debug( - '%s callback: uuid=%s, status=%s, amount=%s', - display_name, - uuid_value, - payment_status, - amount_value, + 'callback: uuid status amount', + display_name=display_name, + uuid_value=uuid_value, + payment_status=payment_status, + amount_value=amount_value, ) if not uuid_value and mulen_payment_id_raw is None: - logger.error('%s callback без uuid и id', display_name) + logger.error('callback без uuid и id', display_name=display_name) return False payment = None @@ -169,10 +169,10 @@ class MulenPayPaymentMixin: if not payment: logger.error( - '%s платеж не найден (uuid=%s, id=%s)', - display_name, - uuid_value, - mulen_payment_id_raw, + 'платеж не найден (uuid id=)', + display_name=display_name, + uuid_value=uuid_value, + mulen_payment_id_raw=mulen_payment_id_raw, ) return False @@ -189,10 +189,10 @@ class MulenPayPaymentMixin: await self.bot.delete_message(chat_id, message_id) except Exception as delete_error: # pragma: no cover - depends on bot rights logger.warning( - 'Не удалось удалить %s счёт %s: %s', - display_name, - message_id, - delete_error, + 'Не удалось удалить счёт', + display_name=display_name, + message_id=message_id, + delete_error=delete_error, ) else: metadata.pop('invoice_message', None) @@ -208,15 +208,13 @@ class MulenPayPaymentMixin: ) except Exception as error: # pragma: no cover - diagnostics logger.warning( - 'Не удалось обновить метаданные %s после удаления счёта: %s', - display_name, - error, + 'Не удалось обновить метаданные после удаления счёта', + display_name=display_name, + error=error, ) logger.info( - '%s платеж %s уже обработан, игнорируем повторный callback', - display_name, - payment.uuid, + 'платеж уже обработан, игнорируем повторный callback', display_name=display_name, uuid=payment.uuid ) return True @@ -231,11 +229,7 @@ class MulenPayPaymentMixin: ) if payment.transaction_id: - logger.info( - 'Для %s платежа %s уже создана транзакция', - display_name, - payment.uuid, - ) + logger.info('Для платежа уже создана транзакция', display_name=display_name, uuid=payment.uuid) return True payment_description = getattr( @@ -265,9 +259,7 @@ class MulenPayPaymentMixin: user = await payment_module.get_user_by_id(db, payment.user_id) if not user: logger.error( - 'Пользователь %s не найден при обработке %s', - payment.user_id, - display_name, + 'Пользователь не найден при обработке', user_id=payment.user_id, display_name=display_name ) return False @@ -292,11 +284,7 @@ class MulenPayPaymentMixin: getattr(self, 'bot', None), ) except Exception as error: - logger.error( - 'Ошибка обработки реферального пополнения %s: %s', - display_name, - error, - ) + logger.error('Ошибка обработки реферального пополнения', display_name=display_name, error=error) if was_first_topup and not user.has_made_first_topup: user.has_made_first_topup = True @@ -307,9 +295,9 @@ class MulenPayPaymentMixin: user = await payment_module.get_user_by_id(db, user.id) if not user: logger.error( - 'Пользователь %s не найден при повторной загрузке после %s', - payment.user_id, - display_name, + 'Пользователь не найден при повторной загрузке после', + user_id=payment.user_id, + display_name=display_name, ) return False @@ -336,11 +324,7 @@ class MulenPayPaymentMixin: db=db, ) except Exception as error: - logger.error( - 'Ошибка отправки уведомления о пополнении %s: %s', - display_name, - error, - ) + logger.error('Ошибка отправки уведомления о пополнении', display_name=display_name, error=error) if getattr(self, 'bot', None) and user.telegram_id: try: @@ -358,11 +342,7 @@ class MulenPayPaymentMixin: reply_markup=keyboard, ) except Exception as error: - logger.error( - 'Ошибка отправки уведомления пользователю %s: %s', - display_name, - error, - ) + logger.error('Ошибка отправки уведомления пользователю', display_name=display_name, error=error) # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: @@ -381,9 +361,9 @@ class MulenPayPaymentMixin: ) except Exception as auto_error: logger.error( - 'Ошибка автоматической покупки подписки для пользователя %s: %s', - user.id, - auto_error, + 'Ошибка автоматической покупки подписки для пользователя', + user_id=user.id, + auto_error=auto_error, exc_info=True, ) @@ -425,19 +405,22 @@ class MulenPayPaymentMixin: reply_markup=keyboard, ) logger.info( - 'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s', - user.id, + 'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю', + user_id=user.id, ) except Exception as e: logger.error( - f'Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}', exc_info=True + 'Ошибка при работе с сохраненной корзиной для пользователя', + user_id=user.id, + error=e, + exc_info=True, ) logger.info( - '✅ Обработан %s платеж %s для пользователя %s', - display_name, - payment.uuid, - payment.user_id, + '✅ Обработан платеж для пользователя', + display_name=display_name, + uuid=payment.uuid, + user_id=payment.user_id, ) return True @@ -449,7 +432,7 @@ class MulenPayPaymentMixin: callback_payload=callback_data, mulen_payment_id=mulen_payment_id_int, ) - logger.info('%s платеж %s отменен', display_name, payment.uuid) + logger.info('платеж отменен', display_name=display_name, uuid=payment.uuid) return True await payment_module.update_mulenpay_payment_status( @@ -460,20 +443,15 @@ class MulenPayPaymentMixin: mulen_payment_id=mulen_payment_id_int, ) logger.info( - 'Получен %s callback со статусом %s для платежа %s', - display_name, - payment_status, - payment.uuid, + 'Получен callback со статусом для платежа', + display_name=display_name, + payment_status=payment_status, + uuid=payment.uuid, ) return True except Exception as error: - logger.error( - 'Ошибка обработки %s callback: %s', - display_name, - error, - exc_info=True, - ) + logger.error('Ошибка обработки callback', display_name=display_name, error=error, exc_info=True) return False def _map_mulenpay_status(self, status_code: int | None) -> str: @@ -566,10 +544,5 @@ class MulenPayPaymentMixin: } except Exception as error: - logger.error( - 'Ошибка получения статуса %s: %s', - display_name, - error, - exc_info=True, - ) + logger.error('Ошибка получения статуса', display_name=display_name, error=error, exc_info=True) return None diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index d486950b..7c047d9a 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -42,17 +42,17 @@ class Pal24PaymentMixin: if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS: logger.warning( - 'Сумма Pal24 меньше минимальной: %s < %s', - amount_kopeks, - settings.PAL24_MIN_AMOUNT_KOPEKS, + 'Сумма Pal24 меньше минимальной: <', + amount_kopeks=amount_kopeks, + PAL24_MIN_AMOUNT_KOPEKS=settings.PAL24_MIN_AMOUNT_KOPEKS, ) return None if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS: logger.warning( - 'Сумма Pal24 больше максимальной: %s > %s', - amount_kopeks, - settings.PAL24_MAX_AMOUNT_KOPEKS, + 'Сумма Pal24 больше максимальной: >', + amount_kopeks=amount_kopeks, + PAL24_MAX_AMOUNT_KOPEKS=settings.PAL24_MAX_AMOUNT_KOPEKS, ) return None @@ -81,16 +81,16 @@ class Pal24PaymentMixin: payment_method=api_payment_method, ) except Pal24APIError as error: - logger.error('Ошибка Pal24 API при создании счета: %s', error) + logger.error('Ошибка Pal24 API при создании счета', error=error) return None if not response.get('success', True): - logger.error('Pal24 вернул ошибку при создании счета: %s', response) + logger.error('Pal24 вернул ошибку при создании счета', response=response) return None bill_id = response.get('bill_id') if not bill_id: - logger.error('Pal24 не вернул bill_id: %s', response) + logger.error('Pal24 не вернул bill_id', response=response) return None def _pick_url(*keys: str) -> str | None: @@ -169,10 +169,10 @@ class Pal24PaymentMixin: ) logger.info( - 'Создан Pal24 счет %s для пользователя %s (%s₽)', - bill_id, - user_id, - amount_kopeks / 100, + 'Создан Pal24 счет для пользователя (₽)', + bill_id=bill_id, + user_id=user_id, + amount_kopeks=amount_kopeks / 100, ) payment_status = getattr(payment, 'status', response.get('status', 'NEW')) @@ -230,7 +230,7 @@ class Pal24PaymentMixin: status = (callback.get('status') or callback.get('Status') or '').upper() if not bill_id and not order_id: - logger.error('Pal24 callback без идентификаторов: %s', callback) + logger.error('Pal24 callback без идентификаторов', callback=callback) return False payment = None @@ -240,11 +240,11 @@ class Pal24PaymentMixin: payment = await payment_module.get_pal24_payment_by_order_id(db, order_id) if not payment: - logger.error('Pal24 платеж не найден: %s / %s', bill_id, order_id) + logger.error('Pal24 платеж не найден: /', bill_id=bill_id, order_id=order_id) return False if payment.is_paid: - logger.info('Pal24 платеж %s уже обработан', payment.bill_id) + logger.info('Pal24 платеж уже обработан', bill_id=payment.bill_id) return True if status in {'PAID', 'SUCCESS', 'OVERPAID'}: @@ -300,15 +300,11 @@ class Pal24PaymentMixin: balance_currency=callback.get('BalanceCurrency') or callback.get('balance_currency'), payer_account=callback.get('AccountNumber') or callback.get('account') or callback.get('Account'), ) - logger.info( - 'Обновили Pal24 платеж %s до статуса %s', - payment.bill_id, - status, - ) + logger.info('Обновили Pal24 платеж до статуса', bill_id=payment.bill_id, status=status) return True except Exception as error: - logger.error('Ошибка обработки Pal24 callback: %s', error, exc_info=True) + logger.error('Ошибка обработки Pal24 callback', error=error, exc_info=True) return False async def _finalize_pal24_payment( @@ -335,9 +331,7 @@ class Pal24PaymentMixin: await self.bot.delete_message(chat_id, message_id) except Exception as delete_error: # pragma: no cover - depends on rights logger.warning( - 'Не удалось удалить счёт PayPalych %s: %s', - message_id, - delete_error, + 'Не удалось удалить счёт PayPalych', message_id=message_id, delete_error=delete_error ) else: metadata.pop('invoice_message', None) @@ -353,26 +347,19 @@ class Pal24PaymentMixin: ) payment.metadata_json = metadata except Exception as error: # pragma: no cover - diagnostics - logger.warning( - 'Не удалось обновить метаданные PayPalych после удаления счёта: %s', - error, - ) + logger.warning('Не удалось обновить метаданные PayPalych после удаления счёта', error=error) if payment.transaction_id: - logger.info( - 'Pal24 платеж %s уже привязан к транзакции (trigger=%s)', - payment.bill_id, - trigger, - ) + logger.info('Pal24 платеж уже привязан к транзакции (trigger=)', bill_id=payment.bill_id, trigger=trigger) return True user = await payment_module.get_user_by_id(db, payment.user_id) if not user: logger.error( - 'Пользователь %s не найден для Pal24 платежа %s (trigger=%s)', - payment.user_id, - payment.bill_id, - trigger, + 'Пользователь не найден для Pal24 платежа (trigger=)', + user_id=payment.user_id, + bill_id=payment.bill_id, + trigger=trigger, ) return False @@ -408,10 +395,7 @@ class Pal24PaymentMixin: await process_referral_topup(db, user.id, payment.amount_kopeks, getattr(self, 'bot', None)) except Exception as error: - logger.error( - 'Ошибка обработки реферального пополнения Pal24: %s', - error, - ) + logger.error('Ошибка обработки реферального пополнения Pal24', error=error) if was_first_topup and not user.has_made_first_topup: user.has_made_first_topup = True @@ -438,10 +422,7 @@ class Pal24PaymentMixin: db=db, ) except Exception as error: - logger.error( - 'Ошибка отправки админ уведомления Pal24: %s', - error, - ) + logger.error('Ошибка отправки админ уведомления Pal24', error=error) if getattr(self, 'bot', None) and user.telegram_id: try: @@ -459,10 +440,7 @@ class Pal24PaymentMixin: reply_markup=keyboard, ) except Exception as error: - logger.error( - 'Ошибка отправки уведомления пользователю Pal24: %s', - error, - ) + logger.error('Ошибка отправки уведомления пользователю Pal24', error=error) try: from aiogram import types @@ -480,9 +458,9 @@ class Pal24PaymentMixin: ) except Exception as auto_error: logger.error( - 'Ошибка автоматической покупки подписки для пользователя %s: %s', - user.id, - auto_error, + 'Ошибка автоматической покупки подписки для пользователя', + user_id=user.id, + auto_error=auto_error, exc_info=True, ) @@ -531,27 +509,20 @@ class Pal24PaymentMixin: reply_markup=keyboard, ) logger.info( - 'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s', - user.id, + 'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю', user_id=user.id ) else: - logger.info( - 'У пользователя %s нет сохраненной корзины или автопокупка выполнена', - user.id, - ) + logger.info('У пользователя нет сохраненной корзины или автопокупка выполнена', user_id=user.id) except Exception as error: logger.error( - 'Ошибка при работе с сохраненной корзиной для пользователя %s: %s', - user.id, - error, - exc_info=True, + 'Ошибка при работе с сохраненной корзиной для пользователя', user_id=user.id, error=error, exc_info=True ) logger.info( - '✅ Обработан Pal24 платеж %s для пользователя %s (trigger=%s)', - payment.bill_id, - payment.user_id, - trigger, + '✅ Обработан Pal24 платеж для пользователя (trigger=)', + bill_id=payment.bill_id, + user_id=payment.user_id, + trigger=trigger, ) return True @@ -579,7 +550,7 @@ class Pal24PaymentMixin: try: response = await service.get_bill_status(bill_id_str) except Pal24APIError as error: - logger.error('Ошибка Pal24 API при получении статуса счёта: %s', error) + logger.error('Ошибка Pal24 API при получении статуса счёта', error=error) else: if response: remote_payloads['bill_status'] = response @@ -595,7 +566,7 @@ class Pal24PaymentMixin: try: payment_response = await service.get_payment_status(payment_id_str) except Pal24APIError as error: - logger.error('Ошибка Pal24 API при получении статуса платежа: %s', error) + logger.error('Ошибка Pal24 API при получении статуса платежа', error=error) else: if payment_response: remote_payloads['payment_status'] = payment_response @@ -606,7 +577,7 @@ class Pal24PaymentMixin: try: payments_response = await service.get_bill_payments(bill_id_str) except Pal24APIError as error: - logger.error('Ошибка Pal24 API при получении списка платежей: %s', error) + logger.error('Ошибка Pal24 API при получении списка платежей', error=error) else: if payments_response: remote_payloads['bill_payments'] = payments_response @@ -711,11 +682,7 @@ class Pal24PaymentMixin: if finalized: payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id) except Exception as error: - logger.error( - 'Ошибка автоматического начисления по Pal24 статусу: %s', - error, - exc_info=True, - ) + logger.error('Ошибка автоматического начисления по Pal24 статусу', error=error, exc_info=True) links_map, selected_method = self._build_links_map(payment, remote_payloads) primary_url = ( @@ -740,7 +707,7 @@ class Pal24PaymentMixin: } except Exception as error: - logger.error('Ошибка получения статуса Pal24: %s', error, exc_info=True) + logger.error('Ошибка получения статуса Pal24', error=error, exc_info=True) return None @staticmethod diff --git a/app/services/payment/platega.py b/app/services/payment/platega.py index 4b0477cc..cc870bb4 100644 --- a/app/services/payment/platega.py +++ b/app/services/payment/platega.py @@ -43,17 +43,17 @@ class PlategaPaymentMixin: if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS: logger.warning( - 'Сумма Platega меньше минимальной: %s < %s', - amount_kopeks, - settings.PLATEGA_MIN_AMOUNT_KOPEKS, + 'Сумма Platega меньше минимальной: <', + amount_kopeks=amount_kopeks, + PLATEGA_MIN_AMOUNT_KOPEKS=settings.PLATEGA_MIN_AMOUNT_KOPEKS, ) return None if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS: logger.warning( - 'Сумма Platega больше максимальной: %s > %s', - amount_kopeks, - settings.PLATEGA_MAX_AMOUNT_KOPEKS, + 'Сумма Platega больше максимальной: >', + amount_kopeks=amount_kopeks, + PLATEGA_MAX_AMOUNT_KOPEKS=settings.PLATEGA_MAX_AMOUNT_KOPEKS, ) return None @@ -73,7 +73,7 @@ class PlategaPaymentMixin: payload=payload_token, ) except Exception as error: # pragma: no cover - network errors - logger.exception('Ошибка Platega при создании платежа: %s', error) + logger.exception('Ошибка Platega при создании платежа', error=error) return None if not response: @@ -112,11 +112,11 @@ class PlategaPaymentMixin: ) logger.info( - 'Создан Platega платеж %s для пользователя %s (метод %s, сумма %s₽)', - transaction_id or payment.id, - user_id, - payment_method_code, - amount_value, + 'Создан Platega платеж для пользователя (метод , сумма ₽)', + transaction_id=transaction_id or payment.id, + user_id=user_id, + payment_method_code=payment_method_code, + amount_value=amount_value, ) return { @@ -148,12 +148,12 @@ class PlategaPaymentMixin: ) if not payment: - logger.warning('Platega webhook: платеж не найден (id=%s)', transaction_id) + logger.warning('Platega webhook: платеж не найден (id=)', transaction_id=transaction_id) return False status_raw = str(payload.get('status') or '').upper() if not status_raw: - logger.warning('Platega webhook без статуса для платежа %s', payment.id) + logger.warning('Platega webhook без статуса для платежа', payment_id=payment.id) return False update_kwargs = { @@ -166,7 +166,7 @@ class PlategaPaymentMixin: if status_raw in self._SUCCESS_STATUSES: if payment.is_paid: - logger.info('Platega платеж %s уже помечен как оплачен', payment.correlation_id) + logger.info('Platega платеж уже помечен как оплачен', correlation_id=payment.correlation_id) await payment_module.update_platega_payment( db, payment=payment, @@ -190,7 +190,7 @@ class PlategaPaymentMixin: **update_kwargs, is_paid=False, ) - logger.info('Platega платеж %s перешёл в статус %s', payment.correlation_id, status_raw) + logger.info('Platega платеж перешёл в статус', correlation_id=payment.correlation_id, status_raw=status_raw) return True await payment_module.update_platega_payment( @@ -219,9 +219,9 @@ class PlategaPaymentMixin: remote_payload = await service.get_transaction(payment.platega_transaction_id) except Exception as error: # pragma: no cover - network errors logger.error( - 'Ошибка Platega при получении транзакции %s: %s', - payment.platega_transaction_id, - error, + 'Ошибка Platega при получении транзакции', + platega_transaction_id=payment.platega_transaction_id, + error=error, ) if remote_payload: @@ -300,25 +300,21 @@ class PlategaPaymentMixin: try: await self.bot.delete_message(chat_id, message_id) except Exception as delete_error: # pragma: no cover - depends on bot rights - logger.warning( - 'Не удалось удалить Platega счёт %s: %s', - message_id, - delete_error, - ) + logger.warning('Не удалось удалить Platega счёт', message_id=message_id, delete_error=delete_error) else: metadata.pop('invoice_message', None) if payment.transaction_id: logger.info( - 'Platega платеж %s уже связан с транзакцией %s', - payment.correlation_id, - payment.transaction_id, + 'Platega платеж уже связан с транзакцией', + correlation_id=payment.correlation_id, + transaction_id=payment.transaction_id, ) return payment user = await payment_module.get_user_by_id(db, payment.user_id) if not user: - logger.error('Пользователь %s не найден для Platega', payment.user_id) + logger.error('Пользователь не найден для Platega', user_id=payment.user_id) return payment # Убеждаемся, что промогруппы загружены в асинхронном контексте, @@ -375,10 +371,7 @@ class PlategaPaymentMixin: should_credit_balance = created_transaction or not balance_already_credited if not should_credit_balance: - logger.info( - 'Platega платеж %s уже зачислил баланс ранее', - payment.correlation_id, - ) + logger.info('Platega платеж уже зачислил баланс ранее', correlation_id=payment.correlation_id) return payment old_balance = user.balance_kopeks @@ -400,7 +393,7 @@ class PlategaPaymentMixin: getattr(self, 'bot', None), ) except Exception as error: - logger.error('Ошибка обработки реферального пополнения Platega: %s', error) + logger.error('Ошибка обработки реферального пополнения Platega', error=error) if was_first_topup and not user.has_made_first_topup: user.has_made_first_topup = True @@ -423,7 +416,7 @@ class PlategaPaymentMixin: db=db, ) except Exception as error: - logger.error('Ошибка отправки админ уведомления Platega: %s', error) + logger.error('Ошибка отправки админ уведомления Platega', error=error) method_title = settings.get_platega_method_display_title(payment.payment_method_code) @@ -443,7 +436,7 @@ class PlategaPaymentMixin: reply_markup=keyboard, ) except Exception as error: - logger.error('Ошибка отправки уведомления пользователю Platega: %s', error) + logger.error('Ошибка отправки уведомления пользователю Platega', error=error) try: from aiogram import types @@ -461,9 +454,9 @@ class PlategaPaymentMixin: ) except Exception as auto_error: logger.error( - 'Ошибка автоматической покупки подписки для пользователя %s: %s', - user.id, - auto_error, + 'Ошибка автоматической покупки подписки для пользователя', + user_id=user.id, + auto_error=auto_error, exc_info=True, ) @@ -516,9 +509,9 @@ class PlategaPaymentMixin: ) except Exception as error: logger.error( - 'Ошибка при работе с сохраненной корзиной для пользователя %s: %s', - payment.user_id, - error, + 'Ошибка при работе с сохраненной корзиной для пользователя', + user_id=payment.user_id, + error=error, exc_info=True, ) @@ -536,9 +529,9 @@ class PlategaPaymentMixin: ) logger.info( - '✅ Обработан Platega платеж %s для пользователя %s', - payment.correlation_id, - payment.user_id, + '✅ Обработан Platega платеж для пользователя', + correlation_id=payment.correlation_id, + user_id=payment.user_id, ) return payment diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index c4630e39..62c054ca 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -68,14 +68,14 @@ class TelegramStarsMixin: ) logger.info( - 'Создан Stars invoice на %s звезд (~%s)', - stars_amount, - settings.format_price(amount_kopeks), + 'Создан Stars invoice на звезд (~)', + stars_amount=stars_amount, + format_price=settings.format_price(amount_kopeks), ) return invoice_link except Exception as error: - logger.error('Ошибка создания Stars invoice: %s', error) + logger.error('Ошибка создания Stars invoice', error=error) raise async def process_stars_payment( @@ -116,10 +116,7 @@ class TelegramStarsMixin: user = await get_user_by_id(db, user_id) if not user: - logger.error( - 'Пользователь с ID %s не найден при обработке Stars платежа', - user_id, - ) + logger.error('Пользователь с ID не найден при обработке Stars платежа', user_id=user_id) return False if simple_payload: @@ -143,7 +140,7 @@ class TelegramStarsMixin: ) except Exception as error: - logger.error('Ошибка обработки Stars платежа: %s', error, exc_info=True) + logger.error('Ошибка обработки Stars платежа', error=error, exc_info=True) return False @staticmethod @@ -160,10 +157,7 @@ class TelegramStarsMixin: tail = payload[len(prefix) :] parts = tail.split('_', 2) if len(parts) < 3: - logger.warning( - 'Payload Stars simple subscription имеет некорректный формат: %s', - payload, - ) + logger.warning('Payload Stars simple subscription имеет некорректный формат', payload=payload) return None user_part, subscription_part, period_part = parts @@ -171,37 +165,28 @@ class TelegramStarsMixin: try: payload_user_id = int(user_part) except ValueError: - logger.warning( - 'Не удалось разобрать user_id в payload Stars simple subscription: %s', - payload, - ) + logger.warning('Не удалось разобрать user_id в payload Stars simple subscription', payload=payload) return None if payload_user_id != expected_user_id: logger.warning( - 'Получен payload Stars simple subscription с чужим user_id: %s (ожидался %s)', - payload_user_id, - expected_user_id, + 'Получен payload Stars simple subscription с чужим user_id: (ожидался)', + payload_user_id=payload_user_id, + expected_user_id=expected_user_id, ) return None try: subscription_id = int(subscription_part) except ValueError: - logger.warning( - 'Не удалось разобрать subscription_id в payload Stars simple subscription: %s', - payload, - ) + logger.warning('Не удалось разобрать subscription_id в payload Stars simple subscription', payload=payload) return None period_days: int | None = None try: period_days = int(period_part) except ValueError: - logger.warning( - 'Не удалось разобрать период в payload Stars simple subscription: %s', - payload, - ) + logger.warning('Не удалось разобрать период в payload Stars simple subscription', payload=payload) return _SimpleSubscriptionPayload( subscription_id=subscription_id, @@ -238,19 +223,19 @@ class TelegramStarsMixin: pending_subscription = result.scalar_one_or_none() except Exception as lookup_error: # pragma: no cover - диагностический лог logger.error( - 'Ошибка поиска pending подписки %s для пользователя %s: %s', - payload_data.subscription_id, - user.id, - lookup_error, + 'Ошибка поиска pending подписки для пользователя', + subscription_id=payload_data.subscription_id, + user_id=user.id, + lookup_error=lookup_error, exc_info=True, ) pending_subscription = None if not pending_subscription: logger.error( - 'Не найдена pending подписка %s для пользователя %s', - payload_data.subscription_id, - user.id, + 'Не найдена pending подписка для пользователя', + subscription_id=payload_data.subscription_id, + user_id=user.id, ) return False @@ -270,18 +255,12 @@ class TelegramStarsMixin: ) except Exception as error: logger.error( - 'Ошибка активации pending подписки для пользователя %s: %s', - user.id, - error, - exc_info=True, + 'Ошибка активации pending подписки для пользователя', user_id=user.id, error=error, exc_info=True ) return False if not subscription: - logger.error( - 'Не удалось активировать pending подписку пользователя %s', - user.id, - ) + logger.error('Не удалось активировать pending подписку пользователя', user_id=user.id) return False try: @@ -296,9 +275,9 @@ class TelegramStarsMixin: await db.refresh(subscription) except Exception as sync_error: # pragma: no cover - диагностический лог logger.error( - 'Ошибка синхронизации подписки с RemnaWave для пользователя %s: %s', - user.id, - sync_error, + 'Ошибка синхронизации подписки с RemnaWave для пользователя', + user_id=user.id, + sync_error=sync_error, exc_info=True, ) @@ -352,15 +331,10 @@ class TelegramStarsMixin: parse_mode='HTML', ) logger.info( - '✅ Пользователь %s получил уведомление об оплате подписки через Stars', - user.telegram_id, + '✅ Пользователь получил уведомление об оплате подписки через Stars', telegram_id=user.telegram_id ) except Exception as error: # pragma: no cover - диагностический лог - logger.error( - 'Ошибка отправки уведомления о подписке через Stars: %s', - error, - exc_info=True, - ) + logger.error('Ошибка отправки уведомления о подписке через Stars', error=error, exc_info=True) if getattr(self, 'bot', None): try: @@ -377,9 +351,7 @@ class TelegramStarsMixin: ) except Exception as admin_error: # pragma: no cover - диагностический лог logger.error( - 'Ошибка уведомления администраторов о подписке через Stars: %s', - admin_error, - exc_info=True, + 'Ошибка уведомления администраторов о подписке через Stars', admin_error=admin_error, exc_info=True ) # Начисляем реферальную комиссию за прямую покупку подписки @@ -393,16 +365,13 @@ class TelegramStarsMixin: getattr(self, 'bot', None), ) except Exception as ref_error: - logger.error( - 'Ошибка реферального начисления при покупке подписки через Stars: %s', - ref_error, - ) + logger.error('Ошибка реферального начисления при покупке подписки через Stars', ref_error=ref_error) logger.info( - '✅ Обработан Stars платеж как покупка подписки: пользователь %s, %s звезд → %s', - user.id, - stars_amount, - settings.format_price(amount_kopeks), + '✅ Обработан Stars платеж как покупка подписки: пользователь , звезд →', + user_id=user.id, + stars_amount=stars_amount, + format_price=settings.format_price(amount_kopeks), ) return True @@ -434,8 +403,7 @@ class TelegramStarsMixin: description_for_referral = f'Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)' logger.info( - "🔍 Проверка реферальной логики для описания: '%s'", - description_for_referral, + "🔍 Проверка реферальной логики для описания: ''", description_for_referral=description_for_referral ) lower_description = description_for_referral.lower() @@ -446,10 +414,7 @@ class TelegramStarsMixin: allow_referral = contains_allowed_keywords and not contains_forbidden_keywords if allow_referral: - logger.info( - '🔞 Вызов process_referral_topup для пользователя %s', - user.id, - ) + logger.info('🔞 Вызов process_referral_topup для пользователя', user_id=user.id) try: from app.services.referral_service import process_referral_topup @@ -460,14 +425,10 @@ class TelegramStarsMixin: getattr(self, 'bot', None), ) except Exception as error: # pragma: no cover - диагностический лог - logger.error( - 'Ошибка обработки реферального пополнения: %s', - error, - ) + logger.error('Ошибка обработки реферального пополнения', error=error) else: logger.info( - "❌ Описание '%s' не подходит для реферальной логики", - description_for_referral, + "❌ Описание '' не подходит для реферальной логики", description_for_referral=description_for_referral ) if was_first_topup and not user.has_made_first_topup: @@ -477,11 +438,11 @@ class TelegramStarsMixin: await db.refresh(user) logger.info( - '💰 Баланс пользователя %s изменен: %s → %s (Δ +%s)', - user.telegram_id, - old_balance, - user.balance_kopeks, - amount_kopeks, + '💰 Баланс пользователя изменен: → (Δ +)', + telegram_id=user.telegram_id, + old_balance=old_balance, + balance_kopeks=user.balance_kopeks, + amount_kopeks=amount_kopeks, ) if getattr(self, 'bot', None): @@ -500,11 +461,7 @@ class TelegramStarsMixin: db=db, ) except Exception as error: # pragma: no cover - диагностический лог - logger.error( - 'Ошибка отправки уведомления о пополнении Stars: %s', - error, - exc_info=True, - ) + logger.error('Ошибка отправки уведомления о пополнении Stars', error=error, exc_info=True) # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: @@ -524,9 +481,9 @@ class TelegramStarsMixin: ) except Exception as auto_error: # pragma: no cover - диагностический лог logger.error( - 'Ошибка автоматической покупки подписки для пользователя %s: %s', - user.id, - auto_error, + 'Ошибка автоматической покупки подписки для пользователя', + user_id=user.id, + auto_error=auto_error, exc_info=True, ) @@ -573,21 +530,17 @@ class TelegramStarsMixin: reply_markup=keyboard, ) logger.info( - 'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s', - user.id, + 'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю', user_id=user.id ) except Exception as error: # pragma: no cover - диагностический лог logger.error( - 'Ошибка при работе с сохраненной корзиной для пользователя %s: %s', - user.id, - error, - exc_info=True, + 'Ошибка при работе с сохраненной корзиной для пользователя', user_id=user.id, error=error, exc_info=True ) logger.info( - '✅ Обработан Stars платеж: пользователь %s, %s звезд → %s', - user.id, - stars_amount, - settings.format_price(amount_kopeks), + '✅ Обработан Stars платеж: пользователь , звезд →', + user_id=user.id, + stars_amount=stars_amount, + format_price=settings.format_price(amount_kopeks), ) return True diff --git a/app/services/payment/tribute.py b/app/services/payment/tribute.py index f4a4d85f..fdcaade9 100644 --- a/app/services/payment/tribute.py +++ b/app/services/payment/tribute.py @@ -36,14 +36,12 @@ class TributePaymentMixin: payment_url = f'https://tribute.ru/pay?amount={amount_kopeks}&user={user_id}' logger.info( - 'Создан Tribute платеж на %s₽ для пользователя %s', - amount_kopeks / 100, - user_id, + 'Создан Tribute платеж на ₽ для пользователя', amount_kopeks=amount_kopeks / 100, user_id=user_id ) return payment_url except Exception as error: - logger.error('Ошибка создания Tribute платежа: %s', error) + logger.error('Ошибка создания Tribute платежа', error=error) raise def verify_tribute_webhook(self, data: dict[str, object], signature: str) -> bool: @@ -62,5 +60,5 @@ class TributePaymentMixin: return hmac.compare_digest(signature, expected_signature) except Exception as error: - logger.error('Ошибка проверки Tribute webhook: %s', error) + logger.error('Ошибка проверки Tribute webhook', error=error) return False diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py index 67ce5e95..424e85de 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -83,17 +83,17 @@ class WataPaymentMixin: if amount_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS: logger.warning( - 'Сумма WATA меньше минимальной: %s < %s', - amount_kopeks, - settings.WATA_MIN_AMOUNT_KOPEKS, + 'Сумма WATA меньше минимальной: <', + amount_kopeks=amount_kopeks, + WATA_MIN_AMOUNT_KOPEKS=settings.WATA_MIN_AMOUNT_KOPEKS, ) return None if amount_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS: logger.warning( - 'Сумма WATA больше максимальной: %s > %s', - amount_kopeks, - settings.WATA_MAX_AMOUNT_KOPEKS, + 'Сумма WATA больше максимальной: >', + amount_kopeks=amount_kopeks, + WATA_MAX_AMOUNT_KOPEKS=settings.WATA_MAX_AMOUNT_KOPEKS, ) return None @@ -108,7 +108,7 @@ class WataPaymentMixin: elif user.email: description = f'{description} | {user.email}' except Exception as error: - logger.debug('Не удалось получить данные пользователя для описания WATA: %s', error) + logger.debug('Не удалось получить данные пользователя для описания WATA', error=error) order_id = f'wata_{user_id}_{uuid.uuid4().hex[:12]}' @@ -120,10 +120,10 @@ class WataPaymentMixin: order_id=order_id, ) except WataAPIError as error: - logger.error('Ошибка создания WATA платежа: %s', error) + logger.error('Ошибка создания WATA платежа', error=error) return None except Exception as error: # pragma: no cover - safety net - logger.exception('Непредвиденная ошибка при создании WATA платежа: %s', error) + logger.exception('Непредвиденная ошибка при создании WATA платежа', error=error) return None payment_link_id = response.get('id') @@ -134,7 +134,7 @@ class WataPaymentMixin: fail_url = response.get('failRedirectUrl') if not payment_link_id: - logger.error('WATA API не вернула идентификатор платежной ссылки: %s', response) + logger.error('WATA API не вернула идентификатор платежной ссылки', response=response) return None expiration_raw = response.get('expirationDateTime') @@ -164,10 +164,10 @@ class WataPaymentMixin: ) logger.info( - 'Создан WATA платеж %s на %s₽ для пользователя %s', - payment_link_id, - amount_kopeks / 100, - user_id, + 'Создан WATA платеж на ₽ для пользователя', + payment_link_id=payment_link_id, + amount_kopeks=amount_kopeks / 100, + user_id=user_id, ) return { @@ -188,7 +188,7 @@ class WataPaymentMixin: payment_module = import_module('app.services.payment_service') if not isinstance(payload, dict): - logger.error('WATA webhook payload не является словарём: %s', payload) + logger.error('WATA webhook payload не является словарём', payload=payload) return False order_id_raw = payload.get('orderId') @@ -200,14 +200,11 @@ class WataPaymentMixin: transaction_status = (transaction_status_raw or '').strip() if not order_id and not payment_link_id: - logger.error( - 'WATA webhook без orderId и paymentLinkId: %s', - payload, - ) + logger.error('WATA webhook без orderId и paymentLinkId', payload=payload) return False if not transaction_status: - logger.error('WATA webhook без статуса транзакции: %s', payload) + logger.error('WATA webhook без статуса транзакции', payload=payload) return False payment = None @@ -218,9 +215,7 @@ class WataPaymentMixin: if not payment: logger.error( - 'WATA платеж не найден (order_id=%s, payment_link_id=%s)', - order_id, - payment_link_id, + 'WATA платеж не найден (order_id payment_link_id=)', order_id=order_id, payment_link_id=payment_link_id ) return False @@ -252,20 +247,14 @@ class WataPaymentMixin: if status_lower == 'paid': if payment.is_paid: - logger.info( - 'WATA платеж %s уже помечен как оплачен', - payment.payment_link_id, - ) + logger.info('WATA платеж уже помечен как оплачен', payment_link_id=payment.payment_link_id) return True await self._finalize_wata_payment(db, payment, payload) return True if status_lower == 'declined': - logger.info( - 'WATA платеж %s отклонён', - payment.payment_link_id, - ) + logger.info('WATA платеж отклонён', payment_link_id=payment.payment_link_id) return True @@ -288,9 +277,9 @@ class WataPaymentMixin: try: remote_link = await self.wata_service.get_payment_link(payment.payment_link_id) # type: ignore[union-attr] except WataAPIError as error: - logger.error('Ошибка получения WATA ссылки %s: %s', payment.payment_link_id, error) + logger.error('Ошибка получения WATA ссылки', payment_link_id=payment.payment_link_id, error=error) except Exception as error: # pragma: no cover - safety net - logger.exception('Непредвиденная ошибка при запросе WATA ссылки: %s', error) + logger.exception('Непредвиденная ошибка при запросе WATA ссылки', error=error) if remote_link: remote_status = remote_link.get('status') or payment.status @@ -317,16 +306,12 @@ class WataPaymentMixin: transaction_id ) except WataAPIError as error: - logger.error( - 'Ошибка получения WATA транзакции %s: %s', - transaction_id, - error, - ) + logger.error('Ошибка получения WATA транзакции', transaction_id=transaction_id, error=error) except Exception as error: # pragma: no cover - safety net logger.exception( - 'Непредвиденная ошибка при запросе WATA транзакции %s: %s', - transaction_id, - error, + 'Непредвиденная ошибка при запросе WATA транзакции', + transaction_id=transaction_id, + error=error, ) if not transaction_payload: try: @@ -343,12 +328,10 @@ class WataPaymentMixin: break except WataAPIError as error: logger.error( - 'Ошибка поиска WATA транзакций для %s: %s', - payment.payment_link_id, - error, + 'Ошибка поиска WATA транзакций для', payment_link_id=payment.payment_link_id, error=error ) except Exception as error: # pragma: no cover - safety net - logger.exception('Непредвиденная ошибка при поиске WATA транзакции: %s', error) + logger.exception('Непредвиденная ошибка при поиске WATA транзакции', error=error) if not transaction_payload and not payment.is_paid and getattr(self, 'wata_service', None): fallback_transaction_id = transaction_id or _extract_transaction_id(payment) @@ -359,15 +342,15 @@ class WataPaymentMixin: ) except WataAPIError as error: logger.error( - 'Ошибка повторного запроса WATA транзакции %s: %s', - fallback_transaction_id, - error, + 'Ошибка повторного запроса WATA транзакции', + fallback_transaction_id=fallback_transaction_id, + error=error, ) except Exception as error: # pragma: no cover - safety net logger.exception( - 'Непредвиденная ошибка при повторном запросе WATA транзакции %s: %s', - fallback_transaction_id, - error, + 'Непредвиденная ошибка при повторном запросе WATA транзакции', + fallback_transaction_id=fallback_transaction_id, + error=error, ) if transaction_payload and not payment.is_paid: @@ -380,9 +363,9 @@ class WataPaymentMixin: payment = await self._finalize_wata_payment(db, payment, transaction_payload) else: logger.debug( - 'WATA транзакция %s в статусе %s, повторная обработка не требуется', - transaction_id or getattr(payment, 'payment_link_id', ''), - normalized_status or 'unknown', + 'WATA транзакция в статусе , повторная обработка не требуется', + transaction_id=transaction_id or getattr(payment, 'payment_link_id', ''), + normalized_status=normalized_status or 'unknown', ) return { @@ -407,9 +390,9 @@ class WataPaymentMixin: paid_status = None if paid_status and str(paid_status).lower() not in {'paid', 'declined', 'pending'}: logger.debug( - 'Неизвестный статус WATA транзакции %s: %s', - getattr(payment, 'payment_link_id', ''), - paid_status, + 'Неизвестный статус WATA транзакции', + getattr=getattr(payment, 'payment_link_id', ''), + paid_status=paid_status, ) paid_at = None @@ -427,11 +410,7 @@ class WataPaymentMixin: try: await self.bot.delete_message(chat_id, message_id) except Exception as delete_error: # pragma: no cover - depends on rights - logger.warning( - 'Не удалось удалить счёт WATA %s: %s', - message_id, - delete_error, - ) + logger.warning('Не удалось удалить счёт WATA', message_id=message_id, delete_error=delete_error) else: existing_metadata.pop('invoice_message', None) @@ -449,15 +428,15 @@ class WataPaymentMixin: if payment.transaction_id: logger.info( - 'WATA платеж %s уже привязан к транзакции %s', - payment.payment_link_id, - payment.transaction_id, + 'WATA платеж уже привязан к транзакции', + payment_link_id=payment.payment_link_id, + transaction_id=payment.transaction_id, ) return payment user = await payment_module.get_user_by_id(db, payment.user_id) if not user: - logger.error('Пользователь %s не найден при обработке WATA', payment.user_id) + logger.error('Пользователь не найден при обработке WATA', user_id=payment.user_id) return payment transaction_external_id = str(transaction_payload.get('id') or transaction_payload.get('transactionId') or '') @@ -485,7 +464,7 @@ class WataPaymentMixin: await db.commit() user = await payment_module.get_user_by_id(db, user.id) if not user: - logger.error('Пользователь %s не найден после коммита WATA', payment.user_id) + logger.error('Пользователь не найден после коммита WATA', user_id=payment.user_id) return payment promo_group = user.get_primary_promo_group() @@ -503,7 +482,7 @@ class WataPaymentMixin: getattr(self, 'bot', None), ) except Exception as error: - logger.error('Ошибка обработки реферального пополнения WATA: %s', error) + logger.error('Ошибка обработки реферального пополнения WATA', error=error) if was_first_topup and not user.has_made_first_topup: user.has_made_first_topup = True @@ -526,7 +505,7 @@ class WataPaymentMixin: db=db, ) except Exception as error: - logger.error('Ошибка отправки админ уведомления WATA: %s', error) + logger.error('Ошибка отправки админ уведомления WATA', error=error) if getattr(self, 'bot', None) and user.telegram_id: try: @@ -548,7 +527,7 @@ class WataPaymentMixin: reply_markup=keyboard, ) except Exception as error: - logger.error('Ошибка отправки уведомления пользователю WATA: %s', error) + logger.error('Ошибка отправки уведомления пользователю WATA', error=error) try: from aiogram import types @@ -566,9 +545,9 @@ class WataPaymentMixin: ) except Exception as auto_error: logger.error( - 'Ошибка автоматической покупки подписки для пользователя %s: %s', - user.id, - auto_error, + 'Ошибка автоматической покупки подписки для пользователя', + user_id=user.id, + auto_error=auto_error, exc_info=True, ) @@ -613,6 +592,6 @@ class WataPaymentMixin: reply_markup=keyboard, ) except Exception as error: - logger.debug('Не удалось отправить напоминание о корзине после WATA: %s', error) + logger.debug('Не удалось отправить напоминание о корзине после WATA', error=error) return payment diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index ed05d603..1eb87683 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -121,7 +121,7 @@ class YooKassaPaymentMixin: payment_metadata['user_telegram_id'] = str(user.telegram_id) payment_metadata['user_username'] = user.username or '' except Exception as e: - logger.warning(f'Не удалось получить telegram_id для user_id={user_id}: {e}') + logger.warning('Не удалось получить telegram_id для user_id', user_id=user_id, error=e) # Preserve existing type from metadata if passed (e.g., "trial") existing_type = payment_metadata.get('type') @@ -143,7 +143,7 @@ class YooKassaPaymentMixin: ) if not yookassa_response or yookassa_response.get('error'): - logger.error('Ошибка создания платежа YooKassa: %s', yookassa_response) + logger.error('Ошибка создания платежа YooKassa', yookassa_response=yookassa_response) return None yookassa_created_at: datetime | None = None @@ -152,7 +152,7 @@ class YooKassaPaymentMixin: dt_with_tz = datetime.fromisoformat(yookassa_response['created_at'].replace('Z', '+00:00')) yookassa_created_at = dt_with_tz.replace(tzinfo=None) except Exception as error: - logger.warning('Не удалось распарсить created_at: %s', error) + logger.warning('Не удалось распарсить created_at', error=error) yookassa_created_at = None local_payment = await payment_module.create_yookassa_payment( @@ -171,10 +171,10 @@ class YooKassaPaymentMixin: ) logger.info( - 'Создан платеж YooKassa %s на %s₽ для пользователя %s', - yookassa_response['id'], - amount_rubles, - user_id, + 'Создан платеж YooKassa на ₽ для пользователя', + yookassa_response=yookassa_response['id'], + amount_rubles=amount_rubles, + user_id=user_id, ) return { @@ -188,7 +188,7 @@ class YooKassaPaymentMixin: } except Exception as error: - logger.error('Ошибка создания платежа YooKassa: %s', error) + logger.error('Ошибка создания платежа YooKassa', error=error) return None async def create_yookassa_sbp_payment( @@ -223,7 +223,7 @@ class YooKassaPaymentMixin: payment_metadata['user_telegram_id'] = str(user.telegram_id) payment_metadata['user_username'] = user.username or '' except Exception as e: - logger.warning(f'Не удалось получить telegram_id для user_id={user_id}: {e}') + logger.warning('Не удалось получить telegram_id для user_id', user_id=user_id, error=e) # Preserve existing type from metadata if passed (e.g., "trial") existing_type = payment_metadata.get('type') @@ -245,10 +245,7 @@ class YooKassaPaymentMixin: ) if not yookassa_response or yookassa_response.get('error'): - logger.error( - 'Ошибка создания платежа YooKassa СБП: %s', - yookassa_response, - ) + logger.error('Ошибка создания платежа YooKassa СБП', yookassa_response=yookassa_response) return None local_payment = await payment_module.create_yookassa_payment( @@ -267,10 +264,10 @@ class YooKassaPaymentMixin: ) logger.info( - 'Создан платеж YooKassa СБП %s на %s₽ для пользователя %s', - yookassa_response['id'], - amount_rubles, - user_id, + 'Создан платеж YooKassa СБП на ₽ для пользователя', + yookassa_response=yookassa_response['id'], + amount_rubles=amount_rubles, + user_id=user_id, ) confirmation_token = (yookassa_response.get('confirmation', {}) or {}).get('confirmation_token') @@ -288,7 +285,7 @@ class YooKassaPaymentMixin: } except Exception as error: - logger.error('Ошибка создания платежа YooKassa СБП: %s', error) + logger.error('Ошибка создания платежа YooKassa СБП', error=error) return None async def get_yookassa_payment_status( @@ -313,9 +310,7 @@ class YooKassaPaymentMixin: ) except Exception as error: # pragma: no cover - defensive logging logger.error( - 'Ошибка получения статуса YooKassa %s: %s', - payment.yookassa_payment_id, - error, + 'Ошибка получения статуса YooKassa', yookassa_payment_id=payment.yookassa_payment_id, error=error ) if remote_data: @@ -328,9 +323,7 @@ class YooKassaPaymentMixin: captured_at = datetime.fromisoformat(str(captured_raw).replace('Z', '+00:00')).replace(tzinfo=None) except Exception as parse_error: # pragma: no cover - diagnostic log logger.debug( - 'Не удалось распарсить captured_at %s: %s', - captured_raw, - parse_error, + 'Не удалось распарсить captured_at', captured_raw=captured_raw, parse_error=parse_error ) captured_at = None @@ -358,26 +351,26 @@ class YooKassaPaymentMixin: transaction_id = getattr(payment, 'transaction_id', None) except Exception as refresh_error: # pragma: no cover - defensive logging logger.warning( - 'Не удалось обновить состояние платежа YooKassa %s перед повторной обработкой: %s', - payment.yookassa_payment_id, - refresh_error, + 'Не удалось обновить состояние платежа YooKassa перед повторной обработкой', + yookassa_payment_id=payment.yookassa_payment_id, + refresh_error=refresh_error, exc_info=True, ) if transaction_id: logger.info( - 'Пропускаем повторную обработку платежа YooKassa %s: уже связан с транзакцией %s', - payment.yookassa_payment_id, - transaction_id, + 'Пропускаем повторную обработку платежа YooKassa : уже связан с транзакцией', + yookassa_payment_id=payment.yookassa_payment_id, + transaction_id=transaction_id, ) else: try: await self._process_successful_yookassa_payment(db, payment) except Exception as process_error: # pragma: no cover - defensive logging logger.error( - 'Ошибка обработки успешного платежа YooKassa %s: %s', - payment.yookassa_payment_id, - process_error, + 'Ошибка обработки успешного платежа YooKassa', + yookassa_payment_id=payment.yookassa_payment_id, + process_error=process_error, exc_info=True, ) @@ -406,9 +399,9 @@ class YooKassaPaymentMixin: # Fast-path: already processed if getattr(payment, 'transaction_id', None): logger.info( - 'Платеж YooKassa %s уже обработан (transaction_id=%s), пропускаем.', - payment.yookassa_payment_id, - payment.transaction_id, + 'Платеж YooKassa уже обработан (transaction_id=), пропускаем.', + yookassa_payment_id=payment.yookassa_payment_id, + transaction_id=payment.transaction_id, ) return True @@ -430,9 +423,9 @@ class YooKassaPaymentMixin: if existing_transaction: # Если транзакция уже существует, просто завершаем обработку logger.info( - 'Платеж YooKassa %s уже был обработан транзакцией %s. Пропускаем повторную обработку.', - payment.yookassa_payment_id, - existing_transaction.id, + 'Платеж YooKassa уже был обработан транзакцией . Пропускаем повторную обработку.', + yookassa_payment_id=payment.yookassa_payment_id, + existing_transaction_id=existing_transaction.id, ) # Убедимся, что платеж связан с транзакцией @@ -453,10 +446,10 @@ class YooKassaPaymentMixin: payment.transaction = linked_payment.transaction except Exception as link_error: # pragma: no cover - защитный лог logger.warning( - 'Не удалось привязать платеж YooKassa %s к существующей транзакции %s: %s', - payment.yookassa_payment_id, - existing_transaction.id, - link_error, + 'Не удалось привязать платеж YooKassa к существующей транзакции', + yookassa_payment_id=payment.yookassa_payment_id, + existing_transaction_id=existing_transaction.id, + link_error=link_error, exc_info=True, ) @@ -471,9 +464,9 @@ class YooKassaPaymentMixin: payment_metadata = json.loads(payment.metadata_json) elif isinstance(payment.metadata_json, dict): payment_metadata = payment.metadata_json - logger.info(f'Метаданные платежа: {payment_metadata}') + logger.info('Метаданные платежа', payment_metadata=payment_metadata) except Exception as parse_error: - logger.error(f'Ошибка парсинга метаданных платежа: {parse_error}') + logger.error('Ошибка парсинга метаданных платежа', parse_error=parse_error) invoice_message = payment_metadata.get('invoice_message') or {} if getattr(self, 'bot', None): @@ -484,9 +477,7 @@ class YooKassaPaymentMixin: await self.bot.delete_message(chat_id, message_id) except Exception as delete_error: # pragma: no cover - depends on bot rights logger.warning( - 'Не удалось удалить сообщение YooKassa %s: %s', - message_id, - delete_error, + 'Не удалось удалить сообщение YooKassa', message_id=message_id, delete_error=delete_error ) else: payment_metadata.pop('invoice_message', None) @@ -503,26 +494,26 @@ class YooKassaPaymentMixin: transaction = await get_transaction_by_id(db, existing_transaction_id) except Exception as fetch_error: # pragma: no cover - диагностический лог logger.warning( - 'Не удалось получить транзакцию %s для платежа YooKassa %s: %s', - existing_transaction_id, - payment.yookassa_payment_id, - fetch_error, + 'Не удалось получить транзакцию для платежа YooKassa', + existing_transaction_id=existing_transaction_id, + yookassa_payment_id=payment.yookassa_payment_id, + fetch_error=fetch_error, exc_info=True, ) if transaction and processing_completed: logger.info( - 'Пропускаем повторную обработку платежа YooKassa %s: транзакция %s уже завершила начисление.', - payment.yookassa_payment_id, - existing_transaction_id, + 'Пропускаем повторную обработку платежа YooKassa : транзакция уже завершила начисление.', + yookassa_payment_id=payment.yookassa_payment_id, + existing_transaction_id=existing_transaction_id, ) return True if transaction: logger.info( - 'Транзакция %s для платежа YooKassa %s найдена, но обработка ранее не была завершена — повторяем критические шаги.', - existing_transaction_id, - payment.yookassa_payment_id, + 'Транзакция для платежа YooKassa найдена, но обработка ранее не была завершена — повторяем критические шаги.', + existing_transaction_id=existing_transaction_id, + yookassa_payment_id=payment.yookassa_payment_id, ) if transaction is None: @@ -542,9 +533,9 @@ class YooKassaPaymentMixin: if existing_transaction: # Если транзакция уже существует, пропускаем обработку logger.info( - 'Платеж YooKassa %s уже был обработан транзакцией %s. Пропускаем повторную обработку.', - payment.yookassa_payment_id, - existing_transaction.id, + 'Платеж YooKassa уже был обработан транзакцией . Пропускаем повторную обработку.', + yookassa_payment_id=payment.yookassa_payment_id, + existing_transaction_id=existing_transaction.id, ) # Убедимся, что платеж связан с транзакцией @@ -565,10 +556,10 @@ class YooKassaPaymentMixin: payment.transaction = linked_payment.transaction except Exception as link_error: # pragma: no cover - защитный лог logger.warning( - 'Не удалось привязать платеж YooKassa %s к существующей транзакции %s: %s', - payment.yookassa_payment_id, - existing_transaction.id, - link_error, + 'Не удалось привязать платеж YooKassa к существующей транзакции', + yookassa_payment_id=payment.yookassa_payment_id, + existing_transaction_id=existing_transaction.id, + link_error=link_error, exc_info=True, ) @@ -627,9 +618,9 @@ class YooKassaPaymentMixin: if is_trial_payment: # Обработка платного триала logger.info( - 'YooKassa платеж %s обработан как оплата триала. Баланс пользователя %s не изменяется.', - payment.yookassa_payment_id, - user.id, + 'YooKassa платеж обработан как оплата триала. Баланс пользователя не изменяется.', + yookassa_payment_id=payment.yookassa_payment_id, + user_id=user.id, ) try: subscription_id = payment_metadata.get('subscription_id') @@ -646,7 +637,9 @@ class YooKassaPaymentMixin: if subscription: logger.info( - f'Триальная подписка {subscription_id} активирована для пользователя {user.id}' + 'Триальная подписка активирована для пользователя', + subscription_id=subscription_id, + user_id=user.id, ) # Создаем пользователя в RemnaWave @@ -654,7 +647,7 @@ class YooKassaPaymentMixin: try: await subscription_service.create_remnawave_user(db, subscription) except Exception as rw_error: - logger.error(f'Ошибка создания RemnaWave для триала: {rw_error}') + logger.error('Ошибка создания RemnaWave для триала', rw_error=rw_error) # Уведомление админам if getattr(self, 'bot', None): @@ -667,7 +660,7 @@ class YooKassaPaymentMixin: payment_method='YooKassa', ) except Exception as admin_error: - logger.warning(f'Ошибка уведомления админов о триале: {admin_error}') + logger.warning('Ошибка уведомления админов о триале', admin_error=admin_error) # Уведомление пользователю (только для Telegram-пользователей) if getattr(self, 'bot', None) and user.telegram_id: @@ -684,23 +677,31 @@ class YooKassaPaymentMixin: parse_mode='HTML', ) except Exception as notify_error: - logger.warning(f'Ошибка уведомления пользователя о триале: {notify_error}') + logger.warning( + 'Ошибка уведомления пользователя о триале', notify_error=notify_error + ) elif not user.telegram_id: logger.info( - f'Пропуск Telegram-уведомления о триале для email-пользователя {user.id}' + 'Пропуск Telegram-уведомления о триале для email-пользователя', user_id=user.id ) else: - logger.error(f'Не удалось активировать триал {subscription_id} для {user.id}') + logger.error( + 'Не удалось активировать триал для', + subscription_id=subscription_id, + user_id=user.id, + ) else: logger.error('Отсутствует subscription_id в metadata триального платежа YooKassa') except Exception as trial_error: - logger.error(f'Ошибка обработки триального платежа YooKassa: {trial_error}', exc_info=True) + logger.error( + 'Ошибка обработки триального платежа YooKassa', trial_error=trial_error, exc_info=True + ) elif is_simple_subscription: logger.info( - 'YooKassa платеж %s обработан как покупка подписки. Баланс пользователя %s не изменяется.', - payment.yookassa_payment_id, - user.id, + 'YooKassa платеж обработан как покупка подписки. Баланс пользователя не изменяется.', + yookassa_payment_id=payment.yookassa_payment_id, + user_id=user.id, ) # Начисляем реферальную комиссию за прямую покупку подписки @@ -715,8 +716,7 @@ class YooKassaPaymentMixin: ) except Exception as ref_error: logger.error( - 'Ошибка реферального начисления при покупке подписки YooKassa: %s', - ref_error, + 'Ошибка реферального начисления при покупке подписки YooKassa', ref_error=ref_error ) else: old_balance = getattr(user, 'balance_kopeks', 0) @@ -772,10 +772,7 @@ class YooKassaPaymentMixin: getattr(self, 'bot', None), ) except Exception as error: - logger.error( - 'Ошибка обработки реферального пополнения YooKassa: %s', - error, - ) + logger.error('Ошибка обработки реферального пополнения YooKassa', error=error) if was_first_topup and not getattr(user, 'has_made_first_topup', False): user.has_made_first_topup = True @@ -806,9 +803,7 @@ class YooKassaPaymentMixin: logger.info('Уведомление админам о пополнении отправлено успешно') except Exception as error: logger.error( - 'Ошибка отправки уведомления админам о YooKassa пополнении: %s', - error, - exc_info=True, # Добавляем полный стек вызовов для отладки + 'Ошибка отправки уведомления админам о YooKassa пополнении', error=error, exc_info=True ) # Отправляем уведомление пользователю (только Telegram-пользователям) @@ -824,23 +819,19 @@ class YooKassaPaymentMixin: ) logger.info('Уведомление пользователю о платеже отправлено успешно') except Exception as error: - logger.error( - 'Ошибка отправки уведомления о платеже: %s', - error, - exc_info=True, # Добавляем полный стек вызовов для отладки - ) + logger.error('Ошибка отправки уведомления о платеже', error=error, exc_info=True) # Проверяем наличие сохраненной корзины для возврата к оформлению подписки # ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях - logger.info(f'Проверяем наличие сохраненной корзины для пользователя {user.id}') + logger.info('Проверяем наличие сохраненной корзины для пользователя', user_id=user.id) from app.services.user_cart_service import user_cart_service try: has_saved_cart = await user_cart_service.has_user_cart(user.id) logger.info( - 'Результат проверки корзины для пользователя %s: %s', - user.id, - has_saved_cart, + 'Результат проверки корзины для пользователя', + user_id=user.id, + has_saved_cart=has_saved_cart, ) auto_purchase_success = False @@ -853,9 +844,9 @@ class YooKassaPaymentMixin: ) except Exception as auto_error: logger.error( - 'Ошибка автоматической покупки подписки для пользователя %s: %s', - user.id, - auto_error, + 'Ошибка автоматической покупки подписки для пользователя', + user_id=user.id, + auto_error=auto_error, exc_info=True, ) @@ -908,21 +899,24 @@ class YooKassaPaymentMixin: reply_markup=keyboard, ) logger.info( - f'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}' + 'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю', + user_id=user.id, ) else: logger.info( - 'У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена', - user.id, + 'У пользователя нет сохраненной корзины, бот недоступен или покупка уже выполнена', + user_id=user.id, ) except Exception as e: logger.error( - f'Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}', + 'Критическая ошибка при работе с сохраненной корзиной для пользователя', + user_id=user.id, + error=e, exc_info=True, ) if is_simple_subscription: - logger.info(f'Обнаружен платеж простой покупки подписки для пользователя {user.id}') + logger.info('Обнаружен платеж простой покупки подписки для пользователя', user_id=user.id) try: # Активируем подписку from app.services.subscription_service import SubscriptionService @@ -933,7 +927,11 @@ class YooKassaPaymentMixin: subscription_period = int(payment_metadata.get('subscription_period', 30)) order_id = payment_metadata.get('order_id') - logger.info(f'Активация подписки: период={subscription_period} дней, заказ={order_id}') + logger.info( + 'Активация подписки: период= дней, заказ', + subscription_period=subscription_period, + order_id=order_id, + ) # Активируем pending подписку пользователя from app.database.crud.subscription import activate_pending_subscription @@ -943,7 +941,7 @@ class YooKassaPaymentMixin: ) if subscription: - logger.info(f'Подписка успешно активирована для пользователя {user.id}') + logger.info('Подписка успешно активирована для пользователя', user_id=user.id) # Обновляем данные подписки в RemnaWave, чтобы получить актуальные ссылки try: @@ -952,9 +950,9 @@ class YooKassaPaymentMixin: await db.refresh(subscription) except Exception as sync_error: logger.error( - 'Ошибка синхронизации подписки с RemnaWave для пользователя %s: %s', - user.id, - sync_error, + 'Ошибка синхронизации подписки с RemnaWave для пользователя', + user_id=user.id, + sync_error=sync_error, exc_info=True, ) @@ -997,7 +995,9 @@ class YooKassaPaymentMixin: parse_mode='HTML', ) elif not user.telegram_id: - logger.info(f'Пропуск Telegram-уведомления о подписке для email-пользователя {user.id}') + logger.info( + 'Пропуск Telegram-уведомления о подписке для email-пользователя', user_id=user.id + ) if getattr(self, 'bot', None): try: @@ -1038,14 +1038,16 @@ class YooKassaPaymentMixin: ) except Exception as admin_error: logger.error( - 'Ошибка отправки уведомления админам о покупке подписки через YooKassa: %s', - admin_error, + 'Ошибка отправки уведомления админам о покупке подписки через YooKassa', + admin_error=admin_error, exc_info=True, ) else: - logger.error(f'Ошибка активации подписки для пользователя {user.id}') + logger.error('Ошибка активации подписки для пользователя', user_id=user.id) except Exception as e: - logger.error(f'Ошибка активации подписки для пользователя {user.id}: {e}', exc_info=True) + logger.error( + 'Ошибка активации подписки для пользователя', user_id=user.id, error=e, exc_info=True + ) if not processing_marked: payment_metadata = await self._mark_yookassa_payment_processing_completed( @@ -1059,9 +1061,9 @@ class YooKassaPaymentMixin: critical_flow_completed = True else: logger.warning( - 'Пользователь %s для платежа YooKassa %s не найден — начисление баланса невозможно', - payment.user_id, - payment.yookassa_payment_id, + 'Пользователь для платежа YooKassa не найден — начисление баланса невозможно', + user_id=payment.user_id, + yookassa_payment_id=payment.yookassa_payment_id, ) if critical_flow_completed and not processing_marked: @@ -1074,17 +1076,17 @@ class YooKassaPaymentMixin: if is_simple_subscription: logger.info( - 'Успешно обработан платеж YooKassa %s как покупка подписки: пользователь %s, сумма %s₽', - payment.yookassa_payment_id, - payment.user_id, - payment.amount_kopeks / 100, + 'Успешно обработан платеж YooKassa как покупка подписки: пользователь , сумма ₽', + yookassa_payment_id=payment.yookassa_payment_id, + user_id=payment.user_id, + amount_kopeks=payment.amount_kopeks / 100, ) else: logger.info( - 'Успешно обработан платеж YooKassa %s: пользователь %s пополнил баланс на %s₽', - payment.yookassa_payment_id, - payment.user_id, - payment.amount_kopeks / 100, + 'Успешно обработан платеж YooKassa : пользователь пополнил баланс на ₽', + yookassa_payment_id=payment.yookassa_payment_id, + user_id=payment.user_id, + amount_kopeks=payment.amount_kopeks / 100, ) # Создаем чек через NaloGO (если NALOGO_ENABLED=true) @@ -1100,9 +1102,9 @@ class YooKassaPaymentMixin: except Exception as error: logger.error( - 'Ошибка обработки успешного платежа YooKassa %s: %s', - payment.yookassa_payment_id, - error, + 'Ошибка обработки успешного платежа YooKassa', + yookassa_payment_id=payment.yookassa_payment_id, + error=error, ) return False @@ -1139,9 +1141,9 @@ class YooKassaPaymentMixin: payment.metadata_json = updated_metadata except Exception as mark_error: # pragma: no cover - защитный лог logger.warning( - 'Не удалось отметить платеж YooKassa %s как завершенный: %s', - payment.yookassa_payment_id, - mark_error, + 'Не удалось отметить платеж YooKassa как завершенный', + yookassa_payment_id=payment.yookassa_payment_id, + mark_error=mark_error, exc_info=True, ) @@ -1162,8 +1164,9 @@ class YooKassaPaymentMixin: # Защита от дублей: если у транзакции уже есть чек — не создаём новый if transaction and getattr(transaction, 'receipt_uuid', None): logger.info( - f'Чек для платежа {payment.yookassa_payment_id} уже создан: {transaction.receipt_uuid}, ' - 'пропускаем повторное создание' + 'Чек для платежа уже создан: пропускаем повторное создание', + yookassa_payment_id=payment.yookassa_payment_id, + receipt_uuid=transaction.receipt_uuid, ) return @@ -1182,7 +1185,11 @@ class YooKassaPaymentMixin: ) if receipt_uuid: - logger.info(f'Чек NaloGO создан для платежа {payment.yookassa_payment_id}: {receipt_uuid}') + logger.info( + 'Чек NaloGO создан для платежа', + yookassa_payment_id=payment.yookassa_payment_id, + receipt_uuid=receipt_uuid, + ) # Сохраняем receipt_uuid в транзакцию if transaction: @@ -1190,16 +1197,18 @@ class YooKassaPaymentMixin: transaction.receipt_uuid = receipt_uuid transaction.receipt_created_at = datetime.utcnow() await db.commit() - logger.debug(f'Чек {receipt_uuid} привязан к транзакции {transaction.id}') + logger.debug( + 'Чек привязан к транзакции', receipt_uuid=receipt_uuid, transaction_id=transaction.id + ) except Exception as save_error: - logger.warning(f'Не удалось сохранить receipt_uuid в транзакцию: {save_error}') + logger.warning('Не удалось сохранить receipt_uuid в транзакцию', save_error=save_error) # При временной недоступности чек добавляется в очередь автоматически except Exception as error: logger.error( - 'Ошибка создания чека NaloGO для платежа %s: %s', - payment.yookassa_payment_id, - error, + 'Ошибка создания чека NaloGO для платежа', + yookassa_payment_id=payment.yookassa_payment_id, + error=error, exc_info=True, ) @@ -1213,7 +1222,7 @@ class YooKassaPaymentMixin: yookassa_payment_id = event_object.get('id') if not yookassa_payment_id: - logger.warning('Webhook без payment id: %s', event) + logger.warning('Webhook без payment id', event=event) return False remote_data: dict[str, Any] | None = None @@ -1224,9 +1233,9 @@ class YooKassaPaymentMixin: ) except Exception as error: # pragma: no cover - диагностический лог logger.warning( - 'Не удалось запросить актуальный статус платежа YooKassa %s: %s', - yookassa_payment_id, - error, + 'Не удалось запросить актуальный статус платежа YooKassa', + yookassa_payment_id=yookassa_payment_id, + error=error, exc_info=True, ) @@ -1235,10 +1244,10 @@ class YooKassaPaymentMixin: event_object = self._merge_remote_yookassa_payload(event_object, remote_data) if previous_status and event_object.get('status') != previous_status: logger.info( - 'Статус платежа YooKassa %s скорректирован по данным API: %s → %s', - yookassa_payment_id, - previous_status, - event_object.get('status'), + 'Статус платежа YooKassa скорректирован по данным API: →', + yookassa_payment_id=yookassa_payment_id, + previous_status=previous_status, + event_object=event_object.get('status'), ) event['object'] = event_object @@ -1246,13 +1255,12 @@ class YooKassaPaymentMixin: payment = await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id) if not payment: - logger.warning('Локальный платеж для YooKassa id %s не найден', yookassa_payment_id) + logger.warning('Локальный платеж для YooKassa id не найден', yookassa_payment_id=yookassa_payment_id) payment = await self._restore_missing_yookassa_payment(db, event_object) if not payment: logger.error( - 'Не удалось восстановить локальную запись платежа YooKassa %s', - yookassa_payment_id, + 'Не удалось восстановить локальную запись платежа YooKassa', yookassa_payment_id=yookassa_payment_id ) return False @@ -1274,11 +1282,7 @@ class YooKassaPaymentMixin: tzinfo=None ) except Exception as error: - logger.debug( - 'Не удалось распарсить captured_at=%s: %s', - captured_at_raw, - error, - ) + logger.debug('Не удалось распарсить captured_at', captured_at_raw=captured_at_raw, error=error) await db.commit() await db.refresh(payment) @@ -1287,9 +1291,9 @@ class YooKassaPaymentMixin: return await self._process_successful_yookassa_payment(db, payment) logger.info( - 'Webhook YooKassa обновил платеж %s до статуса %s', - yookassa_payment_id, - payment.status, + 'Webhook YooKassa обновил платеж до статуса', + yookassa_payment_id=yookassa_payment_id, + payment_status=payment.status, ) return True @@ -1309,8 +1313,8 @@ class YooKassaPaymentMixin: if user_id_raw is None: logger.error( - 'Webhook YooKassa %s не содержит user_id в metadata. Невозможно восстановить платеж.', - yookassa_payment_id, + 'Webhook YooKassa не содержит user_id в metadata. Невозможно восстановить платеж.', + yookassa_payment_id=yookassa_payment_id, ) return None @@ -1318,9 +1322,9 @@ class YooKassaPaymentMixin: user_id = int(user_id_raw) except (TypeError, ValueError): logger.error( - 'Webhook YooKassa %s содержит некорректный user_id=%s', - yookassa_payment_id, - user_id_raw, + 'Webhook YooKassa содержит некорректный user_id', + yookassa_payment_id=yookassa_payment_id, + user_id_raw=user_id_raw, ) return None @@ -1331,17 +1335,17 @@ class YooKassaPaymentMixin: user = await get_user_by_id(db, user_id) if not user: logger.warning( - 'Webhook YooKassa %s: user_id=%s не найден в БД, пропускаем восстановление платежа', - yookassa_payment_id, - user_id, + 'Webhook YooKassa : user_id= не найден в БД, пропускаем восстановление платежа', + yookassa_payment_id=yookassa_payment_id, + user_id=user_id, ) return None except Exception as e: logger.warning( - 'Webhook YooKassa %s: не удалось проверить user_id=%s: %s', - yookassa_payment_id, - user_id, - e, + 'Webhook YooKassa : не удалось проверить user_id', + yookassa_payment_id=yookassa_payment_id, + user_id=user_id, + e=e, ) return None @@ -1350,20 +1354,17 @@ class YooKassaPaymentMixin: currency = (amount_info.get('currency') or 'RUB').upper() if amount_value is None: - logger.error( - 'Webhook YooKassa %s не содержит сумму платежа', - yookassa_payment_id, - ) + logger.error('Webhook YooKassa не содержит сумму платежа', yookassa_payment_id=yookassa_payment_id) return None try: amount_kopeks = int((Decimal(str(amount_value)) * 100).quantize(Decimal(1))) except (InvalidOperation, ValueError) as error: logger.error( - 'Некорректная сумма в webhook YooKassa %s: %s (%s)', - yookassa_payment_id, - amount_value, - error, + 'Некорректная сумма в webhook YooKassa', + yookassa_payment_id=yookassa_payment_id, + amount_value=amount_value, + error=error, ) return None @@ -1377,10 +1378,10 @@ class YooKassaPaymentMixin: yookassa_created_at = datetime.fromisoformat(created_at_raw.replace('Z', '+00:00')).replace(tzinfo=None) except Exception as error: # pragma: no cover - диагностический лог logger.debug( - 'Не удалось распарсить created_at=%s для YooKassa %s: %s', - created_at_raw, - yookassa_payment_id, - error, + 'Не удалось распарсить created_at= для YooKassa', + created_at_raw=created_at_raw, + yookassa_payment_id=yookassa_payment_id, + error=error, ) payment_module = import_module('app.services.payment_service') @@ -1436,7 +1437,7 @@ class YooKassaPaymentMixin: if isinstance(parsed, dict): return parsed except json.JSONDecodeError: - logger.debug('Не удалось распарсить metadata webhook YooKassa: %s', metadata) + logger.debug('Не удалось распарсить metadata webhook YooKassa', metadata=metadata) return {} diff --git a/app/services/payment_method_config_service.py b/app/services/payment_method_config_service.py index 87c6feb0..d6ec3e3d 100644 --- a/app/services/payment_method_config_service.py +++ b/app/services/payment_method_config_service.py @@ -1,7 +1,6 @@ """Service for managing payment method display configurations in cabinet.""" -import logging - +import structlog from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -10,7 +9,7 @@ from app.config import settings from app.database.models import PaymentMethodConfig, PromoGroup -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # ============ Default method definitions ============ @@ -199,7 +198,10 @@ async def ensure_payment_method_configs(db: AsyncSession) -> None: db.add(config) await db.commit() - logger.info(f'Payment method configurations initialized ({len(DEFAULT_METHOD_ORDER)} methods).') + logger.info( + 'Payment method configurations initialized ( methods).', + DEFAULT_METHOD_ORDER_count=len(DEFAULT_METHOD_ORDER), + ) return # Add missing methods (for cases when new methods are added to code) @@ -207,7 +209,7 @@ async def ensure_payment_method_configs(db: AsyncSession) -> None: missing_methods = [m for m in DEFAULT_METHOD_ORDER if m not in existing_method_ids] if missing_methods: - logger.info(f'Adding missing payment methods: {missing_methods}') + logger.info('Adding missing payment methods', missing_methods=missing_methods) # Get max sort_order to append new methods at the end max_order_result = await db.execute(select(func.max(PaymentMethodConfig.sort_order))) max_order = max_order_result.scalar() or 0 @@ -235,7 +237,7 @@ async def ensure_payment_method_configs(db: AsyncSession) -> None: db.add(config) await db.commit() - logger.info(f'Added {len(missing_methods)} missing payment method(s).') + logger.info('Added missing payment method(s).', missing_methods_count=len(missing_methods)) # ============ CRUD ============ diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 29763c81..04f1a443 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -2,9 +2,9 @@ from __future__ import annotations -import logging from importlib import import_module +import structlog from aiogram import Bot from app.config import settings @@ -36,7 +36,7 @@ from app.services.yookassa_service import YooKassaService from app.utils.currency_converter import currency_converter # noqa: F401 -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # --- Совместимость: экспортируем функции, которые активно мокаются в тестах --- @@ -326,15 +326,15 @@ class PaymentService( mulenpay_name = settings.get_mulenpay_display_name() logger.debug( - 'PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Platega=%s, Wata=%s, CloudPayments=%s)', - bool(self.yookassa_service), - bool(self.stars_service), - bool(self.cryptobot_service), - bool(self.heleket_service), - mulenpay_name, - bool(self.mulenpay_service), - bool(self.pal24_service), - bool(self.platega_service), - bool(self.wata_service), - bool(self.cloudpayments_service), + 'PaymentService инициализирован (YooKassa Stars CryptoBot Heleket Pal24 Platega Wata CloudPayments=)', + yookassa_service=bool(self.yookassa_service), + stars_service=bool(self.stars_service), + cryptobot_service=bool(self.cryptobot_service), + heleket_service=bool(self.heleket_service), + mulenpay_name=mulenpay_name, + mulenpay_service=bool(self.mulenpay_service), + pal24_service=bool(self.pal24_service), + platega_service=bool(self.platega_service), + wata_service=bool(self.wata_service), + cloudpayments_service=bool(self.cloudpayments_service), ) diff --git a/app/services/payment_verification_service.py b/app/services/payment_verification_service.py index 9d94fe7d..c368f92c 100644 --- a/app/services/payment_verification_service.py +++ b/app/services/payment_verification_service.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import logging import re from collections import Counter from collections.abc import Iterable @@ -11,6 +10,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any +import structlog from sqlalchemy import desc, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -35,7 +35,7 @@ from app.database.models import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) PENDING_MAX_AGE = timedelta(hours=24) @@ -181,9 +181,9 @@ class AutoPaymentVerificationService: self._task = asyncio.create_task(self._auto_check_loop()) logger.info( - '🔄 Автопроверка пополнений запущена (каждые %s мин) для: %s', - interval_minutes, - display_names, + '🔄 Автопроверка пополнений запущена (каждые мин) для', + interval_minutes=interval_minutes, + display_names=display_names, ) async def stop(self) -> None: @@ -211,11 +211,7 @@ class AutoPaymentVerificationService: except asyncio.CancelledError: raise except Exception as error: - logger.error( - 'Ошибка автопроверки пополнений: %s', - error, - exc_info=True, - ) + logger.error('Ошибка автопроверки пополнений', error=error, exc_info=True) await asyncio.sleep(max(1, interval_minutes) * 60) except asyncio.CancelledError: @@ -241,9 +237,7 @@ class AutoPaymentVerificationService: for method, count in sorted(counts.items(), key=lambda item: method_display_name(item[0])) ) logger.info( - '🔄 Автопроверка пополнений: найдено %s инвойсов (%s)', - len(candidates), - summary, + '🔄 Автопроверка пополнений: найдено инвойсов', candidates_count=len(candidates), summary=summary ) for record in candidates: @@ -256,32 +250,32 @@ class AutoPaymentVerificationService: if not refreshed: logger.debug( - 'Автопроверка пополнений: не удалось обновить %s %s', - method_display_name(record.method), - record.identifier, + 'Автопроверка пополнений: не удалось обновить', + method_display_name=method_display_name(record.method), + identifier=record.identifier, ) continue if refreshed.is_paid and not record.is_paid: logger.info( - '✅ %s %s отмечен как оплаченный после автопроверки', - method_display_name(refreshed.method), - refreshed.identifier, + '✅ отмечен как оплаченный после автопроверки', + method_display_name=method_display_name(refreshed.method), + identifier=refreshed.identifier, ) elif refreshed.status != record.status: logger.info( - 'ℹ️ %s %s обновлён: %s → %s', - method_display_name(refreshed.method), - refreshed.identifier, - record.status or '—', - refreshed.status or '—', + 'ℹ️ обновлён: →', + method_display_name=method_display_name(refreshed.method), + identifier=refreshed.identifier, + record_status=record.status or '—', + refreshed_status=refreshed.status or '—', ) else: logger.debug( - 'Автопроверка пополнений: %s %s без изменений (%s)', - method_display_name(refreshed.method), - refreshed.identifier, - refreshed.status or '—', + 'Автопроверка пополнений: без изменений', + method_display_name=method_display_name(refreshed.method), + identifier=refreshed.identifier, + refreshed_status=refreshed.status or '—', ) if session.in_transaction(): @@ -399,17 +393,17 @@ def _build_record( ) -> PendingPayment | None: user = getattr(payment, 'user', None) if user is None: - logger.debug('Skipping %s payment %s without linked user', method.value, identifier) + logger.debug('Skipping payment without linked user', method_value=method.value, identifier=identifier) return None created_at = getattr(payment, 'created_at', None) if not isinstance(created_at, datetime): - logger.debug('Skipping %s payment %s without valid created_at', method.value, identifier) + logger.debug('Skipping payment without valid created_at', method_value=method.value, identifier=identifier) return None local_id = getattr(payment, 'id', None) if local_id is None: - logger.debug('Skipping %s payment without local id', method.value) + logger.debug('Skipping payment without local id', method_value=method.value) return None return PendingPayment( @@ -835,7 +829,7 @@ async def get_payment_record( return None await db.refresh(payment, attribute_names=['user']) if payment.created_at < cutoff: - logger.debug('YooKassa payment %s is older than cutoff', payment.id) + logger.debug('YooKassa payment is older than cutoff', payment_id=payment.id) return _build_record( method, payment, @@ -918,7 +912,7 @@ async def get_payment_record( is_paid=bool(transaction.is_completed), ) - logger.debug('Unsupported payment method requested: %s', method) + logger.debug('Unsupported payment method requested', method=method) return None @@ -961,7 +955,7 @@ async def run_manual_check( result = await payment_service.get_kassa_ai_payment_status(db, local_payment_id) payment = result.get('payment') if result else None else: - logger.warning('Manual check requested for unsupported method %s', method) + logger.warning('Manual check requested for unsupported method', method=method) return None if not payment: @@ -971,10 +965,10 @@ async def run_manual_check( except Exception as error: # pragma: no cover - defensive logging logger.error( - 'Manual status check failed for %s payment %s: %s', - method.value, - local_payment_id, - error, + 'Manual status check failed for payment', + method_value=method.value, + local_payment_id=local_payment_id, + error=error, exc_info=True, ) return None diff --git a/app/services/pinned_message_service.py b/app/services/pinned_message_service.py index d5f4a91f..d6201683 100644 --- a/app/services/pinned_message_service.py +++ b/app/services/pinned_message_service.py @@ -1,7 +1,7 @@ import asyncio -import logging from datetime import datetime +import structlog from aiogram import Bot from aiogram.exceptions import ( TelegramBadRequest, @@ -17,7 +17,7 @@ from app.database.models import PinnedMessage, User, UserStatus from app.utils.validators import sanitize_html, validate_html_tags -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def get_active_pinned_message(db: AsyncSession) -> PinnedMessage | None: @@ -76,7 +76,7 @@ async def set_active_pinned_message( await db.commit() await db.refresh(pinned_message) - logger.info('Создано новое закрепленное сообщение #%s', pinned_message.id) + logger.info('Создано новое закрепленное сообщение #', pinned_message_id=pinned_message.id) return pinned_message @@ -89,7 +89,7 @@ async def deactivate_active_pinned_message(db: AsyncSession) -> PinnedMessage | pinned_message.updated_at = datetime.utcnow() await db.commit() await db.refresh(pinned_message) - logger.info('Деактивировано закрепленное сообщение #%s', pinned_message.id) + logger.info('Деактивировано закрепленное сообщение #', pinned_message_id=pinned_message.id) return pinned_message @@ -175,17 +175,13 @@ async def broadcast_pinned_message( break except TelegramRetryAfter as retry_error: delay = min(retry_error.retry_after + 1, 30) - logger.warning( - 'RetryAfter for user %s, waiting %s seconds', - telegram_id, - delay, - ) + logger.warning('RetryAfter for user , waiting seconds', telegram_id=telegram_id, delay=delay) await asyncio.sleep(delay) except Exception as send_error: logger.error( - 'Ошибка отправки закрепленного сообщения пользователю %s: %s', - telegram_id, - send_error, + 'Ошибка отправки закрепленного сообщения пользователю', + telegram_id=telegram_id, + send_error=send_error, ) failed_count += 1 break @@ -255,11 +251,7 @@ async def unpin_active_pinned_message( else: failed_count += 1 except Exception as error: - logger.error( - 'Ошибка открепления сообщения у пользователя %s: %s', - telegram_id, - error, - ) + logger.error('Ошибка открепления сообщения у пользователя', telegram_id=telegram_id, error=error) failed_count += 1 for i in range(0, len(recipient_telegram_ids), 40): @@ -341,17 +333,9 @@ async def _send_and_pin_message(bot: Bot, chat_id: int, pinned_message: PinnedMe await asyncio.sleep(min(e.retry_after + 1, 30)) raise # Propagate to caller's retry loop except TelegramBadRequest as error: - logger.warning( - 'Некорректный запрос при отправке закрепленного сообщения в чат %s: %s', - chat_id, - error, - ) + logger.warning('Некорректный запрос при отправке закрепленного сообщения в чат', chat_id=chat_id, error=error) except Exception as error: - logger.error( - 'Не удалось отправить закрепленное сообщение пользователю %s: %s', - chat_id, - error, - ) + logger.error('Не удалось отправить закрепленное сообщение пользователю', chat_id=chat_id, error=error) return False @@ -365,18 +349,18 @@ async def _unpin_message_for_user(bot: Bot, chat_id: int, max_retries: int = 3) if attempt < max_retries - 1: delay = min(e.retry_after + 1, 30) logger.warning( - 'RetryAfter при откреплении для %s, ожидание %s сек (попытка %d/%d)', - chat_id, - delay, - attempt + 1, - max_retries, + 'RetryAfter при откреплении для , ожидание сек (попытка /)', + chat_id=chat_id, + delay=delay, + attempt=attempt + 1, + max_retries=max_retries, ) await asyncio.sleep(delay) else: logger.warning( - 'Не удалось открепить сообщение у %s после %d попыток (flood control)', - chat_id, - max_retries, + 'Не удалось открепить сообщение у после попыток (flood control)', + chat_id=chat_id, + max_retries=max_retries, ) return False except TelegramForbiddenError: @@ -384,10 +368,6 @@ async def _unpin_message_for_user(bot: Bot, chat_id: int, max_retries: int = 3) except TelegramBadRequest: return False except Exception as error: - logger.error( - 'Не удалось открепить сообщение у пользователя %s: %s', - chat_id, - error, - ) + logger.error('Не удалось открепить сообщение у пользователя', chat_id=chat_id, error=error) return False return False diff --git a/app/services/platega_service.py b/app/services/platega_service.py index ac90ea5c..258cadc7 100644 --- a/app/services/platega_service.py +++ b/app/services/platega_service.py @@ -4,16 +4,16 @@ from __future__ import annotations import asyncio import json -import logging from datetime import datetime, timedelta from typing import Any import aiohttp +import structlog from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class PlategaService: @@ -105,10 +105,7 @@ class PlategaService: if response.status >= 400: logger.error( - 'Platega API error %s %s: %s', - response.status, - endpoint, - raw_text, + 'Platega API error', response_status=response.status, endpoint=endpoint, raw_text=raw_text ) if response.status in self._retryable_statuses and attempt < self._max_retries: await asyncio.sleep(self._retry_delay * attempt) @@ -117,29 +114,29 @@ class PlategaService: return data except asyncio.CancelledError: - logger.debug('Platega request cancelled: %s %s', method, endpoint) + logger.debug('Platega request cancelled', method=method, endpoint=endpoint) raise except TimeoutError as error: last_error = error logger.warning( - 'Platega request timeout (%s %s) attempt %s/%s', - method, - endpoint, - attempt, - self._max_retries, + 'Platega request timeout attempt /', + method=method, + endpoint=endpoint, + attempt=attempt, + max_retries=self._max_retries, ) except aiohttp.ClientError as error: last_error = error logger.warning( - 'Platega client error (%s %s) attempt %s/%s: %s', - method, - endpoint, - attempt, - self._max_retries, - error, + 'Platega client error attempt /', + method=method, + endpoint=endpoint, + attempt=attempt, + max_retries=self._max_retries, + error=error, ) except Exception as error: # pragma: no cover - safety - logger.exception('Unexpected Platega error: %s', error) + logger.exception('Unexpected Platega error', error=error) return None if attempt < self._max_retries: @@ -147,11 +144,11 @@ class PlategaService: if last_error is not None: logger.error( - 'Platega request failed after %s attempts (%s %s): %s', - self._max_retries, - method, - endpoint, - last_error, + 'Platega request failed after attempts', + max_retries=self._max_retries, + method=method, + endpoint=endpoint, + last_error=last_error, ) return None @@ -169,11 +166,7 @@ class PlategaService: try: return json.loads(raw_text), raw_text except json.JSONDecodeError as error: - logger.error( - 'Failed to decode Platega JSON response %s: %s', - response.url, - error, - ) + logger.error('Failed to decode Platega JSON response', url=response.url, error=error) return None, raw_text return None, raw_text @@ -190,11 +183,7 @@ class PlategaService: if len(encoded) <= max_bytes: return cleaned - logger.debug( - 'Platega description trimmed from %s to %s bytes', - len(encoded), - max_bytes, - ) + logger.debug('Platega description trimmed from to bytes', encoded_count=len(encoded), max_bytes=max_bytes) trimmed_bytes = encoded[:max_bytes] while True: @@ -213,5 +202,5 @@ class PlategaService: delta = timedelta(hours=hours, minutes=minutes, seconds=seconds) return datetime.utcnow() + delta except Exception: - logger.warning('Failed to parse Platega expiresIn value: %s', expires_in) + logger.warning('Failed to parse Platega expiresIn value', expires_in=expires_in) return None diff --git a/app/services/poll_service.py b/app/services/poll_service.py index c57893d5..7be577f1 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,8 +1,8 @@ import asyncio -import logging from collections.abc import Iterable from types import SimpleNamespace +import structlog from aiogram import Bot from aiogram.exceptions import TelegramBadRequest from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup @@ -22,7 +22,7 @@ from app.database.models import ( from app.localization.texts import get_texts -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _build_poll_invitation_text(poll: Poll, language: str) -> str: @@ -158,18 +158,18 @@ async def send_poll_to_users( # Проверяем, является ли ошибка связанной с лимитом подключений if 'too many clients' in str(error).lower(): logger.warning( - '⚠️ Ограничение на количество подключений к БД: %s пользователю %s', - poll_id, - user_snapshot.telegram_id, + '⚠️ Ограничение на количество подключений к БД: пользователю', + poll_id=poll_id, + telegram_id=user_snapshot.telegram_id, ) # Уменьшаем вероятность переполнения, делая небольшую задержку await asyncio.sleep(0.1) else: logger.error( - '❌ Ошибка отправки опроса %s пользователю %s: %s', - poll_id, - user_snapshot.telegram_id, - error, + '❌ Ошибка отправки опроса пользователю', + poll_id=poll_id, + telegram_id=user_snapshot.telegram_id, + error=error, ) return 'failed' diff --git a/app/services/privacy_policy_service.py b/app/services/privacy_policy_service.py index 8b99171e..1441fea1 100644 --- a/app/services/privacy_policy_service.py +++ b/app/services/privacy_policy_service.py @@ -1,5 +1,4 @@ -import logging - +import structlog from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -11,7 +10,7 @@ from app.database.crud.privacy_policy import ( from app.database.models import PrivacyPolicy -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class PrivacyPolicyService: @@ -88,7 +87,7 @@ class PrivacyPolicyService: content, enable_if_new=enable_if_new, ) - logger.info('✅ Политика конфиденциальности обновлена для языка %s', lang) + logger.info('✅ Политика конфиденциальности обновлена для языка', lang=lang) return policy @classmethod diff --git a/app/services/promo_group_assignment.py b/app/services/promo_group_assignment.py index 43631d9e..d05341cd 100644 --- a/app/services/promo_group_assignment.py +++ b/app/services/promo_group_assignment.py @@ -1,6 +1,6 @@ -import logging from datetime import datetime +import structlog from aiogram import Bot from aiogram.client.default import DefaultBotProperties from sqlalchemy import select @@ -12,7 +12,7 @@ from app.database.models import PromoGroup, User from app.services.admin_notification_service import AdminNotificationService -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def _notify_admins_about_auto_assignment( @@ -49,9 +49,9 @@ async def _notify_admins_about_auto_assignment( ) except Exception as exc: logger.error( - 'Ошибка отправки уведомления о автоназначении промогруппы пользователю %s: %s', - user.telegram_id, - exc, + 'Ошибка отправки уведомления о автоназначении промогруппы пользователю', + telegram_id=user.telegram_id, + exc=exc, ) finally: try: @@ -96,7 +96,7 @@ async def maybe_assign_promo_group_by_total_spent( user = await db.get(User, user_id) if not user: - logger.debug('Не удалось найти пользователя %s для автовыдачи промогруппы', user_id) + logger.debug('Не удалось найти пользователя для автовыдачи промогруппы', user_id=user_id) return None # Получаем текущую primary промогруппу @@ -121,11 +121,11 @@ async def maybe_assign_promo_group_by_total_spent( if target_threshold <= previous_threshold: logger.debug( - "Порог промогруппы '%s' (%s) не превышает ранее назначенный (%s) для пользователя %s", - target_group.name, - target_threshold, - previous_threshold, - user.telegram_id, + "Порог промогруппы '' не превышает ранее назначенный для пользователя", + target_group_name=target_group.name, + target_threshold=target_threshold, + previous_threshold=previous_threshold, + telegram_id=user.telegram_id, ) return None @@ -134,9 +134,9 @@ async def maybe_assign_promo_group_by_total_spent( if user.auto_promo_group_assigned and already_has_group: logger.debug( - "Пользователь %s уже имеет промогруппу '%s', повторная выдача не требуется", - user.telegram_id, - target_group.name, + "Пользователь уже имеет промогруппу '', повторная выдача не требуется", + telegram_id=user.telegram_id, + target_group_name=target_group.name, ) await sync_user_primary_promo_group(db, user_id) if target_threshold > previous_threshold: @@ -154,16 +154,16 @@ async def maybe_assign_promo_group_by_total_spent( # Добавляем новую промогруппу к существующим await add_user_to_promo_group(db, user_id, target_group.id, assigned_by='auto') logger.info( - "🤖 Пользователю %s добавлена промогруппа '%s' за траты %s ₽", - user.telegram_id, - target_group.name, - total_spent / 100, + "🤖 Пользователю добавлена промогруппа '' за траты ₽", + telegram_id=user.telegram_id, + target_group_name=target_group.name, + total_spent=total_spent / 100, ) else: logger.info( - "🤖 Пользователь %s уже имеет промогруппу '%s', отмечаем автоприсвоение", - user.telegram_id, - target_group.name, + "🤖 Пользователь уже имеет промогруппу '', отмечаем автоприсвоение", + telegram_id=user.telegram_id, + target_group_name=target_group.name, ) await db.commit() @@ -180,10 +180,6 @@ async def maybe_assign_promo_group_by_total_spent( return target_group except Exception as exc: - logger.error( - 'Ошибка при автоматическом назначении промогруппы пользователю %s: %s', - user_id, - exc, - ) + logger.error('Ошибка при автоматическом назначении промогруппы пользователю', user_id=user_id, exc=exc) await db.rollback() return None diff --git a/app/services/promo_offer_service.py b/app/services/promo_offer_service.py index 0e81ec7f..f5455da6 100644 --- a/app/services/promo_offer_service.py +++ b/app/services/promo_offer_service.py @@ -1,9 +1,9 @@ from __future__ import annotations -import logging from collections.abc import Sequence from datetime import datetime, timedelta +import structlog from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -18,7 +18,7 @@ from app.database.models import ( from app.services.subscription_service import SubscriptionService -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class PromoOfferService: @@ -120,8 +120,7 @@ class PromoOfferService: await db.rollback() await db.refresh(subscription) logger.error( - 'Не удалось синхронизировать временный доступ подписки %s с RemnaWave', - subscription.id, + 'Не удалось синхронизировать временный доступ подписки с RemnaWave', subscription_id=subscription.id ) return False, None, None, 'remnawave_sync_failed' @@ -192,9 +191,9 @@ class PromoOfferService: await self.subscription_service.update_remnawave_user(db, subscription) except Exception as exc: # pragma: no cover - defensive logging logger.error( - 'Ошибка обновления Remnawave при отзыве тестового доступа подписки %s: %s', - subscription.id, - exc, + 'Ошибка обновления Remnawave при отзыве тестового доступа подписки', + subscription_id=subscription.id, + exc=exc, ) await db.commit() @@ -212,16 +211,16 @@ class PromoOfferService: ) except Exception as exc: # pragma: no cover - defensive logging logger.warning( - 'Failed to record promo offer test access disable log for user %s: %s', - payload.get('user_id'), - exc, + 'Failed to record promo offer test access disable log for user', + payload=payload.get('user_id'), + exc=exc, ) try: await db.rollback() except Exception as rollback_error: # pragma: no cover - defensive logging logger.warning( - 'Failed to rollback session after promo offer test access log failure: %s', - rollback_error, + 'Failed to rollback session after promo offer test access log failure', + rollback_error=rollback_error, ) return len(entries) diff --git a/app/services/promocode_service.py b/app/services/promocode_service.py index fafcd6a3..4a1ff15f 100644 --- a/app/services/promocode_service.py +++ b/app/services/promocode_service.py @@ -1,6 +1,6 @@ -import logging from typing import Any +import structlog from sqlalchemy.ext.asyncio import AsyncSession from app.database.crud.promo_group import get_promo_group_by_id @@ -18,7 +18,7 @@ from app.services.remnawave_service import RemnaWaveService from app.services.subscription_service import SubscriptionService -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class PromoCodeService: @@ -60,9 +60,9 @@ class PromoCodeService: recent_count = await count_user_recent_activations(db, user_id, hours=24) if recent_count >= 5: logger.warning( - 'Promo stacking limit: user %s has %d activations in 24h', - self._format_user_log(user), - recent_count, + 'Promo stacking limit: user has activations in 24h', + format_user_log=self._format_user_log(user), + recent_count=recent_count, ) return {'success': False, 'error': 'daily_limit'} @@ -87,7 +87,9 @@ class PromoCodeService: await mark_user_as_had_paid_subscription(db, user) logger.info( - f'🎯 Пользователь {self._format_user_log(user)} получил платную подписку через промокод {code}' + '🎯 Пользователь получил платную подписку через промокод', + _format_user_log=self._format_user_log(user), + code=code, ) # Assign promo group if promocode has one @@ -107,24 +109,33 @@ class PromoCodeService: ) logger.info( - f"🎯 Пользователю {self._format_user_log(user)} назначена промогруппа '{promo_group.name}' " - f'(приоритет: {promo_group.priority}) через промокод {code}' + '🎯 Пользователю назначена промогруппа (приоритет: ) через промокод', + _format_user_log=self._format_user_log(user), + promo_group_name=promo_group.name, + priority=promo_group.priority, + code=code, ) # Add to result description result_description += f'\n🎁 Назначена промогруппа: {promo_group.name}' else: logger.warning( - f'⚠️ Промогруппа ID {promocode.promo_group_id} не найдена для промокода {code}' + '⚠️ Промогруппа ID не найдена для промокода', + promo_group_id=promocode.promo_group_id, + code=code, ) else: logger.info( - f'ℹ️ Пользователь {self._format_user_log(user)} уже имеет промогруппу ID {promocode.promo_group_id}' + 'ℹ️ Пользователь уже имеет промогруппу ID', + _format_user_log=self._format_user_log(user), + promo_group_id=promocode.promo_group_id, ) except Exception as pg_error: logger.error( - f'❌ Ошибка назначения промогруппы для пользователя {self._format_user_log(user)} ' - f'при активации промокода {code}: {pg_error}' + '❌ Ошибка назначения промогруппы для пользователя при активации промокода', + _format_user_log=self._format_user_log(user), + code=code, + pg_error=pg_error, ) # Don't fail the whole promocode activation if promo group assignment fails @@ -133,7 +144,7 @@ class PromoCodeService: promocode.current_uses += 1 await db.commit() - logger.info(f'✅ Пользователь {self._format_user_log(user)} активировал промокод {code}') + logger.info('✅ Пользователь активировал промокод', _format_user_log=self._format_user_log(user), code=code) promocode_data = { 'code': promocode.code, @@ -155,7 +166,7 @@ class PromoCodeService: } except Exception as e: - logger.error(f'Ошибка активации промокода {code} для пользователя {user_id}: {e}') + logger.error('Ошибка активации промокода для пользователя', code=code, user_id=user_id, error=e) await db.rollback() return {'success': False, 'error': 'server_error'} @@ -188,8 +199,11 @@ class PromoCodeService: if current_discount > 0: if expires_at is None or expires_at > datetime.utcnow(): logger.warning( - f'⚠️ Пользователь {self._format_user_log(user)} попытался активировать промокод {promocode.code}, ' - f'но у него уже есть активная скидка {current_discount}% до {expires_at}' + '⚠️ Пользователь попытался активировать промокод но у него уже есть активная скидка до', + _format_user_log=self._format_user_log(user), + code=promocode.code, + current_discount=current_discount, + expires_at=expires_at, ) raise ValueError('active_discount_exists') @@ -214,8 +228,11 @@ class PromoCodeService: await db.flush() logger.info( - f'✅ Пользователю {self._format_user_log(user)} назначена скидка {discount_percent}% ' - f'(срок: {discount_hours} ч.) по промокоду {promocode.code}' + '✅ Пользователю назначена скидка (срок: ч.) по промокоду', + _format_user_log=self._format_user_log(user), + discount_percent=discount_percent, + discount_hours=discount_hours, + code=promocode.code, ) if promocode.type == PromoCodeType.BALANCE.value and promocode.balance_bonus_kopeks > 0: @@ -236,7 +253,9 @@ class PromoCodeService: effects.append(f'⏰ Подписка продлена на {promocode.subscription_days} дней') logger.info( - f'✅ Подписка пользователя {self._format_user_log(user)} продлена на {promocode.subscription_days} дней в RemnaWave с текущими сквадами' + '✅ Подписка пользователя продлена на дней в RemnaWave с текущими сквадами', + _format_user_log=self._format_user_log(user), + subscription_days=promocode.subscription_days, ) else: @@ -251,9 +270,9 @@ class PromoCodeService: trial_squads = [trial_uuid] except Exception as error: logger.error( - 'Не удалось подобрать сквад для подписки по промокоду %s: %s', - promocode.code, - error, + 'Не удалось подобрать сквад для подписки по промокоду', + promocode_code=promocode.code, + error=error, ) forced_devices = None @@ -278,7 +297,10 @@ class PromoCodeService: effects.append(f'🎉 Получена подписка на {promocode.subscription_days} дней') logger.info( - f'✅ Создана новая подписка для пользователя {self._format_user_log(user)} на {promocode.subscription_days} дней с триал сквадом {trial_squads}' + '✅ Создана новая подписка для пользователя на дней с триал сквадом', + _format_user_log=self._format_user_log(user), + subscription_days=promocode.subscription_days, + trial_squads=trial_squads, ) if promocode.type == PromoCodeType.TRIAL_SUBSCRIPTION.value: @@ -307,7 +329,9 @@ class PromoCodeService: effects.append(f'🎁 Активирована тестовая подписка на {trial_days} дней') logger.info( - f'✅ Создана триал подписка для пользователя {self._format_user_log(user)} на {trial_days} дней' + '✅ Создана триал подписка для пользователя на дней', + _format_user_log=self._format_user_log(user), + trial_days=trial_days, ) else: effects.append('ℹ️ У вас уже есть активная подписка') @@ -390,16 +414,21 @@ class PromoCodeService: if has_group: await remove_user_from_promo_group(db, user_id, promocode.promo_group_id) logger.info( - f'Снята промогруппа ID {promocode.promo_group_id} у пользователя ' - f'{self._format_user_log(user)} при деактивации промокода {deactivated_code}' + 'Снята промогруппа ID у пользователя при деактивации промокода', + promo_group_id=promocode.promo_group_id, + _format_user_log=self._format_user_log(user), + deactivated_code=deactivated_code, ) await db.commit() initiator = 'администратором' if admin_initiated else 'пользователем' logger.info( - f'Промокод {deactivated_code} (скидка {current_discount}%) деактивирован ' - f'{initiator} для пользователя {self._format_user_log(user)}' + 'Промокод (скидка %) деактивирован для пользователя', + deactivated_code=deactivated_code, + current_discount=current_discount, + initiator=initiator, + _format_user_log=self._format_user_log(user), ) return { @@ -409,6 +438,6 @@ class PromoCodeService: } except Exception as e: - logger.error(f'Ошибка деактивации промокода для пользователя {user_id}: {e}') + logger.error('Ошибка деактивации промокода для пользователя', user_id=user_id, error=e) await db.rollback() return {'success': False, 'error': 'server_error'} diff --git a/app/services/public_offer_service.py b/app/services/public_offer_service.py index 94ee9f19..02226087 100644 --- a/app/services/public_offer_service.py +++ b/app/services/public_offer_service.py @@ -1,6 +1,6 @@ -import logging from html.parser import HTMLParser +import structlog from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -12,7 +12,7 @@ from app.database.crud.public_offer import ( from app.database.models import PublicOffer -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class PublicOfferService: @@ -93,7 +93,7 @@ class PublicOfferService: content, enable_if_new=enable_if_new, ) - logger.info('✅ Публичная оферта обновлена для языка %s', lang) + logger.info('✅ Публичная оферта обновлена для языка', lang=lang) return offer @classmethod diff --git a/app/services/referral_contest_service.py b/app/services/referral_contest_service.py index c39d2354..2a315652 100644 --- a/app/services/referral_contest_service.py +++ b/app/services/referral_contest_service.py @@ -1,9 +1,9 @@ import asyncio -import logging from collections.abc import Sequence from datetime import UTC, date, datetime, time, timedelta from zoneinfo import ZoneInfo +import structlog from aiogram import Bot from aiogram.exceptions import TelegramForbiddenError, TelegramNotFound from sqlalchemy.ext.asyncio import AsyncSession @@ -25,7 +25,7 @@ from app.database.database import AsyncSessionLocal from app.database.models import ReferralContest, User -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class ReferralContestService: @@ -71,7 +71,7 @@ class ReferralContestService: except asyncio.CancelledError: raise except Exception as exc: - logger.error('Ошибка сервиса конкурсов: %s', exc) + logger.error('Ошибка сервиса конкурсов', exc=exc) await asyncio.sleep(self._poll_interval_seconds) except asyncio.CancelledError: @@ -93,12 +93,7 @@ class ReferralContestService: except asyncio.CancelledError: raise except Exception as exc: - logger.error( - 'Ошибка обработки конкурса %s (%s): %s', - contest.id, - contest.title, - exc, - ) + logger.error('Ошибка обработки конкурса', contest_id=contest.id, title=contest.title, exc=exc) async def _maybe_send_daily_summary( self, @@ -204,7 +199,7 @@ class ReferralContestService: ) if not leaderboard: - logger.info('Конкурс %s: пока нет участников', contest.id) + logger.info('Конкурс : пока нет участников', contest_id=contest.id) if is_final: await mark_final_summary_sent(db, contest) @@ -255,18 +250,17 @@ class ReferralContestService: # Skip email-only users (no telegram_id) if not user.telegram_id: - logger.debug(f'Skipping contest notification for email-only user {user.id}') + logger.debug('Skipping contest notification for email-only user', user_id=user.id) continue try: await self.bot.send_message(user.telegram_id, text, disable_web_page_preview=True) except (TelegramForbiddenError, TelegramNotFound): logger.info( - 'Не удалось отправить сообщение участнику %s (вероятно, блокировка)', - user.telegram_id, + 'Не удалось отправить сообщение участнику (вероятно, блокировка)', telegram_id=user.telegram_id ) except Exception as exc: - logger.error('Ошибка отправки участнику конкурса %s: %s', user.telegram_id, exc) + logger.error('Ошибка отправки участнику конкурса', telegram_id=user.telegram_id, exc=exc) async def _notify_admins( self, @@ -314,7 +308,7 @@ class ReferralContestService: message_thread_id=settings.ADMIN_NOTIFICATIONS_TOPIC_ID, ) except Exception as exc: - logger.error('Не удалось отправить админскую сводку конкурса: %s', exc) + logger.error('Не удалось отправить админскую сводку конкурса', exc=exc) async def _notify_public_channel( self, @@ -364,9 +358,9 @@ class ReferralContestService: disable_web_page_preview=True, ) except (TelegramForbiddenError, TelegramNotFound): - logger.info('Не удалось отправить сводку конкурса в канал %s', channel_id_raw) + logger.info('Не удалось отправить сводку конкурса в канал', channel_id_raw=channel_id_raw) except Exception as exc: - logger.error('Ошибка отправки сводки конкурса в канал %s: %s', channel_id_raw, exc) + logger.error('Ошибка отправки сводки конкурса в канал', channel_id_raw=channel_id_raw, exc=exc) def _build_participant_message( self, @@ -483,7 +477,7 @@ class ReferralContestService: try: return ZoneInfo(tz_name) except Exception: - logger.warning('Не удалось загрузить TZ %s, используем UTC', tz_name) + logger.warning('Не удалось загрузить TZ , используем UTC', tz_name=tz_name) return ZoneInfo('UTC') def _parse_times(self, times_str: str | None) -> list[time]: @@ -549,12 +543,12 @@ class ReferralContestService: if user_created_at < contest_start or user_created_at > contest_end: logger.debug( - 'Реферал %s зарегистрирован вне периода конкурса %s (создан %s, период %s - %s)', - user.id, - contest.id, - user_created_at, - contest_start, - contest_end, + 'Реферал зарегистрирован вне периода конкурса (создан , период -)', + user_id=user.id, + contest_id=contest.id, + user_created_at=user_created_at, + contest_start=contest_start, + contest_end=contest_end, ) continue @@ -568,13 +562,13 @@ class ReferralContestService: ) if event: logger.info( - 'Записан зачёт конкурса %s: реферер %s, реферал %s', - contest.id, - user.referred_by_id, - user.id, + 'Записан зачёт конкурса : реферер , реферал', + contest_id=contest.id, + referred_by_id=user.referred_by_id, + user_id=user.id, ) except Exception as exc: - logger.error('Не удалось записать зачёт конкурса %s: %s', contest.id, exc) + logger.error('Не удалось записать зачёт конкурса', contest_id=contest.id, exc=exc) async def on_referral_registration( self, @@ -610,12 +604,12 @@ class ReferralContestService: if user_created_at < contest_start or user_created_at > contest_end: logger.debug( - 'Реферал %s зарегистрирован вне периода конкурса %s (создан %s, период %s - %s)', - user.id, - contest.id, - user_created_at, - contest_start, - contest_end, + 'Реферал зарегистрирован вне периода конкурса (создан , период -)', + user_id=user.id, + contest_id=contest.id, + user_created_at=user_created_at, + contest_start=contest_start, + contest_end=contest_end, ) continue @@ -629,13 +623,13 @@ class ReferralContestService: ) if event: logger.info( - 'Записан зачёт конкурса регистрации %s: реферер %s, реферал %s', - contest.id, - user.referred_by_id, - user.id, + 'Записан зачёт конкурса регистрации : реферер , реферал', + contest_id=contest.id, + referred_by_id=user.referred_by_id, + user_id=user.id, ) except Exception as exc: - logger.error('Не удалось записать зачёт регистрации для конкурса %s: %s', contest.id, exc) + logger.error('Не удалось записать зачёт регистрации для конкурса', contest_id=contest.id, exc=exc) async def sync_contest( self, @@ -653,15 +647,15 @@ class ReferralContestService: stats = await sync_contest_events(db, contest_id) if 'error' not in stats: logger.info( - 'Синхронизация конкурса %s: создано %s, обновлено %s, пропущено %s', - contest_id, - stats.get('created', 0), - stats.get('updated', 0), - stats.get('skipped', 0), + 'Синхронизация конкурса : создано , обновлено , пропущено', + contest_id=contest_id, + stats=stats.get('created', 0), + stats_2=stats.get('updated', 0), + stats_3=stats.get('skipped', 0), ) return stats except Exception as exc: - logger.error('Ошибка синхронизации конкурса %s: %s', contest_id, exc) + logger.error('Ошибка синхронизации конкурса', contest_id=contest_id, exc=exc) return {'error': str(exc)} async def cleanup_contest( @@ -680,14 +674,14 @@ class ReferralContestService: stats = await cleanup_invalid_contest_events(db, contest_id) if 'error' not in stats: logger.info( - 'Очистка конкурса %s: удалено %s невалидных событий, осталось %s', - contest_id, - stats.get('deleted', 0), - stats.get('remaining', 0), + 'Очистка конкурса : удалено невалидных событий, осталось', + contest_id=contest_id, + stats=stats.get('deleted', 0), + stats_2=stats.get('remaining', 0), ) return stats except Exception as exc: - logger.error('Ошибка очистки конкурса %s: %s', contest_id, exc) + logger.error('Ошибка очистки конкурса', contest_id=contest_id, exc=exc) return {'error': str(exc)} diff --git a/app/services/referral_diagnostics_service.py b/app/services/referral_diagnostics_service.py index d7320f1f..31e540f9 100644 --- a/app/services/referral_diagnostics_service.py +++ b/app/services/referral_diagnostics_service.py @@ -7,13 +7,13 @@ - Выявление потерянных рефералов """ -import logging import re from dataclasses import dataclass, field from datetime import datetime, timedelta from pathlib import Path from typing import Optional +import structlog from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -23,7 +23,7 @@ from app.database.crud.user import add_user_balance from app.database.models import ReferralEarning, User -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @dataclass @@ -314,13 +314,13 @@ class ReferralDiagnosticsService: mtime = datetime.fromtimestamp(path.stat().st_mtime).date() is_fresh = mtime >= today - timedelta(days=1) candidates.append((path, is_fresh, path.stat().st_mtime)) - logger.info(f'📁 Найден лог: {path} (свежий: {is_fresh})') + logger.info('📁 Найден лог: (свежий: )', path=path, is_fresh=is_fresh) candidates.sort(key=lambda x: (not x[1], -x[2])) if candidates: selected = candidates[0][0] - logger.info(f'✅ Выбран лог-файл: {selected}') + logger.info('✅ Выбран лог-файл', selected=selected) return selected return Path('logs/current/bot.log') @@ -378,7 +378,7 @@ class ReferralDiagnosticsService: Returns: DiagnosticReport с результатами анализа всего файла """ - logger.info(f'📂 Начинаю анализ файла: {file_path}') + logger.info('📂 Начинаю анализ файла', file_path=file_path) # Парсим весь файл без фильтра по дате # Используем широкий диапазон дат (все время) @@ -402,8 +402,10 @@ class ReferralDiagnosticsService: lost_referrals = await self._find_lost_referrals(db, list(user_clicks.values())) logger.info( - f'✅ Анализ файла завершён: строк={total_lines}, ' - f'реф-кликов={len(clicks)}, потерянных={len(lost_referrals)}' + '✅ Анализ файла завершён: строк=, реф-кликов=, потерянных', + total_lines=total_lines, + clicks_count=len(clicks), + lost_referrals_count=len(lost_referrals), ) return DiagnosticReport( @@ -429,11 +431,11 @@ class ReferralDiagnosticsService: lines_in_period = 0 if not self.log_path.exists(): - logger.warning(f'❌ Лог-файл не найден: {self.log_path}') + logger.warning('❌ Лог-файл не найден', log_path=self.log_path) return clicks, 0, 0 file_size = self.log_path.stat().st_size - logger.info(f'📂 Читаю лог-файл: {self.log_path} ({file_size / 1024 / 1024:.2f} MB)') + logger.info('📂 Читаю лог-файл: ( MB)', log_path=self.log_path, file_size=round(file_size / 1024 / 1024, 2)) # Паттерн timestamp timestamp_pattern = re.compile(r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+ - .+ - .+ - (.+)$') @@ -505,9 +507,14 @@ class ReferralDiagnosticsService: break except Exception as e: - logger.error(f'Ошибка парсинга логов: {e}', exc_info=True) + logger.error('Ошибка парсинга логов', error=e, exc_info=True) - logger.info(f'📊 Парсинг: строк={total_lines}, за период={lines_in_period}, реф-кликов={len(clicks)}') + logger.info( + '📊 Парсинг: строк=, за период=, реф-кликов', + total_lines=total_lines, + lines_in_period=lines_in_period, + clicks_count=len(clicks), + ) return clicks, total_lines, lines_in_period async def _find_lost_referrals(self, db: AsyncSession, clicks: list[ReferralClick]) -> list[LostReferral]: @@ -542,7 +549,12 @@ class ReferralDiagnosticsService: # Пользователь был создан ДО клика по реф-ссылке # Это старый пользователь, который просто зашёл по чужой ссылке is_lost = False - logger.debug(f'⏭️ Пропускаем {click.telegram_id}: создан {user.created_at} < клик {click.timestamp}') + logger.debug( + '⏭️ Пропускаем создан < клик', + telegram_id=click.telegram_id, + created_at=user.created_at, + timestamp=click.timestamp, + ) elif user.referred_by_id is None: # Пользователь в БД, но без реферера (и создан после клика) is_lost = True @@ -567,7 +579,7 @@ class ReferralDiagnosticsService: ) ) - logger.info(f'🔍 Найдено потерянных рефералов: {len(lost)}') + logger.info('🔍 Найдено потерянных рефералов', lost_count=len(lost)) return lost async def _add_to_active_contests( @@ -610,7 +622,9 @@ class ReferralDiagnosticsService: contest_end = contest.end_at if contest.end_at.tzinfo is None else contest.end_at.replace(tzinfo=None) if user_created_at < contest_start or user_created_at > contest_end: - logger.debug(f'Реферал {referral.id} зарегистрирован вне периода конкурса {contest.id}') + logger.debug( + 'Реферал зарегистрирован вне периода конкурса', referral_id=referral.id, contest_id=contest.id + ) continue event = await add_contest_event( @@ -623,11 +637,13 @@ class ReferralDiagnosticsService: ) if event: logger.info( - f'🏆 Восстановленный реферал добавлен в конкурс {contest.id}: ' - f'реферер {referrer.id}, реферал {referral.id}' + '🏆 Восстановленный реферал добавлен в конкурс реферер реферал', + contest_id=contest.id, + referrer_id=referrer.id, + referral_id=referral.id, ) except Exception as exc: - logger.error(f'Не удалось добавить в конкурс {contest.id}: {exc}') + logger.error('Не удалось добавить в конкурс', contest_id=contest.id, error=exc) # Также проверяем конкурсы по регистрации (если есть) reg_contests = await get_contests_for_events(db, now_utc, contest_types=['referral_registered']) @@ -656,9 +672,9 @@ class ReferralDiagnosticsService: event_type='restored_referral_registration', ) if event: - logger.info(f'🏆 Восстановленный реферал (регистрация) добавлен в конкурс {contest.id}') + logger.info('🏆 Восстановленный реферал (регистрация) добавлен в конкурс', contest_id=contest.id) except Exception as exc: - logger.error(f'Не удалось добавить в конкурс регистрации {contest.id}: {exc}') + logger.error('Не удалось добавить в конкурс регистрации', contest_id=contest.id, error=exc) async def fix_lost_referrals( self, db: AsyncSession, lost_referrals: list[LostReferral], apply: bool = False @@ -718,7 +734,11 @@ class ReferralDiagnosticsService: if user.referred_by_id != referrer.id: if apply: user.referred_by_id = referrer.id - logger.info(f'✅ Установлен referred_by_id={referrer.id} для пользователя {user.telegram_id}') + logger.info( + '✅ Установлен referred_by_id= для пользователя', + referrer_id=referrer.id, + telegram_id=user.telegram_id, + ) detail.referred_by_set = True report.users_fixed += 1 @@ -769,8 +789,9 @@ class ReferralDiagnosticsService: ) user.has_made_first_topup = True logger.info( - f'💰 Начислен бонус рефералу {user.telegram_id}: ' - f'{settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100}₽' + '💰 Начислен бонус рефералу ₽', + telegram_id=user.telegram_id, + REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100, ) # 4. Начисляем бонус рефереру @@ -804,8 +825,9 @@ class ReferralDiagnosticsService: ) logger.info( - f'💰 Начислен бонус рефереру {referrer.telegram_id or referrer.id}: ' - f'{inviter_bonus / 100}₽' + '💰 Начислен бонус рефереру ₽', + telegram_id=referrer.telegram_id or referrer.id, + inviter_bonus=inviter_bonus / 100, ) # Добавляем в активные конкурсы рефералов @@ -816,7 +838,7 @@ class ReferralDiagnosticsService: report.details.append(detail) except Exception as e: - logger.error(f'❌ Ошибка исправления реферала {lost.telegram_id}: {e}', exc_info=True) + logger.error('❌ Ошибка исправления реферала', telegram_id=lost.telegram_id, error=e, exc_info=True) detail.error = str(e) report.errors += 1 report.details.append(detail) @@ -824,11 +846,13 @@ class ReferralDiagnosticsService: if apply: await db.commit() logger.info( - f'✅ Исправлено рефералов: {report.users_fixed}, ' - f'начислено бонусов: {report.bonuses_to_referrals / 100}₽ + {report.bonuses_to_referrers / 100}₽' + '✅ Исправлено рефералов: начислено бонусов: ₽ + ₽', + users_fixed=report.users_fixed, + bonuses_to_referrals=report.bonuses_to_referrals / 100, + bonuses_to_referrers=report.bonuses_to_referrers / 100, ) else: - logger.info(f'📋 Предпросмотр: {report.users_fixed} рефералов будут исправлены') + logger.info('📋 Предпросмотр: рефералов будут исправлены', users_fixed=report.users_fixed) return report @@ -932,9 +956,10 @@ class ReferralDiagnosticsService: report.total_missing_to_referrers += missing.referrer_bonus_amount logger.info( - f'📊 Проверка бонусов: {report.total_referrals_checked} рефералов, ' - f'{report.referrals_with_topup} с пополнением, ' - f'{len(report.missing_bonuses)} без бонусов' + '📊 Проверка бонусов: рефералов, с пополнением, без бонусов', + total_referrals_checked=report.total_referrals_checked, + referrals_with_topup=report.referrals_with_topup, + missing_bonuses_count=len(report.missing_bonuses), ) return report @@ -1007,7 +1032,9 @@ class ReferralDiagnosticsService: ) referral.has_made_first_topup = True logger.info( - f'💰 Начислен бонус рефералу {referral.telegram_id}: {missing.referral_bonus_amount / 100}₽' + '💰 Начислен бонус рефералу ₽', + telegram_id=referral.telegram_id, + referral_bonus_amount=missing.referral_bonus_amount / 100, ) # Начисляем бонус рефереру @@ -1034,7 +1061,9 @@ class ReferralDiagnosticsService: reason='referral_first_topup', ) logger.info( - f'💰 Начислен бонус рефереру {referrer.telegram_id}: {missing.referrer_bonus_amount / 100}₽' + '💰 Начислен бонус рефереру ₽', + telegram_id=referrer.telegram_id, + referrer_bonus_amount=missing.referrer_bonus_amount / 100, ) # Добавляем в активные конкурсы рефералов @@ -1044,7 +1073,7 @@ class ReferralDiagnosticsService: report.details.append(detail) except Exception as e: - logger.error(f'❌ Ошибка начисления бонуса: {e}', exc_info=True) + logger.error('❌ Ошибка начисления бонуса', error=e, exc_info=True) detail.error = str(e) report.errors += 1 report.details.append(detail) @@ -1052,8 +1081,9 @@ class ReferralDiagnosticsService: if apply: await db.commit() logger.info( - f'✅ Начислено бонусов: {report.bonuses_to_referrals / 100}₽ рефералам + ' - f'{report.bonuses_to_referrers / 100}₽ рефереерам' + '✅ Начислено бонусов: ₽ рефералам + ₽ рефереерам', + bonuses_to_referrals=report.bonuses_to_referrals / 100, + bonuses_to_referrers=report.bonuses_to_referrers / 100, ) return report diff --git a/app/services/referral_service.py b/app/services/referral_service.py index 8fbcaf81..78710a1b 100644 --- a/app/services/referral_service.py +++ b/app/services/referral_service.py @@ -1,5 +1,4 @@ -import logging - +import structlog from aiogram import Bot from sqlalchemy import delete from sqlalchemy.ext.asyncio import AsyncSession @@ -14,7 +13,7 @@ from app.services.notification_delivery_service import ( from app.utils.user_utils import get_effective_referral_commission_percent -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def send_referral_notification( @@ -46,18 +45,18 @@ async def send_referral_notification( telegram_message=message, ) if success: - logger.info(f'✅ Email уведомление о реферале отправлено пользователю {user.id}') + logger.info('✅ Email уведомление о реферале отправлено пользователю', user_id=user.id) else: - logger.warning(f'⚠️ Не удалось отправить email уведомление пользователю {user.id}') + logger.warning('⚠️ Не удалось отправить email уведомление пользователю', user_id=user.id) else: logger.debug('Пропуск уведомления: пользователь без telegram_id и без User object') return try: await bot.send_message(telegram_id, message, parse_mode='HTML') - logger.info(f'✅ Уведомление отправлено пользователю {telegram_id}') + logger.info('✅ Уведомление отправлено пользователю', telegram_id=telegram_id) except Exception as e: - logger.error(f'❌ Ошибка отправки уведомления пользователю {telegram_id}: {e}') + logger.error('❌ Ошибка отправки уведомления пользователю', telegram_id=telegram_id, error=e) async def process_referral_registration(db: AsyncSession, new_user_id: int, referrer_id: int, bot: Bot = None): @@ -66,11 +65,13 @@ async def process_referral_registration(db: AsyncSession, new_user_id: int, refe referrer = await get_user_by_id(db, referrer_id) if not new_user or not referrer: - logger.error(f'Пользователи не найдены: new_user_id={new_user_id}, referrer_id={referrer_id}') + logger.error( + 'Пользователи не найдены: new_user_id=, referrer_id', new_user_id=new_user_id, referrer_id=referrer_id + ) return False if new_user.referred_by_id != referrer_id: - logger.error(f'Пользователь {new_user_id} не привязан к рефереру {referrer_id}') + logger.error('Пользователь не привязан к рефереру', new_user_id=new_user_id, referrer_id=referrer_id) return False await create_referral_earning( @@ -82,7 +83,7 @@ async def process_referral_registration(db: AsyncSession, new_user_id: int, refe await referral_contest_service.on_referral_registration(db, new_user_id) except Exception as exc: - logger.debug('Не удалось записать конкурсную регистрацию: %s', exc) + logger.debug('Не удалось записать конкурсную регистрацию', exc=exc) if bot: commission_percent = get_effective_referral_commission_percent(referrer) @@ -108,12 +109,14 @@ async def process_referral_registration(db: AsyncSession, new_user_id: int, refe ) logger.info( - f'✅ Зарегистрирован реферал {new_user_id} для {referrer_id}. Бонусы будут выданы после пополнения.' + '✅ Зарегистрирован реферал для . Бонусы будут выданы после пополнения.', + new_user_id=new_user_id, + referrer_id=referrer_id, ) return True except Exception as e: - logger.error(f'Ошибка обработки реферальной регистрации: {e}') + logger.error('Ошибка обработки реферальной регистрации', error=e) return False @@ -121,12 +124,12 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko try: user = await get_user_by_id(db, user_id) if not user or not user.referred_by_id: - logger.info(f'Пользователь {user_id} не является рефералом') + logger.info('Пользователь не является рефералом', user_id=user_id) return True referrer = await get_user_by_id(db, user.referred_by_id) if not referrer: - logger.error(f'Реферер {user.referred_by_id} не найден') + logger.error('Реферер не найден', referred_by_id=user.referred_by_id) return False commission_percent = get_effective_referral_commission_percent(referrer) @@ -138,9 +141,9 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko if not user.has_made_first_topup: if not qualifies_for_first_bonus: logger.info( - 'Пополнение %s на %s₽ меньше минимума для первого бонуса, но комиссия будет начислена', - user_id, - topup_amount_kopeks / 100, + 'Пополнение на ₽ меньше минимума для первого бонуса, но комиссия будет начислена', + user_id=user_id, + topup_amount_kopeks=topup_amount_kopeks / 100, ) if commission_amount > 0: @@ -161,9 +164,9 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko ) logger.info( - '💰 Комиссия с пополнения: %s получил %s₽ (до первого бонуса)', - referrer.telegram_id, - commission_amount / 100, + '💰 Комиссия с пополнения: получил ₽ (до первого бонуса)', + telegram_id=referrer.telegram_id, + commission_amount=commission_amount / 100, ) if bot: @@ -198,9 +201,9 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko ) ) await db.commit() - logger.info(f"🗑️ Удалена запись 'ожидание пополнения' для реферала {user.id}") + logger.info("🗑️ Удалена запись 'ожидание пополнения' для реферала", user_id=user.id) except Exception as e: - logger.error(f'Ошибка удаления записи ожидания: {e}') + logger.error('Ошибка удаления записи ожидания', error=e) if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0: await add_user_balance( @@ -210,7 +213,11 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko 'Бонус за первое пополнение по реферальной программе', bot=bot, ) - logger.info(f'💰 Реферал {user.id} получил бонус {settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100}₽') + logger.info( + '💰 Реферал получил бонус ₽', + user_id=user.id, + REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100, + ) if bot: bonus_notification = ( @@ -243,7 +250,7 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko reason='referral_first_topup', ) referrer_id = referrer.telegram_id or referrer.email or f'user#{referrer.id}' - logger.info(f'💰 Реферер {referrer_id} получил бонус {inviter_bonus / 100}₽') + logger.info('💰 Реферер получил бонус ₽', referrer_id=referrer_id, inviter_bonus=inviter_bonus / 100) if bot: inviter_bonus_notification = ( @@ -279,7 +286,11 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko ) referrer_id = referrer.telegram_id or referrer.email or f'user#{referrer.id}' - logger.info(f'💰 Комиссия с пополнения: {referrer_id} получил {commission_amount / 100}₽') + logger.info( + '💰 Комиссия с пополнения: получил ₽', + referrer_id=referrer_id, + commission_amount=commission_amount / 100, + ) if bot: commission_notification = ( @@ -302,7 +313,7 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko return True except Exception as e: - logger.error(f'Ошибка обработки пополнения реферала: {e}') + logger.error('Ошибка обработки пополнения реферала', error=e) return False @@ -316,7 +327,7 @@ async def process_referral_purchase( referrer = await get_user_by_id(db, user.referred_by_id) if not referrer: - logger.error(f'Реферер {user.referred_by_id} не найден') + logger.error('Реферер не найден', referred_by_id=user.referred_by_id) return False commission_percent = get_effective_referral_commission_percent(referrer) @@ -338,7 +349,9 @@ async def process_referral_purchase( ) referrer_id = referrer.telegram_id or referrer.email or f'user#{referrer.id}' - logger.info(f'💰 Комиссия с покупки: {referrer_id} получил {commission_amount / 100}₽') + logger.info( + '💰 Комиссия с покупки: получил ₽', referrer_id=referrer_id, commission_amount=commission_amount / 100 + ) if bot: purchase_commission_notification = ( @@ -361,13 +374,13 @@ async def process_referral_purchase( if not user.has_had_paid_subscription: user.has_had_paid_subscription = True await db.commit() - logger.info(f'✅ Пользователь {user_id} отмечен как имевший платную подписку') + logger.info('✅ Пользователь отмечен как имевший платную подписку', user_id=user_id) return True except Exception as e: - logger.error(f'Ошибка обработки покупки реферала: {e}') + logger.error('Ошибка обработки покупки реферала', error=e) import traceback - logger.error(f'Полный traceback: {traceback.format_exc()}') + logger.error('Полный traceback', format_exc=traceback.format_exc()) return False diff --git a/app/services/referral_withdrawal_service.py b/app/services/referral_withdrawal_service.py index eb7aebc8..9e2d9fc3 100644 --- a/app/services/referral_withdrawal_service.py +++ b/app/services/referral_withdrawal_service.py @@ -4,9 +4,9 @@ """ import json -import logging from datetime import datetime, timedelta +import structlog from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -20,7 +20,7 @@ from app.database.models import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class ReferralWithdrawalService: diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index e93eb3f4..460ac7f7 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -1,5 +1,4 @@ import asyncio -import logging import re from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import asdict, is_dataclass @@ -7,6 +6,7 @@ from datetime import datetime, timedelta from typing import Any, Optional from zoneinfo import ZoneInfo +import structlog from sqlalchemy import String, and_, cast, delete, func, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -40,7 +40,7 @@ from app.utils.subscription_utils import ( from app.utils.timezone import get_local_timezone -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _get_user_traffic_bytes(panel_user: dict[str, Any]) -> int: @@ -198,10 +198,10 @@ class RemnaWaveService: conflicting_user = uuid_map.get(panel_uuid) if conflicting_user and conflicting_user is not user: logger.warning( - '♻️ Обнаружен конфликт UUID %s между пользователями %s и %s. Сбрасываем у старой записи.', - panel_uuid, - getattr(conflicting_user, 'telegram_id', '?'), - getattr(user, 'telegram_id', '?'), + '♻️ Обнаружен конфликт UUID между пользователями и . Сбрасываем у старой записи.', + panel_uuid=panel_uuid, + getattr=getattr(conflicting_user, 'telegram_id', '?'), + getattr_2=getattr(user, 'telegram_id', '?'), ) mutation.set_user_uuid(conflicting_user, None) mutation.set_user_updated_at(conflicting_user, datetime.utcnow()) @@ -215,10 +215,10 @@ class RemnaWaveService: mutation.set_map_entry(panel_uuid, user) logger.info( - '🔁 Обновлен RemnaWave UUID пользователя %s: %s → %s', - getattr(user, 'telegram_id', '?'), - current_uuid, - panel_uuid, + '🔁 Обновлен RemnaWave UUID пользователя : →', + getattr=getattr(user, 'telegram_id', '?'), + current_uuid=current_uuid, + panel_uuid=panel_uuid, ) if mutation.has_changes(): @@ -274,11 +274,11 @@ class RemnaWaveService: else: utc_normalized = parsed_date - logger.debug(f'Успешно распарсена дата: {date_str} -> {utc_normalized} (UTC)') + logger.debug('Успешно распарсена дата: (UTC)', date_str=date_str, utc_normalized=utc_normalized) return utc_normalized except Exception as e: - logger.warning(f"⚠️ Не удалось распарсить дату '{date_str}': {e}. Используем дефолтную дату.") + logger.warning('⚠️ Не удалось распарсить дату . Используем дефолтную дату.', date_str=date_str, error=e) return self._now_utc() + timedelta(days=30) def _safe_expire_at_for_panel(self, expire_at: datetime | None) -> datetime: @@ -299,9 +299,9 @@ class RemnaWaveService: if normalized_expire < minimum_expire: logger.debug( - '⚙️ Коррекция даты истечения (%s) до минимально допустимой (%s) для панели', - normalized_expire, - minimum_expire, + '⚙️ Коррекция даты истечения до минимально допустимой для панели', + normalized_expire=normalized_expire, + minimum_expire=minimum_expire, ) result = minimum_expire else: @@ -381,7 +381,7 @@ class RemnaWaveService: Returns: Tuple[first_name, last_name, username] - извлеченные данные """ - logger.debug(f"📥 Парсинг описания пользователя: '{description}'") + logger.debug('📥 Парсинг описания пользователя', description=description) if not description: logger.debug('❌ Пустое описание пользователя') @@ -402,7 +402,7 @@ class RemnaWaveService: match = re.search(pattern, description) if match: user_info = match.group(1).strip() - logger.debug(f"🔍 Найдена информация о пользователе: '{user_info}'") + logger.debug('🔍 Найдена информация о пользователе', user_info=user_info) break if not user_info: @@ -419,29 +419,32 @@ class RemnaWaveService: # Убираем username из основной информации name_part = user_info[: username_match.start()].strip() logger.debug( - f"📱 Найден username: '{username_with_at}' (обработанный: '{username}'), остаток: '{name_part}'" + '📱 Найден username: (обработанный: ), остаток', + username_with_at=username_with_at, + username=username, + name_part=name_part, ) else: username = None name_part = user_info - logger.debug(f"📱 Username не найден, имя: '{name_part}'") + logger.debug('📱 Username не найден, имя', name_part=name_part) # Разделяем имя и фамилию if name_part and not name_part.startswith('@'): # Если есть имя (не начинается с @), используем его name_parts = name_part.split() - logger.debug(f'🔤 Части имени: {name_parts}') + logger.debug('🔤 Части имени', name_parts=name_parts) if len(name_parts) >= 2: # Первое слово - имя, остальные - фамилия first_name = name_parts[0] last_name = ' '.join(name_parts[1:]) if len(name_parts) > 1 else None - logger.debug(f"👤 Имя: '{first_name}', Фамилия: '{last_name}'") + logger.debug('👤 Имя: Фамилия', first_name=first_name, last_name=last_name) elif len(name_parts) == 1 and not name_parts[0].startswith('@'): # Только имя first_name = name_parts[0] last_name = None - logger.debug(f"👤 Только имя: '{first_name}'") + logger.debug('👤 Только имя', first_name=first_name) else: first_name = None last_name = None @@ -452,7 +455,10 @@ class RemnaWaveService: logger.debug('👤 Имя не определено (начинается с @)') logger.debug( - f"✅ Результат парсинга: first_name='{first_name}', last_name='{last_name}', username='{username}'" + '✅ Результат парсинга: first_name=, last_name=, username', + first_name=first_name, + last_name=last_name, + username=username, ) return first_name, last_name, username @@ -502,8 +508,7 @@ class RemnaWaveService: return db_user, True except IntegrityError as create_error: logger.info( - '♻️ Пользователь с telegram_id %s уже существует. Используем существующую запись.', - telegram_id, + '♻️ Пользователь с telegram_id уже существует. Используем существующую запись.', telegram_id=telegram_id ) try: @@ -515,20 +520,26 @@ class RemnaWaveService: try: existing_user = await get_user_by_telegram_id(db, telegram_id) if existing_user is None: - logger.error('❌ Не удалось найти существующего пользователя с telegram_id %s', telegram_id) + logger.error( + '❌ Не удалось найти существующего пользователя с telegram_id', telegram_id=telegram_id + ) return None, False logger.debug( - 'Используется существующий пользователь %s после конфликта уникальности: %s', - telegram_id, - create_error, + 'Используется существующий пользователь после конфликта уникальности', + telegram_id=telegram_id, + create_error=create_error, ) return existing_user, False except Exception as load_error: - logger.error('❌ Ошибка загрузки существующего пользователя %s: %s', telegram_id, load_error) + logger.error( + '❌ Ошибка загрузки существующего пользователя', telegram_id=telegram_id, load_error=load_error + ) return None, False except Exception as general_error: - logger.error('❌ Общая ошибка создания/загрузки пользователя %s: %s', telegram_id, general_error) + logger.error( + '❌ Общая ошибка создания/загрузки пользователя', telegram_id=telegram_id, general_error=general_error + ) try: await db.rollback() except: @@ -544,27 +555,27 @@ class RemnaWaveService: system_stats = await api.get_system_stats() logger.info('Системная статистика получена') except Exception as e: - logger.error(f'Ошибка получения системной статистики: {e}') + logger.error('Ошибка получения системной статистики', error=e) system_stats = {} try: bandwidth_stats = await api.get_bandwidth_stats() logger.info('Статистика трафика получена') except Exception as e: - logger.error(f'Ошибка получения статистики трафика: {e}') + logger.error('Ошибка получения статистики трафика', error=e) bandwidth_stats = {} try: realtime_usage = await api.get_nodes_realtime_usage() logger.info('Реалтайм статистика получена') except Exception as e: - logger.error(f'Ошибка получения реалтайм статистики: {e}') + logger.error('Ошибка получения реалтайм статистики', error=e) realtime_usage = [] try: nodes_stats = await api.get_nodes_statistics() except Exception as e: - logger.error(f'Ошибка получения статистики нод: {e}') + logger.error('Ошибка получения статистики нод', error=e) nodes_stats = {} total_download = sum(node.get('downloadBytes', 0) for node in realtime_usage) @@ -593,7 +604,7 @@ class RemnaWaveService: try: uptime_seconds = int(float(uptime_value)) if uptime_value is not None else 0 except (TypeError, ValueError): - logger.warning(f"Не удалось преобразовать uptime '{uptime_value}' в число, используем 0") + logger.warning('Не удалось преобразовать uptime в число, используем 0', uptime_value=uptime_value) result = { 'system': { @@ -674,15 +685,17 @@ class RemnaWaveService: } logger.info( - f'Статистика сформирована: пользователи={result["system"]["total_users"]}, общий трафик={total_user_traffic}' + 'Статистика сформирована: пользователи=, общий трафик', + result=result['system']['total_users'], + total_user_traffic=total_user_traffic, ) return result except RemnaWaveAPIError as e: - logger.error(f'Ошибка Remnawave API при получении статистики: {e}') + logger.error('Ошибка Remnawave API при получении статистики', error=e) return {'error': str(e)} except Exception as e: - logger.error(f'Общая ошибка получения системной статистики: {e}') + logger.error('Общая ошибка получения системной статистики', error=e) return {'error': f'Внутренняя ошибка сервера: {e!s}'} def _parse_bandwidth_string(self, bandwidth_str: str) -> int: @@ -718,15 +731,15 @@ class RemnaWaveService: if unit in units: result = int(value * units[unit]) - logger.debug(f"Парсинг '{bandwidth_str}': {value} {unit} = {result} байт") + logger.debug('Парсинг = байт', bandwidth_str=bandwidth_str, value=value, unit=unit, result=result) return result - logger.warning(f'Неизвестная единица измерения: {unit}') + logger.warning('Неизвестная единица измерения', unit=unit) - logger.warning(f"Не удалось распарсить строку трафика: '{bandwidth_str}'") + logger.warning('Не удалось распарсить строку трафика', bandwidth_str=bandwidth_str) return 0 except Exception as e: - logger.error(f"Ошибка парсинга строки трафика '{bandwidth_str}': {e}") + logger.error('Ошибка парсинга строки трафика', bandwidth_str=bandwidth_str, error=e) return 0 async def get_all_nodes(self) -> list[dict[str, Any]]: @@ -752,11 +765,11 @@ class RemnaWaveService: } ) - logger.info(f'✅ Получено {len(result)} нод из Remnawave') + logger.info('✅ Получено нод из Remnawave', result_count=len(result)) return result except Exception as e: - logger.error(f'Ошибка получения нод из Remnawave: {e}') + logger.error('Ошибка получения нод из Remnawave', error=e) return [] async def test_connection(self) -> bool: @@ -767,7 +780,7 @@ class RemnaWaveService: return True except Exception as e: - logger.error(f'❌ Ошибка соединения с Remnawave API: {e}') + logger.error('❌ Ошибка соединения с Remnawave API', error=e) return False async def get_node_details(self, node_uuid: str) -> dict[str, Any] | None: @@ -806,7 +819,7 @@ class RemnaWaveService: } except Exception as e: - logger.error(f'Ошибка получения информации о ноде {node_uuid}: {e}') + logger.error('Ошибка получения информации о ноде', node_uuid=node_uuid, error=e) return None async def manage_node(self, node_uuid: str, action: str) -> bool: @@ -821,11 +834,11 @@ class RemnaWaveService: else: return False - logger.info(f'✅ Действие {action} выполнено для ноды {node_uuid}') + logger.info('✅ Действие выполнено для ноды', action=action, node_uuid=node_uuid) return True except Exception as e: - logger.error(f'Ошибка управления нодой {node_uuid}: {e}') + logger.error('Ошибка управления нодой', node_uuid=node_uuid, error=e) return False async def restart_all_nodes(self) -> bool: @@ -839,7 +852,7 @@ class RemnaWaveService: return result except Exception as e: - logger.error(f'Ошибка перезагрузки всех нод: {e}') + logger.error('Ошибка перезагрузки всех нод', error=e) return False async def update_squad_inbounds(self, squad_uuid: str, inbound_uuids: list[str]) -> bool: @@ -849,7 +862,7 @@ class RemnaWaveService: await api._make_request('PATCH', '/api/internal-squads', data) return True except Exception as e: - logger.error(f'Error updating squad inbounds: {e}') + logger.error('Error updating squad inbounds', error=e) return False async def get_all_squads(self) -> list[dict[str, Any]]: @@ -872,11 +885,11 @@ class RemnaWaveService: } ) - logger.info(f'✅ Получено {len(result)} сквадов из Remnawave') + logger.info('✅ Получено сквадов из Remnawave', result_count=len(result)) return result except Exception as e: - logger.error(f'Ошибка получения сквадов из Remnawave: {e}') + logger.error('Ошибка получения сквадов из Remnawave', error=e) return [] async def create_squad(self, name: str, inbounds: list[str]) -> str | None: @@ -884,11 +897,11 @@ class RemnaWaveService: async with self.get_api_client() as api: squad = await api.create_internal_squad(name, inbounds) - logger.info(f'✅ Создан новый сквад: {name}') + logger.info('✅ Создан новый сквад', name=name) return squad.uuid except Exception as e: - logger.error(f'Ошибка создания сквада {name}: {e}') + logger.error('Ошибка создания сквада', name=name, error=e) return None async def update_squad(self, uuid: str, name: str = None, inbounds: list[str] = None) -> bool: @@ -896,11 +909,11 @@ class RemnaWaveService: async with self.get_api_client() as api: await api.update_internal_squad(uuid, name, inbounds) - logger.info(f'✅ Обновлен сквад {uuid}') + logger.info('✅ Обновлен сквад', uuid=uuid) return True except Exception as e: - logger.error(f'Ошибка обновления сквада {uuid}: {e}') + logger.error('Ошибка обновления сквада', uuid=uuid, error=e) return False async def delete_squad(self, uuid: str) -> bool: @@ -909,12 +922,12 @@ class RemnaWaveService: result = await api.delete_internal_squad(uuid) if result: - logger.info(f'✅ Удален сквад {uuid}') + logger.info('✅ Удален сквад', uuid=uuid) return result except Exception as e: - logger.error(f'Ошибка удаления сквада {uuid}: {e}') + logger.error('Ошибка удаления сквада', uuid=uuid, error=e) return False async def migrate_squad_users( @@ -965,9 +978,7 @@ class RemnaWaveService: total_candidates = len(subscriptions) if not subscriptions: logger.info( - '🚚 Переезд сквада %s → %s: подходящих подписок не найдено', - source_uuid, - target_uuid, + '🚚 Переезд сквада → : подходящих подписок не найдено', source_uuid=source_uuid, target_uuid=target_uuid ) return { 'success': True, @@ -1007,8 +1018,8 @@ class RemnaWaveService: if api is None: panel_failed += 1 logger.error( - '❌ RemnaWave API недоступен для обновления пользователя %s', - subscription.user.telegram_id, + '❌ RemnaWave API недоступен для обновления пользователя', + telegram_id=subscription.user.telegram_id, ) continue @@ -1021,9 +1032,9 @@ class RemnaWaveService: except Exception as error: panel_failed += 1 logger.error( - '❌ Ошибка обновления сквадов пользователя %s: %s', - subscription.user.telegram_id, - error, + '❌ Ошибка обновления сквадов пользователя', + telegram_id=subscription.user.telegram_id, + error=error, ) continue @@ -1086,11 +1097,11 @@ class RemnaWaveService: await db.rollback() logger.info( - '🚚 Завершен переезд сквада %s → %s: обновлено %s подписок (%s не обновлены в панели)', - source_uuid, - target_uuid, - updated_subscriptions, - panel_failed, + '🚚 Завершен переезд сквада → : обновлено подписок (не обновлены в панели)', + source_uuid=source_uuid, + target_uuid=target_uuid, + updated_subscriptions=updated_subscriptions, + panel_failed=panel_failed, ) return { @@ -1108,12 +1119,7 @@ class RemnaWaveService: raise except Exception as error: await db.rollback() - logger.error( - '❌ Ошибка переезда сквада %s → %s: %s', - source_uuid, - target_uuid, - error, - ) + logger.error('❌ Ошибка переезда сквада →', source_uuid=source_uuid, target_uuid=target_uuid, error=error) return { 'success': False, 'error': 'unexpected', @@ -1126,7 +1132,7 @@ class RemnaWaveService: try: stats = {'created': 0, 'updated': 0, 'errors': 0, 'deleted': 0} - logger.info(f'🔄 Начинаем синхронизацию типа: {sync_type}') + logger.info('🔄 Начинаем синхронизацию типа', sync_type=sync_type) async with self.get_api_client() as api: panel_users = [] @@ -1134,7 +1140,7 @@ class RemnaWaveService: size = 500 # Увеличен размер батча для ускорения загрузки while True: - logger.info(f'📥 Загружаем пользователей: start={start}, size={size}') + logger.info('📥 Загружаем пользователей: start=, size', start=start, size=size) # enrich_happ_links=False - happ_crypto_link уже возвращается API в поле happ.cryptoLink # Не делаем дополнительные HTTP-запросы для каждого пользователя @@ -1142,7 +1148,9 @@ class RemnaWaveService: users_batch = response['users'] total_users = response['total'] - logger.info(f'📊 Получено {len(users_batch)} пользователей из {total_users}') + logger.info( + '📊 Получено пользователей из', users_batch_count=len(users_batch), total_users=total_users + ) for user_obj in users_batch: user_dict = { @@ -1170,7 +1178,7 @@ class RemnaWaveService: if start > total_users: break - logger.info(f'✅ Всего загружено пользователей из панели: {len(panel_users)}') + logger.info('✅ Всего загружено пользователей из панели', panel_users_count=len(panel_users)) # Загрузка пользователей с их подписками за один запрос (bulk loading) from sqlalchemy import select @@ -1191,13 +1199,13 @@ class RemnaWaveService: # Also index email-only users by their remnawave_uuid for sync email_users_count = sum(1 for u in bot_users if u.telegram_id is None) if email_users_count > 0: - logger.info(f'📧 Email-only пользователей (без telegram_id): {email_users_count}') + logger.info('📧 Email-only пользователей (без telegram_id)', email_users_count=email_users_count) - logger.info(f'📊 Пользователей в боте: {len(bot_users)}') + logger.info('📊 Пользователей в боте', bot_users_count=len(bot_users)) panel_users_with_tg = [user for user in panel_users if user.get('telegramId') is not None] - logger.info(f'📊 Пользователей в панели с Telegram ID: {len(panel_users_with_tg)}') + logger.info('📊 Пользователей в панели с Telegram ID', panel_users_with_tg_count=len(panel_users_with_tg)) unique_panel_users_map = self._deduplicate_panel_users_by_telegram_id(panel_users_with_tg) unique_panel_users = list(unique_panel_users_map.values()) @@ -1205,8 +1213,8 @@ class RemnaWaveService: if duplicates_count: logger.info( - '♻️ Обнаружено %s дубликатов пользователей по Telegram ID. Используем самые свежие записи.', - duplicates_count, + '♻️ Обнаружено дубликатов пользователей по Telegram ID. Используем самые свежие записи.', + duplicates_count=duplicates_count, ) panel_telegram_ids = set(unique_panel_users_map.keys()) @@ -1216,7 +1224,10 @@ class RemnaWaveService: user for user in panel_users if user.get('telegramId') is None and user.get('email') ] if panel_users_email_only: - logger.info(f'📧 Пользователей в панели с Email (без Telegram): {len(panel_users_email_only)}') + logger.info( + '📧 Пользователей в панели с Email (без Telegram)', + panel_users_email_only_count=len(panel_users_email_only), + ) # Для ускорения - подготовим данные о подписках # Соберем все существующие подписки за один запрос @@ -1240,20 +1251,25 @@ class RemnaWaveService: continue if (i + 1) % 10 == 0: - logger.info(f'🔄 Обрабатываем пользователя {i + 1}/{len(unique_panel_users)}: {telegram_id}') + logger.info( + '🔄 Обрабатываем пользователя /', + i=i + 1, + unique_panel_users_count=len(unique_panel_users), + telegram_id=telegram_id, + ) db_user = bot_users_by_telegram_id.get(telegram_id) if not db_user: if sync_type in ['new_only', 'all']: - logger.info(f'🆕 Создание пользователя для telegram_id {telegram_id}') + logger.info('🆕 Создание пользователя для telegram_id', telegram_id=telegram_id) db_user, is_created = await self._get_or_create_bot_user_from_panel(db, panel_user) if not db_user: logger.error( - '❌ Не удалось создать или получить пользователя для telegram_id %s', - telegram_id, + '❌ Не удалось создать или получить пользователя для telegram_id', + telegram_id=telegram_id, ) stats['errors'] += 1 continue @@ -1265,7 +1281,11 @@ class RemnaWaveService: updated_fields = [] # Если были обновлены другие поля (подписка, статус и т.д.), сохраняем изменения if updated_fields: - logger.info(f'🔄 Обновлены поля {updated_fields} для пользователя {telegram_id}') + logger.info( + '🔄 Обновлены поля для пользователя', + updated_fields=updated_fields, + telegram_id=telegram_id, + ) await db.flush() # Сохраняем изменения без коммита _, uuid_mutation = self._ensure_user_remnawave_uuid( @@ -1277,23 +1297,27 @@ class RemnaWaveService: if is_created: await self._create_subscription_from_panel_data(db, db_user, panel_user) stats['created'] += 1 - logger.info(f'✅ Создан пользователь {telegram_id} с подпиской') + logger.info('✅ Создан пользователь с подпиской', telegram_id=telegram_id) else: # Обновляем данные существующего пользователя # Но теперь мы уже загрузили подписку с пользователем, нет необходимости перезагружать await self._update_subscription_from_panel_data(db, db_user, panel_user) stats['updated'] += 1 - logger.info(f'♻️ Обновлена подписка существующего пользователя {telegram_id}') + logger.info('♻️ Обновлена подписка существующего пользователя', telegram_id=telegram_id) elif sync_type in ['update_only', 'all']: - logger.debug(f'🔄 Обновление пользователя {telegram_id}') + logger.debug('🔄 Обновление пользователя', telegram_id=telegram_id) # При синхронизации не обновляем имя и username пользователя # только сохраняем изменения, если были обновлены другие поля (подписка и т.д.) updated_fields = [] # Если были обновлены другие поля (подписка, статус и т.д.), сохраняем изменения if updated_fields: - logger.info(f'🔄 Обновлены поля {updated_fields} для пользователя {telegram_id}') + logger.info( + '🔄 Обновлены поля для пользователя', + updated_fields=updated_fields, + telegram_id=telegram_id, + ) await db.flush() # Сохраняем изменения без коммита # Обновляем UUID ДО операций с подпиской, чтобы избежать @@ -1315,10 +1339,10 @@ class RemnaWaveService: await self._create_subscription_from_panel_data(db, db_user, panel_user) stats['updated'] += 1 - logger.debug(f'✅ Обновлён пользователь {telegram_id}') + logger.debug('✅ Обновлён пользователь', telegram_id=telegram_id) except Exception as user_error: - logger.error(f'❌ Ошибка обработки пользователя {telegram_id}: {user_error}') + logger.error('❌ Ошибка обработки пользователя', telegram_id=telegram_id, user_error=user_error) stats['errors'] += 1 if uuid_mutation: uuid_mutation.rollback() @@ -1335,9 +1359,9 @@ class RemnaWaveService: # in async context (greenlet_spawn error). Break the loop to # prevent cascading failures for every remaining user. logger.warning( - '⚠️ Сессия повреждена после rollback, прерываем обработку (обработано %d/%d пользователей)', - i + 1, - len(unique_panel_users), + '⚠️ Сессия повреждена после rollback, прерываем обработку (обработано / пользователей)', + i=i + 1, + unique_panel_users_count=len(unique_panel_users), ) break @@ -1349,10 +1373,12 @@ class RemnaWaveService: if (i + 1) % batch_size == 0: try: await db.commit() - logger.debug(f'📦 Коммит изменений после обработки {i + 1} пользователей') + logger.debug('📦 Коммит изменений после обработки пользователей', i=i + 1) pending_uuid_mutations.clear() except Exception as commit_error: - logger.error(f'❌ Ошибка коммита после обработки {i + 1} пользователей: {commit_error}') + logger.error( + '❌ Ошибка коммита после обработки пользователей', i=i + 1, commit_error=commit_error + ) await db.rollback() for mutation in reversed(pending_uuid_mutations): mutation.rollback() @@ -1364,7 +1390,7 @@ class RemnaWaveService: await db.commit() pending_uuid_mutations.clear() except Exception as final_commit_error: - logger.error(f'❌ Ошибка финального коммита: {final_commit_error}') + logger.error('❌ Ошибка финального коммита', final_commit_error=final_commit_error) await db.rollback() for mutation in reversed(pending_uuid_mutations): mutation.rollback() @@ -1372,7 +1398,10 @@ class RemnaWaveService: # Обработка email-only пользователей из панели if panel_users_email_only and sync_type in ['new_only', 'all']: - logger.info(f'📧 Обработка {len(panel_users_email_only)} email-only пользователей из панели...') + logger.info( + '📧 Обработка email-only пользователей из панели...', + panel_users_email_only_count=len(panel_users_email_only), + ) for panel_user in panel_users_email_only: try: @@ -1406,20 +1435,20 @@ class RemnaWaveService: await self._create_subscription_from_panel_data(db, db_user, panel_user) stats['updated'] += 1 - logger.info(f'📧 Обновлен email-пользователь: {panel_email}') + logger.info('📧 Обновлен email-пользователь', panel_email=panel_email) else: # Email-only пользователи не создаются автоматически при синхронизации, # они должны сначала зарегистрироваться через cabinet - logger.debug(f'📧 Email-пользователь {panel_email} не найден в боте, пропускаем') + logger.debug('📧 Email-пользователь не найден в боте, пропускаем', panel_email=panel_email) except Exception as email_user_error: - logger.error(f'❌ Ошибка обработки email-пользователя: {email_user_error}') + logger.error('❌ Ошибка обработки email-пользователя', email_user_error=email_user_error) stats['errors'] += 1 try: await db.commit() except Exception as email_commit_error: - logger.error(f'❌ Ошибка коммита email-пользователей: {email_commit_error}') + logger.error('❌ Ошибка коммита email-пользователей', email_commit_error=email_commit_error) await db.rollback() if sync_type == 'all': @@ -1439,7 +1468,9 @@ class RemnaWaveService: ] if users_to_deactivate: - logger.info(f'📊 Найдено {len(users_to_deactivate)} пользователей для деактивации') + logger.info( + '📊 Найдено пользователей для деактивации', users_to_deactivate_count=len(users_to_deactivate) + ) # Используем один API клиент для всех операций сброса HWID hwid_api_client = None @@ -1448,7 +1479,7 @@ class RemnaWaveService: hwid_api_cm = self.get_api_client() hwid_api_client = await hwid_api_cm.__aenter__() except Exception as api_init_error: - logger.warning(f'⚠️ Не удалось создать API клиент для сброса HWID: {api_init_error}') + logger.warning('⚠️ Не удалось создать API клиент для сброса HWID', api_init_error=api_init_error) hwid_api_client = None hwid_api_cm = None @@ -1463,20 +1494,26 @@ class RemnaWaveService: if subscription and is_recently_updated_by_webhook(subscription): logger.debug( - 'Пропуск деактивации подписки %s: обновлена вебхуком недавно', - subscription.id, + 'Пропуск деактивации подписки : обновлена вебхуком недавно', + subscription_id=subscription.id, ) continue - logger.info(f'🗑️ Деактивация подписки пользователя {telegram_id} (нет в панели)') + logger.info('🗑️ Деактивация подписки пользователя (нет в панели)', telegram_id=telegram_id) if db_user.remnawave_uuid and hwid_api_client: try: devices_reset = await hwid_api_client.reset_user_devices(db_user.remnawave_uuid) if devices_reset: - logger.info(f'🔧 Сброшены HWID устройства для пользователя {telegram_id}') + logger.info( + '🔧 Сброшены HWID устройства для пользователя', telegram_id=telegram_id + ) except Exception as hwid_error: - logger.error(f'❌ Ошибка сброса HWID устройств для {telegram_id}: {hwid_error}') + logger.error( + '❌ Ошибка сброса HWID устройств для', + telegram_id=telegram_id, + hwid_error=hwid_error, + ) try: from sqlalchemy import delete @@ -1490,9 +1527,9 @@ class RemnaWaveService: SubscriptionServer.subscription_id == subscription.id ) ) - logger.info(f'🗑️ Удалены серверы подписки для {telegram_id}') + logger.info('🗑️ Удалены серверы подписки для', telegram_id=telegram_id) except Exception as servers_error: - logger.warning(f'⚠️ Не удалось удалить серверы подписки: {servers_error}') + logger.warning('⚠️ Не удалось удалить серверы подписки', servers_error=servers_error) from app.database.models import SubscriptionStatus @@ -1505,8 +1542,10 @@ class RemnaWaveService: # Для платных подписок - НЕ сбрасываем is_trial и end_date! # Сохраняем оригинальные значения чтобы можно было восстановить logger.warning( - f'⚠️ ПЛАТНАЯ подписка пользователя {telegram_id} отключена (нет в панели), ' - f'но is_trial={subscription.is_trial} и end_date={subscription.end_date} СОХРАНЕНЫ' + '⚠️ ПЛАТНАЯ подписка пользователя отключена (нет в панели), но is_trial= и end_date= СОХРАНЕНЫ', + telegram_id=telegram_id, + is_trial=subscription.is_trial, + end_date=subscription.end_date, ) else: # Для триальных подписок - сбрасываем как раньше @@ -1530,12 +1569,16 @@ class RemnaWaveService: cleanup_mutation.set_user_updated_at(db_user, datetime.utcnow()) stats['deleted'] += 1 - logger.info(f'✅ Деактивирована подписка пользователя {telegram_id} (сохранен баланс)') + logger.info( + '✅ Деактивирована подписка пользователя (сохранен баланс)', telegram_id=telegram_id + ) processed_count += 1 except Exception as delete_error: - logger.error(f'❌ Ошибка деактивации подписки {telegram_id}: {delete_error}') + logger.error( + '❌ Ошибка деактивации подписки', telegram_id=telegram_id, delete_error=delete_error + ) stats['errors'] += 1 if cleanup_mutation: cleanup_mutation.rollback() @@ -1555,11 +1598,16 @@ class RemnaWaveService: if processed_count % batch_size == 0: try: await db.commit() - logger.debug(f'📦 Коммит изменений после деактивации {processed_count} подписок') + logger.debug( + '📦 Коммит изменений после деактивации подписок', + processed_count=processed_count, + ) cleanup_uuid_mutations.clear() except Exception as commit_error: logger.error( - f'❌ Ошибка коммита после деактивации {processed_count} подписок: {commit_error}' + '❌ Ошибка коммита после деактивации подписок', + processed_count=processed_count, + commit_error=commit_error, ) await db.rollback() for mutation in reversed(cleanup_uuid_mutations): @@ -1573,7 +1621,9 @@ class RemnaWaveService: await db.commit() cleanup_uuid_mutations.clear() except Exception as final_commit_error: - logger.error(f'❌ Ошибка финального коммита при деактивации: {final_commit_error}') + logger.error( + '❌ Ошибка финального коммита при деактивации', final_commit_error=final_commit_error + ) await db.rollback() for mutation in reversed(cleanup_uuid_mutations): mutation.rollback() @@ -1588,12 +1638,16 @@ class RemnaWaveService: pass logger.info( - f'🎯 Синхронизация завершена: создано {stats["created"]}, обновлено {stats["updated"]}, деактивировано {stats["deleted"]}, ошибок {stats["errors"]}' + '🎯 Синхронизация завершена: создано обновлено деактивировано ошибок', + stats=stats['created'], + stats_2=stats['updated'], + stats_3=stats['deleted'], + stats_4=stats['errors'], ) return stats except Exception as e: - logger.error(f'❌ Критическая ошибка синхронизации пользователей: {e}') + logger.error('❌ Критическая ошибка синхронизации пользователей', error=e) return {'created': 0, 'updated': 0, 'errors': 1, 'deleted': 0} async def _create_subscription_from_panel_data(self, db: AsyncSession, user, panel_user): @@ -1646,10 +1700,12 @@ class RemnaWaveService: } await create_subscription_no_commit(db, **subscription_data) - logger.info(f'✅ Подготовлена подписка для пользователя {user.telegram_id} до {expire_at}') + logger.info( + '✅ Подготовлена подписка для пользователя до', telegram_id=user.telegram_id, expire_at=expire_at + ) except Exception as e: - logger.error(f'❌ Ошибка создания подписки для пользователя {user.telegram_id}: {e}') + logger.error('❌ Ошибка создания подписки для пользователя', telegram_id=user.telegram_id, error=e) try: from app.database.crud.subscription import create_subscription_no_commit from app.database.models import SubscriptionStatus @@ -1670,9 +1726,9 @@ class RemnaWaveService: panel_user.get('subscriptionCryptoLink') or (panel_user.get('happ') or {}).get('cryptoLink', '') ), ) - logger.info(f'✅ Подготовлена базовая подписка для пользователя {user.telegram_id}') + logger.info('✅ Подготовлена базовая подписка для пользователя', telegram_id=user.telegram_id) except Exception as basic_error: - logger.error(f'❌ Ошибка создания базовой подписки: {basic_error}') + logger.error('❌ Ошибка создания базовой подписки', basic_error=basic_error) async def _update_subscription_from_panel_data(self, db: AsyncSession, user, panel_user): try: @@ -1690,8 +1746,7 @@ class RemnaWaveService: # Skip if recently updated by webhook (prevent stale data overwrite) if is_recently_updated_by_webhook(subscription): logger.debug( - 'Пропуск синхронизации подписки %s: обновлена вебхуком недавно', - subscription.id, + 'Пропуск синхронизации подписки : обновлена вебхуком недавно', subscription_id=subscription.id ) return @@ -1724,25 +1779,33 @@ class RemnaWaveService: .replace(tzinfo=None) ) logger.info( - f'✅ Sync: обновлена end_date для user {getattr(user, "telegram_id", "?")}: ' - f'{subscription.end_date} -> {new_end_date_local} (разница: {time_diff:.0f}с)' + '✅ Sync: обновлена end_date для user -> (разница: с)', + value=getattr(user, 'telegram_id', '?'), + end_date=subscription.end_date, + new_end_date_local=new_end_date_local, + time_diff=round(time_diff, 0), ) subscription.end_date = new_end_date_local else: # Локальная дата позже - НЕ перезаписываем logger.debug( - f'⏭️ Sync: end_date для user {getattr(user, "telegram_id", "?")} актуальна: ' - f'локальная ({subscription.end_date} / UTC: {local_end_date_utc}) >= RemnaWave ({expire_at} UTC)' + '⏭️ Sync: end_date для user актуальна: локальная ( UTC: ) RemnaWave ( UTC)', + value=getattr(user, 'telegram_id', '?'), + end_date=subscription.end_date, + local_end_date_utc=local_end_date_utc, + expire_at=expire_at, ) else: logger.debug( - f'⏭️ Sync: пропускаем обновление end_date для user {getattr(user, "telegram_id", "?")}: ' - f'разница слишком мала ({time_diff:.0f}с < 60с)' + '⏭️ Sync: пропускаем обновление end_date для user разница слишком мала (с < 60с)', + value=getattr(user, 'telegram_id', '?'), + time_diff=round(time_diff, 0), ) else: logger.debug( - f'⏭️ Sync: пропускаем обновление end_date для user {getattr(user, "telegram_id", "?")}: ' - f'панель не ACTIVE (статус: {panel_status})' + '⏭️ Sync: пропускаем обновление end_date для user панель не ACTIVE (статус: )', + value=getattr(user, 'telegram_id', '?'), + panel_status=panel_status, ) current_time = self._now_utc() @@ -1759,9 +1822,11 @@ class RemnaWaveService: # а реальная end_date уже обновлена продлением if subscription.status == SubscriptionStatus.ACTIVE.value: logger.warning( - f'⚠️ Sync: пропускаем деактивацию подписки user {getattr(user, "telegram_id", "?")}: ' - f'статус ACTIVE, end_date ({subscription.end_date} / UTC: {end_date_utc}) <= now ({current_time}). ' - f'Деактивация будет выполнена через middleware с буфером.' + '⚠️ Sync: пропускаем деактивацию подписки user статус ACTIVE, end_date ( UTC: ) <= now . Деактивация будет выполнена через middleware с буфером.', + value=getattr(user, 'telegram_id', '?'), + end_date=subscription.end_date, + end_date_utc=end_date_utc, + current_time=current_time, ) new_status = subscription.status # Сохраняем текущий статус else: @@ -1771,36 +1836,36 @@ class RemnaWaveService: if subscription.status != new_status: subscription.status = new_status - logger.debug(f'Обновлен статус подписки: {new_status}') + logger.debug('Обновлен статус подписки', new_status=new_status) used_traffic_bytes = _get_user_traffic_bytes(panel_user) traffic_used_gb = used_traffic_bytes / (1024**3) if abs(subscription.traffic_used_gb - traffic_used_gb) > 0.01: subscription.traffic_used_gb = traffic_used_gb - logger.debug(f'Обновлен использованный трафик: {traffic_used_gb} GB') + logger.debug('Обновлен использованный трафик', traffic_used_gb=traffic_used_gb) traffic_limit_bytes = panel_user.get('trafficLimitBytes', 0) traffic_limit_gb = traffic_limit_bytes // (1024**3) if traffic_limit_bytes > 0 else 0 if subscription.traffic_limit_gb != traffic_limit_gb: subscription.traffic_limit_gb = traffic_limit_gb - logger.debug(f'Обновлен лимит трафика: {traffic_limit_gb} GB') + logger.debug('Обновлен лимит трафика', traffic_limit_gb=traffic_limit_gb) device_limit = panel_user.get('hwidDeviceLimit', 1) or 1 if subscription.device_limit != device_limit: subscription.device_limit = device_limit - logger.debug(f'Обновлен лимит устройств: {device_limit}') + logger.debug('Обновлен лимит устройств', device_limit=device_limit) new_short_uuid = panel_user.get('shortUuid') if new_short_uuid and subscription.remnawave_short_uuid != new_short_uuid: old_short_uuid = subscription.remnawave_short_uuid subscription.remnawave_short_uuid = new_short_uuid logger.debug( - 'Обновлен short UUID подписки пользователя %s: %s → %s', - getattr(user, 'telegram_id', '?'), - old_short_uuid, - new_short_uuid, + 'Обновлен short UUID подписки пользователя : →', + getattr=getattr(user, 'telegram_id', '?'), + old_short_uuid=old_short_uuid, + new_short_uuid=new_short_uuid, ) panel_url = panel_user.get('subscriptionUrl', '') @@ -1827,13 +1892,13 @@ class RemnaWaveService: if current_squads != new_squads: subscription.connected_squads = squad_uuids - logger.debug(f'Обновлены подключенные сквады: {squad_uuids}') + logger.debug('Обновлены подключенные сквады', squad_uuids=squad_uuids) # Коммитим изменения позже, в основном цикле, чтобы уменьшить количество транзакций - logger.debug(f'✅ Обновлена подписка для пользователя {user.telegram_id}') + logger.debug('✅ Обновлена подписка для пользователя', telegram_id=user.telegram_id) except Exception as e: - logger.error(f'❌ Ошибка обновления подписки для пользователя {user.telegram_id}: {e}') + logger.error('❌ Ошибка обновления подписки для пользователя', telegram_id=user.telegram_id, error=e) # Не делаем rollback, так как это может повлиять на другие операции # Ошибку прокидываем выше для корректной обработки в основном цикле raise @@ -1924,7 +1989,11 @@ class RemnaWaveService: existing_users = await api.get_user_by_telegram_id(user.telegram_id) if existing_users: panel_uuid = existing_users[0].uuid - logger.debug(f'Найден пользователь {user.telegram_id} в панели: {panel_uuid}') + logger.debug( + 'Найден пользователь в панели', + telegram_id=user.telegram_id, + panel_uuid=panel_uuid, + ) # Fallback: поиск по email (для OAuth юзеров без telegram_id) if not panel_uuid and user.email: @@ -1932,7 +2001,9 @@ class RemnaWaveService: if existing_users: panel_uuid = existing_users[0].uuid logger.debug( - f'Найден пользователь {user.email} в панели по email: {panel_uuid}' + 'Найден пользователь в панели по email', + email=user.email, + panel_uuid=panel_uuid, ) if panel_uuid: @@ -1967,7 +2038,9 @@ class RemnaWaveService: except Exception as e: logger.error( - f'Ошибка синхронизации пользователя {sub.user.telegram_id if sub.user else "N/A"} в панель: {e}' + 'Ошибка синхронизации пользователя в панель', + telegram_id=sub.user.telegram_id if sub.user else 'N/A', + error=e, ) return ('error', sub, None) @@ -1995,16 +2068,16 @@ class RemnaWaveService: try: await db.commit() except Exception as commit_error: - logger.error( - 'Ошибка фиксации транзакции при синхронизации в панель: %s', - commit_error, - ) + logger.error('Ошибка фиксации транзакции при синхронизации в панель', commit_error=commit_error) await db.rollback() stats['errors'] += len(valid_subscriptions) logger.info( - f'📦 Обработано {offset + len(subscriptions)} подписок: ' - f'создано {stats["created"]}, обновлено {stats["updated"]}, ошибок {stats["errors"]}' + '📦 Обработано подписок: создано обновлено ошибок', + offset=offset + len(subscriptions), + stats=stats['created'], + stats_2=stats['updated'], + stats_3=stats['errors'], ) if len(subscriptions) < batch_size: @@ -2013,12 +2086,15 @@ class RemnaWaveService: offset += batch_size logger.info( - f'✅ Синхронизация в панель завершена: создано {stats["created"]}, обновлено {stats["updated"]}, ошибок {stats["errors"]}' + '✅ Синхронизация в панель завершена: создано обновлено ошибок', + stats=stats['created'], + stats_2=stats['updated'], + stats_3=stats['errors'], ) return stats except Exception as e: - logger.error(f'Ошибка синхронизации пользователей в панель: {e}') + logger.error('Ошибка синхронизации пользователей в панель', error=e) return {'created': 0, 'updated': 0, 'errors': 1} async def get_user_traffic_stats(self, telegram_id: int) -> dict[str, Any] | None: @@ -2042,7 +2118,7 @@ class RemnaWaveService: } except Exception as e: - logger.error(f'Ошибка получения статистики трафика для пользователя {telegram_id}: {e}') + logger.error('Ошибка получения статистики трафика для пользователя', telegram_id=telegram_id, error=e) return None async def get_user_traffic_stats_by_uuid(self, remnawave_uuid: str) -> dict[str, Any] | None: @@ -2069,7 +2145,7 @@ class RemnaWaveService: } except Exception as e: - logger.error(f'Ошибка получения статистики трафика по UUID {remnawave_uuid}: {e}') + logger.error('Ошибка получения статистики трафика по UUID', remnawave_uuid=remnawave_uuid, error=e) return None async def get_telegram_id_by_email(self, user_identifier: str) -> int | None: @@ -2093,11 +2169,13 @@ class RemnaWaveService: user = await api.get_user_by_username(user_identifier) if user and user.telegram_id: logger.info( - f"Найден пользователь по username '{user_identifier}': telegram_id={user.telegram_id}" + 'Найден пользователь по username telegram_id', + user_identifier=user_identifier, + telegram_id=user.telegram_id, ) return user.telegram_id except Exception as e: - logger.debug(f"Пользователь не найден по username '{user_identifier}': {e}") + logger.debug('Пользователь не найден по username', user_identifier=user_identifier, error=e) # Если не нашли по username, ищем по email среди всех пользователей try: @@ -2110,17 +2188,19 @@ class RemnaWaveService: panel_telegram_id = panel_user.telegram_id if hasattr(panel_user, 'telegram_id') else None if panel_telegram_id: logger.info( - f"Найден пользователь по email '{user_identifier}': telegram_id={panel_telegram_id}" + 'Найден пользователь по email telegram_id', + user_identifier=user_identifier, + panel_telegram_id=panel_telegram_id, ) return panel_telegram_id except Exception as e: - logger.warning(f"Ошибка поиска пользователя по email '{user_identifier}': {e}") + logger.warning('Ошибка поиска пользователя по email', user_identifier=user_identifier, error=e) - logger.warning(f"Пользователь с идентификатором '{user_identifier}' не найден в панели") + logger.warning('Пользователь с идентификатором не найден в панели', user_identifier=user_identifier) return None except Exception as e: - logger.error(f"Ошибка получения telegram_id для '{user_identifier}': {e}") + logger.error('Ошибка получения telegram_id для', user_identifier=user_identifier, error=e) return None async def test_api_connection(self) -> dict[str, Any]: @@ -2164,7 +2244,7 @@ class RemnaWaveService: return usage_data except Exception as e: - logger.error(f'Ошибка получения актуального использования нод: {e}') + logger.error('Ошибка получения актуального использования нод', error=e) return [] async def get_squad_details(self, squad_uuid: str) -> dict | None: @@ -2184,7 +2264,7 @@ class RemnaWaveService: } return None except Exception as e: - logger.error(f'Error getting squad details: {e}') + logger.error('Error getting squad details', error=e) return None async def add_all_users_to_squad(self, squad_uuid: str) -> bool: @@ -2193,7 +2273,7 @@ class RemnaWaveService: response = await api._make_request('POST', f'/api/internal-squads/{squad_uuid}/bulk-actions/add-users') return response.get('response', {}).get('eventSent', False) except Exception as e: - logger.error(f'Error adding users to squad: {e}') + logger.error('Error adding users to squad', error=e) return False async def remove_all_users_from_squad(self, squad_uuid: str) -> bool: @@ -2204,7 +2284,7 @@ class RemnaWaveService: ) return response.get('response', {}).get('eventSent', False) except Exception as e: - logger.error(f'Error removing users from squad: {e}') + logger.error('Error removing users from squad', error=e) return False async def get_all_inbounds(self) -> list[dict]: @@ -2225,7 +2305,7 @@ class RemnaWaveService: for inbound in inbounds_data ] except Exception as e: - logger.error(f'Error getting all inbounds: {e}') + logger.error('Error getting all inbounds', error=e) return [] async def rename_squad(self, squad_uuid: str, new_name: str) -> bool: @@ -2235,7 +2315,7 @@ class RemnaWaveService: await api._make_request('PATCH', '/api/internal-squads', data) return True except Exception as e: - logger.error(f'Error renaming squad: {e}') + logger.error('Error renaming squad', error=e) return False async def get_node_user_usage_by_range(self, node_uuid: str, start_date, end_date) -> list[dict[str, Any]]: @@ -2253,7 +2333,7 @@ class RemnaWaveService: return usage_data.get('response', []) except Exception as e: - logger.error(f'Ошибка получения статистики использования ноды {node_uuid}: {e}') + logger.error('Ошибка получения статистики использования ноды', node_uuid=node_uuid, error=e) return [] async def get_node_statistics(self, node_uuid: str) -> dict[str, Any] | None: @@ -2283,26 +2363,26 @@ class RemnaWaveService: } except Exception as e: - logger.error(f'Ошибка получения статистики ноды {node_uuid}: {e}') + logger.error('Ошибка получения статистики ноды', node_uuid=node_uuid, error=e) async def validate_user_data_before_sync(self, panel_user) -> bool: try: if not panel_user.telegram_id: - logger.debug(f'Нет telegram_id для пользователя {panel_user.uuid}') + logger.debug('Нет telegram_id для пользователя', uuid=panel_user.uuid) return False if not panel_user.uuid: - logger.debug(f'Нет UUID для пользователя {panel_user.telegram_id}') + logger.debug('Нет UUID для пользователя', telegram_id=panel_user.telegram_id) return False if panel_user.telegram_id <= 0: - logger.debug(f'Некорректный telegram_id: {panel_user.telegram_id}') + logger.debug('Некорректный telegram_id', telegram_id=panel_user.telegram_id) return False return True except Exception as e: - logger.error(f'Ошибка валидации данных пользователя: {e}') + logger.error('Ошибка валидации данных пользователя', error=e) return False async def force_cleanup_user_data(self, db: AsyncSession, user: User) -> bool: @@ -2320,22 +2400,23 @@ class RemnaWaveService: user_id_display = user.telegram_id or user.email or f'#{user.id}' if was_paid: logger.warning( - f'⚠️ ВНИМАНИЕ: force_cleanup_user_data вызвана для ПЛАТНОГО пользователя {user_id_display}! ' - f'has_had_paid_subscription={user.has_had_paid_subscription}, ' - f'balance={user.balance_kopeks}, ' - f'is_trial={user.subscription.is_trial if user.subscription else "N/A"}' + '⚠️ ВНИМАНИЕ: force_cleanup_user_data вызвана для ПЛАТНОГО пользователя ! has_had_paid_subscription=, balance=, is_trial', + user_id_display=user_id_display, + has_had_paid_subscription=user.has_had_paid_subscription, + balance_kopeks=user.balance_kopeks, + is_trial=user.subscription.is_trial if user.subscription else 'N/A', ) - logger.info(f'🗑️ ПРИНУДИТЕЛЬНАЯ полная очистка данных пользователя {user_id_display}') + logger.info('🗑️ ПРИНУДИТЕЛЬНАЯ полная очистка данных пользователя', user_id_display=user_id_display) if user.remnawave_uuid: try: async with self.get_api_client() as api: devices_reset = await api.reset_user_devices(user.remnawave_uuid) if devices_reset: - logger.info(f'🔧 Сброшены HWID устройства для {user_id_display}') + logger.info('🔧 Сброшены HWID устройства для', user_id_display=user_id_display) except Exception as hwid_error: - logger.warning(f'⚠️ Ошибка сброса HWID устройств: {hwid_error}') + logger.warning('⚠️ Ошибка сброса HWID устройств', hwid_error=hwid_error) try: from sqlalchemy import delete @@ -2354,20 +2435,20 @@ class RemnaWaveService: await db.execute( delete(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id) ) - logger.info(f'🗑️ Удалены серверы подписки для {user_id_display}') + logger.info('🗑️ Удалены серверы подписки для', user_id_display=user_id_display) await db.execute(delete(Transaction).where(Transaction.user_id == user.id)) - logger.info(f'🗑️ Удалены транзакции для {user_id_display}') + logger.info('🗑️ Удалены транзакции для', user_id_display=user_id_display) await db.execute(delete(ReferralEarning).where(ReferralEarning.user_id == user.id)) await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user.id)) - logger.info(f'🗑️ Удалены реферальные доходы для {user_id_display}') + logger.info('🗑️ Удалены реферальные доходы для', user_id_display=user_id_display) await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id)) - logger.info(f'🗑️ Удалены использования промокодов для {user_id_display}') + logger.info('🗑️ Удалены использования промокодов для', user_id_display=user_id_display) except Exception as records_error: - logger.error(f'❌ Ошибка удаления связанных записей: {records_error}') + logger.error('❌ Ошибка удаления связанных записей', records_error=records_error) try: user.balance_kopeks = 0 @@ -2393,16 +2474,18 @@ class RemnaWaveService: await db.commit() - logger.info(f'✅ ПРИНУДИТЕЛЬНО очищены ВСЕ данные пользователя {user_id_display}') + logger.info('✅ ПРИНУДИТЕЛЬНО очищены ВСЕ данные пользователя', user_id_display=user_id_display) return True except Exception as cleanup_error: - logger.error(f'❌ Ошибка финальной очистки пользователя: {cleanup_error}') + logger.error('❌ Ошибка финальной очистки пользователя', cleanup_error=cleanup_error) await db.rollback() return False except Exception as e: - logger.error(f'❌ Критическая ошибка принудительной очистки пользователя {user.telegram_id}: {e}') + logger.error( + '❌ Критическая ошибка принудительной очистки пользователя', telegram_id=user.telegram_id, error=e + ) await db.rollback() return False @@ -2422,7 +2505,7 @@ class RemnaWaveService: if telegram_id: panel_telegram_ids.add(telegram_id) - logger.info(f'📊 Найдено {len(panel_telegram_ids)} пользователей в панели') + logger.info('📊 Найдено пользователей в панели', panel_telegram_ids_count=len(panel_telegram_ids)) from app.database.crud.subscription import get_all_subscriptions from app.database.models import SubscriptionStatus @@ -2446,7 +2529,8 @@ class RemnaWaveService: if user.telegram_id not in panel_telegram_ids: logger.info( - f'🗑️ ПОЛНАЯ деактивация подписки пользователя {user.telegram_id} (отсутствует в панели)' + '🗑️ ПОЛНАЯ деактивация подписки пользователя (отсутствует в панели)', + telegram_id=user.telegram_id, ) cleanup_success = await self.force_cleanup_user_data(db, user) @@ -2457,7 +2541,9 @@ class RemnaWaveService: stats['errors'] += 1 except Exception as sub_error: - logger.error(f'❌ Ошибка обработки подписки {subscription.id}: {sub_error}') + logger.error( + '❌ Ошибка обработки подписки', subscription_id=subscription.id, sub_error=sub_error + ) stats['errors'] += 1 page += 1 @@ -2465,12 +2551,15 @@ class RemnaWaveService: break logger.info( - f'🧹 Усиленная очистка завершена: проверено {stats["checked"]}, деактивировано {stats["deactivated"]}, ошибок {stats["errors"]}' + '🧹 Усиленная очистка завершена: проверено деактивировано ошибок', + stats=stats['checked'], + stats_2=stats['deactivated'], + stats_3=stats['errors'], ) return stats except Exception as e: - logger.error(f'❌ Критическая ошибка усиленной очистки подписок: {e}') + logger.error('❌ Критическая ошибка усиленной очистки подписок', error=e) return {'deactivated': 0, 'errors': 1, 'checked': 0} async def sync_subscription_statuses(self, db: AsyncSession) -> dict[str, int]: @@ -2489,7 +2578,9 @@ class RemnaWaveService: if telegram_id: panel_users_dict[telegram_id] = panel_user - logger.info(f'📊 Найдено {len(panel_users_dict)} пользователей в панели для синхронизации') + logger.info( + '📊 Найдено пользователей в панели для синхронизации', panel_users_dict_count=len(panel_users_dict) + ) from app.database.crud.subscription import get_all_subscriptions from app.database.models import SubscriptionStatus @@ -2510,7 +2601,7 @@ class RemnaWaveService: # Skip email-only users (no telegram_id for panel lookup) if not user.telegram_id: - logger.debug(f'Пропускаем email-пользователя {user.id} при синхронизации с панелью') + logger.debug('Пропускаем email-пользователя при синхронизации с панелью', user_id=user.id) continue panel_user = panel_users_dict.get(user.telegram_id) @@ -2526,16 +2617,20 @@ class RemnaWaveService: if is_recently_updated_by_webhook(subscription): logger.debug( - 'Пропуск деактивации подписки %s: обновлена вебхуком недавно', - subscription.id, + 'Пропуск деактивации подписки : обновлена вебхуком недавно', + subscription_id=subscription.id, ) else: - logger.info(f'🗑️ Деактивируем подписку пользователя {user.telegram_id} (нет в панели)') + logger.info( + '🗑️ Деактивируем подписку пользователя (нет в панели)', telegram_id=user.telegram_id + ) await deactivate_subscription(db, subscription) stats['updated'] += 1 except Exception as sub_error: - logger.error(f'❌ Ошибка синхронизации подписки {subscription.id}: {sub_error}') + logger.error( + '❌ Ошибка синхронизации подписки', subscription_id=subscription.id, sub_error=sub_error + ) stats['errors'] += 1 page += 1 @@ -2543,12 +2638,15 @@ class RemnaWaveService: break logger.info( - f'🔄 Синхронизация статусов завершена: проверено {stats["checked"]}, обновлено {stats["updated"]}, ошибок {stats["errors"]}' + '🔄 Синхронизация статусов завершена: проверено обновлено ошибок', + stats=stats['checked'], + stats_2=stats['updated'], + stats_3=stats['errors'], ) return stats except Exception as e: - logger.error(f'❌ Критическая ошибка синхронизации статусов: {e}') + logger.error('❌ Критическая ошибка синхронизации статусов', error=e) return {'updated': 0, 'errors': 1, 'checked': 0} async def validate_and_fix_subscriptions(self, db: AsyncSession) -> dict[str, int]: @@ -2589,8 +2687,10 @@ class RemnaWaveService: ): time_since_expiry = current_time - end_date_utc logger.warning( - f'🔧 fix_data_issues: деактивируем подписку {subscription.id} ' - f'(user={user.telegram_id}), просрочена на {time_since_expiry}' + '🔧 fix_data_issues: деактивируем подписку (user=), просрочена на', + subscription_id=subscription.id, + telegram_id=user.telegram_id, + time_since_expiry=time_since_expiry, ) subscription.status = SubscriptionStatus.EXPIRED.value issues_fixed += 1 @@ -2603,31 +2703,37 @@ class RemnaWaveService: subscription.remnawave_short_uuid = rw_user.short_uuid subscription.subscription_url = rw_user.subscription_url subscription.subscription_crypto_link = rw_user.happ_crypto_link - logger.info(f'🔧 Восстановлены данные Remnawave для {user.telegram_id}') + logger.info( + '🔧 Восстановлены данные Remnawave для', telegram_id=user.telegram_id + ) issues_fixed += 1 except Exception as rw_error: logger.warning( - f'⚠️ Не удалось получить данные Remnawave для {user.telegram_id}: {rw_error}' + '⚠️ Не удалось получить данные Remnawave для', + telegram_id=user.telegram_id, + rw_error=rw_error, ) if subscription.traffic_limit_gb < 0: subscription.traffic_limit_gb = 0 - logger.info(f'🔧 Исправлен некорректный лимит трафика для {user.telegram_id}') + logger.info('🔧 Исправлен некорректный лимит трафика для', telegram_id=user.telegram_id) issues_fixed += 1 if subscription.traffic_used_gb < 0: subscription.traffic_used_gb = 0.0 - logger.info(f'🔧 Исправлено некорректное использование трафика для {user.telegram_id}') + logger.info( + '🔧 Исправлено некорректное использование трафика для', telegram_id=user.telegram_id + ) issues_fixed += 1 if subscription.device_limit <= 0: subscription.device_limit = 1 - logger.info(f'🔧 Исправлен лимит устройств для {user.telegram_id}') + logger.info('🔧 Исправлен лимит устройств для', telegram_id=user.telegram_id) issues_fixed += 1 if subscription.connected_squads is None: subscription.connected_squads = [] - logger.info(f'🔧 Инициализирован список сквадов для {user.telegram_id}') + logger.info('🔧 Инициализирован список сквадов для', telegram_id=user.telegram_id) issues_fixed += 1 if issues_fixed > 0: @@ -2636,7 +2742,9 @@ class RemnaWaveService: await db.commit() except Exception as sub_error: - logger.error(f'❌ Ошибка валидации подписки {subscription.id}: {sub_error}') + logger.error( + '❌ Ошибка валидации подписки', subscription_id=subscription.id, sub_error=sub_error + ) stats['errors'] += 1 await db.rollback() @@ -2645,12 +2753,16 @@ class RemnaWaveService: break logger.info( - f'🔍 Валидация завершена: проверено {stats["checked"]}, исправлено подписок {stats["fixed"]}, найдено проблем {stats["issues_found"]}, ошибок {stats["errors"]}' + '🔍 Валидация завершена: проверено исправлено подписок найдено проблем ошибок', + stats=stats['checked'], + stats_2=stats['fixed'], + stats_3=stats['issues_found'], + stats_4=stats['errors'], ) return stats except Exception as e: - logger.error(f'❌ Критическая ошибка валидации: {e}') + logger.error('❌ Критическая ошибка валидации', error=e) return {'fixed': 0, 'errors': 1, 'checked': 0, 'issues_found': 0} async def get_sync_recommendations(self, db: AsyncSession) -> dict[str, Any]: @@ -2698,7 +2810,7 @@ class RemnaWaveService: return recommendations except Exception as e: - logger.error(f'❌ Ошибка получения рекомендаций: {e}') + logger.error('❌ Ошибка получения рекомендаций', error=e) return { 'should_sync': True, 'sync_type': 'all', @@ -2724,7 +2836,7 @@ class RemnaWaveService: return status_result except Exception as e: - logger.error(f'Ошибка мониторинга статуса панели Remnawave: {e}') + logger.error('Ошибка мониторинга статуса панели Remnawave', error=e) return {'status': 'error', 'error': str(e)} async def _send_status_change_notification( @@ -2759,10 +2871,12 @@ class RemnaWaveService: await notification_service.send_remnawave_panel_status_notification(new_status, details) - logger.info(f'Отправлено уведомление об изменении статуса панели: {old_status} -> {new_status}') + logger.info( + 'Отправлено уведомление об изменении статуса панели', old_status=old_status, new_status=new_status + ) except Exception as e: - logger.error(f'Ошибка отправки уведомления об изменении статуса: {e}') + logger.error('Ошибка отправки уведомления об изменении статуса', error=e) async def send_manual_status_notification(self, bot, status: str, message: str = ''): try: @@ -2781,11 +2895,11 @@ class RemnaWaveService: await notification_service.send_remnawave_panel_status_notification(status, details) - logger.info(f'Отправлено ручное уведомление о статусе панели: {status}') + logger.info('Отправлено ручное уведомление о статусе панели', status=status) return True except Exception as e: - logger.error(f'Ошибка отправки ручного уведомления: {e}') + logger.error('Ошибка отправки ручного уведомления', error=e) return False async def get_panel_status_summary(self) -> dict[str, Any]: @@ -2822,7 +2936,7 @@ class RemnaWaveService: return summary except Exception as e: - logger.error(f'Ошибка получения сводки статуса панели: {e}') + logger.error('Ошибка получения сводки статуса панели', error=e) return { 'status': 'error', 'description': '❌ Ошибка проверки статуса', @@ -2894,17 +3008,17 @@ class RemnaWaveService: if result['api_available']: if attempt > 1: - logger.info('Панель Remnawave ответила с %s попытки', attempt) + logger.info('Панель Remnawave ответила с попытки', attempt=attempt) return result last_result = result if attempt < attempts: logger.warning( - 'Панель Remnawave недоступна (попытка %s/%s): %s', - attempt, - attempts, - result.get('api_error') or 'неизвестная ошибка', + 'Панель Remnawave недоступна (попытка /)', + attempt=attempt, + attempts=attempts, + result=result.get('api_error') or 'неизвестная ошибка', ) await asyncio.sleep(1) @@ -2912,15 +3026,12 @@ class RemnaWaveService: last_error = error if attempt < attempts: logger.warning( - 'Ошибка проверки здоровья панели (попытка %s/%s): %s', - attempt, - attempts, - error, + 'Ошибка проверки здоровья панели (попытка /)', attempt=attempt, attempts=attempts, error=error ) await asyncio.sleep(1) continue - logger.error(f'Ошибка проверки здоровья панели: {error}') + logger.error('Ошибка проверки здоровья панели', error=error) if last_result is not None: return last_result diff --git a/app/services/remnawave_sync_service.py b/app/services/remnawave_sync_service.py index f28f521d..1d111d84 100644 --- a/app/services/remnawave_sync_service.py +++ b/app/services/remnawave_sync_service.py @@ -1,10 +1,10 @@ import asyncio -import logging from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, time, timedelta from typing import Any +import structlog from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -17,7 +17,7 @@ from app.services.remnawave_service import ( from app.utils.cache import cache -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @dataclass(frozen=True) @@ -143,7 +143,7 @@ class RemnaWaveAutoSyncService: self._last_user_stats = None self._last_server_stats = None self._last_run_finished_at = datetime.utcnow() - logger.error('❌ Автосинхронизация RemnaWave: %s', message) + logger.error('❌ Автосинхронизация RemnaWave', message=message) return { 'started': True, 'success': False, @@ -158,7 +158,7 @@ class RemnaWaveAutoSyncService: self._last_user_stats = None self._last_server_stats = None self._last_run_finished_at = datetime.utcnow() - logger.exception('❌ Ошибка автосинхронизации RemnaWave: %s', error) + logger.exception('❌ Ошибка автосинхронизации RemnaWave', error=error) return { 'started': True, 'success': False, @@ -247,7 +247,7 @@ class RemnaWaveAutoSyncService: try: await cache.delete_pattern('available_countries*') except Exception as error: - logger.warning('⚠️ Не удалось очистить кеш стран после автосинхронизации: %s', error) + logger.warning('⚠️ Не удалось очистить кеш стран после автосинхронизации', error=error) return { 'created': created, diff --git a/app/services/remnawave_webhook_service.py b/app/services/remnawave_webhook_service.py index ed147146..400defde 100644 --- a/app/services/remnawave_webhook_service.py +++ b/app/services/remnawave_webhook_service.py @@ -9,11 +9,11 @@ Admin events (node, service, crm) send alerts to the admin notification chat. from __future__ import annotations import html -import logging import re from datetime import UTC, datetime from typing import Any +import structlog from aiogram import Bot from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy import delete @@ -38,7 +38,7 @@ from app.services.notification_delivery_service import NotificationType, notific from app.utils.miniapp_buttons import build_miniapp_or_callback_button -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # Mapping from locale text_key to NotificationType for unified delivery @@ -170,11 +170,11 @@ class RemnaWaveWebhookService: user_handler = self._user_handlers.get(event_name) if user_handler: if db is None: - logger.error('RemnaWave webhook: DB session required for user event %s', event_name) + logger.error('RemnaWave webhook: DB session required for user event', event_name=event_name) return False return await self._process_user_event(db, event_name, data, user_handler) - logger.debug('Unhandled RemnaWave webhook event: %s', event_name) + logger.debug('Unhandled RemnaWave webhook event', event_name=event_name) return False async def _process_user_event(self, db: AsyncSession, event_name: str, data: dict, handler: Any) -> bool: @@ -182,10 +182,10 @@ class RemnaWaveWebhookService: user, subscription = await self._resolve_user_and_subscription(db, data) if not user: logger.warning( - 'RemnaWave webhook: user not found for event %s, data telegramId=%s uuid=%s', - event_name, - data.get('telegramId'), - data.get('uuid'), + 'RemnaWave webhook: user not found for event , data telegramId= uuid', + event_name=event_name, + data=data.get('telegramId'), + data_2=data.get('uuid'), ) return False @@ -195,9 +195,9 @@ class RemnaWaveWebhookService: return True except (StaleDataError, PendingRollbackError): logger.warning( - 'RemnaWave webhook %s: entity already deleted for user %s (concurrent deletion)', - event_name, - user_id, + 'RemnaWave webhook : entity already deleted for user (concurrent deletion)', + event_name=event_name, + user_id=user_id, ) try: await db.rollback() @@ -205,7 +205,9 @@ class RemnaWaveWebhookService: pass return True except Exception: - logger.exception('Error processing RemnaWave webhook event %s for user %s', event_name, user_id) + logger.exception( + 'Error processing RemnaWave webhook event for user', event_name=event_name, user_id=user_id + ) try: await db.rollback() except Exception: @@ -215,7 +217,7 @@ class RemnaWaveWebhookService: async def _process_admin_event(self, event_name: str, data: dict) -> bool: """Format and send admin notification for infrastructure events.""" if not self._admin_service.is_enabled: - logger.debug('Admin notifications disabled, skipping event %s', event_name) + logger.debug('Admin notifications disabled, skipping event', event_name=event_name) return True title = self._admin_handlers.get(event_name, event_name) @@ -272,7 +274,7 @@ class RemnaWaveWebhookService: await self._admin_service.send_webhook_notification('\n'.join(lines)) return True except Exception: - logger.exception('Failed to send admin notification for event %s', event_name) + logger.exception('Failed to send admin notification for event', event_name=event_name) return False # ------------------------------------------------------------------ @@ -398,25 +400,25 @@ class RemnaWaveWebhookService: per-event toggles from Settings. """ if not settings.WEBHOOK_NOTIFY_USER_ENABLED: - logger.debug('Webhook user notifications disabled globally, skipping %s', text_key) + logger.debug('Webhook user notifications disabled globally, skipping', text_key=text_key) return setting_key = _TEXT_KEY_TO_SETTING.get(text_key) if setting_key and not getattr(settings, setting_key, True): - logger.debug('Webhook notification %s disabled via %s', text_key, setting_key) + logger.debug('Webhook notification disabled via', text_key=text_key, setting_key=setting_key) return texts = get_texts(user.language) message = texts.get(text_key) if not message: - logger.warning('Missing locale key %s for language %s', text_key, user.language) + logger.warning('Missing locale key for language', text_key=text_key, language=user.language) return if format_kwargs: try: message = message.format(**format_kwargs) except (KeyError, IndexError): - logger.warning('Failed to format message %s with kwargs %s', text_key, format_kwargs) + logger.warning('Failed to format message with kwargs', text_key=text_key, format_kwargs=format_kwargs) return # Append "Close" button to every webhook notification keyboard @@ -431,7 +433,7 @@ class RemnaWaveWebhookService: notification_type = _TEXT_KEY_TO_NOTIFICATION_TYPE.get(text_key) if not notification_type: - logger.warning('No NotificationType mapping for text_key %s', text_key) + logger.warning('No NotificationType mapping for text_key', text_key=text_key) return context = {'text_key': text_key, **(format_kwargs or {})} @@ -446,7 +448,7 @@ class RemnaWaveWebhookService: telegram_markup=reply_markup, ) except Exception: - logger.exception('Notification delivery failed for user %s, text_key %s', user.id, text_key) + logger.exception('Notification delivery failed for user , text_key', user_id=user.id, text_key=text_key) # ------------------------------------------------------------------ # Webhook timestamp helper @@ -468,7 +470,7 @@ class RemnaWaveWebhookService: self._stamp_webhook_update(subscription) if subscription.status != SubscriptionStatus.EXPIRED.value: await expire_subscription(db, subscription) - logger.info('Webhook: subscription %s expired for user %s', subscription.id, user.id) + logger.info('Webhook: subscription expired for user', subscription_id=subscription.id, user_id=user.id) else: await db.commit() @@ -481,7 +483,7 @@ class RemnaWaveWebhookService: self._stamp_webhook_update(subscription) if subscription.status != SubscriptionStatus.DISABLED.value: await deactivate_subscription(db, subscription) - logger.info('Webhook: subscription %s disabled for user %s', subscription.id, user.id) + logger.info('Webhook: subscription disabled for user', subscription_id=subscription.id, user_id=user.id) else: await db.commit() @@ -494,7 +496,9 @@ class RemnaWaveWebhookService: self._stamp_webhook_update(subscription) if subscription.status == SubscriptionStatus.DISABLED.value: await reactivate_subscription(db, subscription) - logger.info('Webhook: subscription %s re-enabled for user %s', subscription.id, user.id) + logger.info( + 'Webhook: subscription re-enabled for user', subscription_id=subscription.id, user_id=user.id + ) else: await db.commit() @@ -507,7 +511,9 @@ class RemnaWaveWebhookService: self._stamp_webhook_update(subscription) if subscription.status == SubscriptionStatus.ACTIVE.value: await deactivate_subscription(db, subscription) - logger.info('Webhook: subscription %s limited (traffic) for user %s', subscription.id, user.id) + logger.info( + 'Webhook: subscription limited (traffic) for user', subscription_id=subscription.id, user_id=user.id + ) else: await db.commit() @@ -522,7 +528,9 @@ class RemnaWaveWebhookService: # Re-enable if was disabled due to traffic limit if subscription.status == SubscriptionStatus.DISABLED.value: await reactivate_subscription(db, subscription) - logger.info('Webhook: traffic reset for subscription %s, user %s', subscription.id, user.id) + logger.info( + 'Webhook: traffic reset for subscription , user', subscription_id=subscription.id, user_id=user.id + ) await self._notify_user(user, 'WEBHOOK_SUB_TRAFFIC_RESET', reply_markup=self._get_subscription_keyboard(user)) @@ -578,10 +586,10 @@ class RemnaWaveWebhookService: subscription.status = SubscriptionStatus.ACTIVE.value changed = True logger.info( - 'Webhook: subscription %s reactivated (%s → active) for user %s', - subscription.id, - subscription.status, - user.id, + 'Webhook: subscription reactivated (→ active) for user', + subscription_id=subscription.id, + subscription_status=subscription.status, + user_id=user.id, ) elif panel_status == 'DISABLED': if subscription.status != SubscriptionStatus.DISABLED.value: @@ -602,7 +610,11 @@ class RemnaWaveWebhookService: self._stamp_webhook_update(subscription) if changed: subscription.updated_at = datetime.now(UTC).replace(tzinfo=None) - logger.info('Webhook: subscription %s modified (synced from panel) for user %s', subscription.id, user.id) + logger.info( + 'Webhook: subscription modified (synced from panel) for user', + subscription_id=subscription.id, + user_id=user.id, + ) await db.commit() async def _handle_user_deleted( @@ -623,9 +635,9 @@ class RemnaWaveWebhookService: except Exception: # Subscription was cascade-deleted, re-fetch user and skip subscription updates logger.warning( - 'Webhook: subscription %s already deleted for user %s, skipping subscription cleanup', - sub_id, - user_id, + 'Webhook: subscription already deleted for user , skipping subscription cleanup', + sub_id=sub_id, + user_id=user_id, ) subscription = None try: @@ -636,19 +648,19 @@ class RemnaWaveWebhookService: try: user = await get_user_by_id(db, user_id) except Exception: - logger.error('Webhook: user %s not found after rollback', user_id) + logger.error('Webhook: user not found after rollback', user_id=user_id) return if not user: - logger.error('Webhook: user %s not found after rollback', user_id) + logger.error('Webhook: user not found after rollback', user_id=user_id) return if subscription: if subscription.status != SubscriptionStatus.EXPIRED.value: subscription.status = SubscriptionStatus.EXPIRED.value logger.info( - 'Webhook: subscription %s marked expired (user deleted in panel) for user %s', - sub_id, - user_id, + 'Webhook: subscription marked expired (user deleted in panel) for user', + sub_id=sub_id, + user_id=user_id, ) # Clear subscription data — panel user no longer exists @@ -693,7 +705,9 @@ class RemnaWaveWebhookService: if changed: subscription.updated_at = datetime.now(UTC).replace(tzinfo=None) logger.info( - 'Webhook: subscription %s credentials revoked/updated for user %s', subscription.id, user.id + 'Webhook: subscription credentials revoked/updated for user', + subscription_id=subscription.id, + user_id=user.id, ) await db.commit() @@ -702,7 +716,7 @@ class RemnaWaveWebhookService: async def _handle_user_created( self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict ) -> None: - logger.info('Webhook: user %s created externally in panel (uuid=%s)', user.id, data.get('uuid')) + logger.info('Webhook: user created externally in panel (uuid=)', user_id=user.id, data=data.get('uuid')) async def _handle_expires_in_72h( self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict @@ -727,7 +741,7 @@ class RemnaWaveWebhookService: async def _handle_first_connected( self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict ) -> None: - logger.info('Webhook: user %s first VPN connection', user.id) + logger.info('Webhook: user first VPN connection', user_id=user.id) await self._notify_user(user, 'WEBHOOK_SUB_FIRST_CONNECTED', reply_markup=self._get_subscription_keyboard(user)) async def _handle_bandwidth_threshold( @@ -754,7 +768,7 @@ class RemnaWaveWebhookService: async def _handle_user_not_connected( self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict ) -> None: - logger.info('Webhook: user %s has not connected to VPN', user.id) + logger.info('Webhook: user has not connected to VPN', user_id=user.id) await self._notify_user(user, 'WEBHOOK_USER_NOT_CONNECTED', reply_markup=self._get_connect_keyboard(user)) # ------------------------------------------------------------------ @@ -797,7 +811,7 @@ class RemnaWaveWebhookService: self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict ) -> None: device_name = self._extract_device_name(data) - logger.info('Webhook: device added for user %s: %s', user.id, device_name or '(empty)') + logger.info('Webhook: device added for user', user_id=user.id, device_name=device_name or '(empty)') await self._notify_user( user, 'WEBHOOK_DEVICE_ADDED', @@ -809,7 +823,7 @@ class RemnaWaveWebhookService: self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict ) -> None: device_name = self._extract_device_name(data) - logger.info('Webhook: device deleted for user %s: %s', user.id, device_name or '(empty)') + logger.info('Webhook: device deleted for user', user_id=user.id, device_name=device_name or '(empty)') await self._notify_user( user, 'WEBHOOK_DEVICE_DELETED', diff --git a/app/services/reporting_service.py b/app/services/reporting_service.py index 7e22adbc..9d77379a 100644 --- a/app/services/reporting_service.py +++ b/app/services/reporting_service.py @@ -1,11 +1,11 @@ import asyncio -import logging from dataclasses import dataclass from datetime import UTC, date, datetime, time as datetime_time, timedelta from enum import Enum from html import escape from zoneinfo import ZoneInfo +import structlog from aiogram import Bot from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from sqlalchemy import cast, func, not_, or_, select @@ -28,7 +28,7 @@ from app.database.models import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class ReportingServiceError(RuntimeError): @@ -84,10 +84,7 @@ class ReportingService: return self._task = asyncio.create_task(self._auto_daily_loop(send_time)) - logger.info( - '📊 Сервис отчетов запущен: ежедневная отправка в %s по МСК', - send_time.strftime('%H:%M'), - ) + logger.info('📊 Сервис отчетов запущен: ежедневная отправка в по МСК', send_time=send_time.strftime('%H:%M')) async def stop(self) -> None: if self._task and not self._task.done(): @@ -129,14 +126,11 @@ class ReportingService: report_date=report_date, send_to_topic=True, ) - logger.info( - '📊 Автоматический отчет за %s отправлен', - report_date.strftime('%d.%m.%Y'), - ) + logger.info('📊 Автоматический отчет за отправлен', report_date=report_date.strftime('%d.%m.%Y')) except asyncio.CancelledError: raise except Exception as exc: - logger.error('Ошибка автоматической отправки отчета: %s', exc) + logger.error('Ошибка автоматической отправки отчета', exc=exc) next_run_utc, report_date = self._calculate_next_run(send_time) @@ -144,7 +138,7 @@ class ReportingService: logger.info('Сервис отчетов остановлен') raise except Exception as exc: - logger.error('Критическая ошибка в сервисе отчетов: %s', exc) + logger.error('Критическая ошибка в сервисе отчетов', exc=exc) def _calculate_next_run( self, @@ -177,7 +171,7 @@ class ReportingService: parse_mode='HTML', ) except (TelegramBadRequest, TelegramForbiddenError) as exc: - logger.error('Не удалось отправить отчет: %s', exc) + logger.error('Не удалось отправить отчет', exc=exc) raise ReportingServiceError('Не удалось отправить отчет в чат') from exc # ---------- referral helpers ---------- diff --git a/app/services/server_status_service.py b/app/services/server_status_service.py index e88e57cd..54ab826a 100644 --- a/app/services/server_status_service.py +++ b/app/services/server_status_service.py @@ -1,15 +1,15 @@ from __future__ import annotations -import logging import re from dataclasses import dataclass import aiohttp +import structlog from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @dataclass @@ -35,7 +35,7 @@ class ServerStatusService: _FLAG_PATTERN = re.compile(r'^([\U0001F1E6-\U0001F1FF]{2})\s*(.*)$') def __init__(self) -> None: - self._logger = logging.getLogger(self.__class__.__name__) + pass async def get_servers(self) -> list[ServerStatusEntry]: mode = settings.get_server_status_mode() diff --git a/app/services/startup_notification_service.py b/app/services/startup_notification_service.py index fc7d26aa..1d8b77d4 100644 --- a/app/services/startup_notification_service.py +++ b/app/services/startup_notification_service.py @@ -4,10 +4,10 @@ Отправляет красивое сообщение с информацией о системе при запуске бота. """ -import logging from datetime import datetime from typing import Final +import structlog from aiogram import Bot from aiogram.enums import ParseMode from aiogram.types import BufferedInputFile, InlineKeyboardButton, InlineKeyboardMarkup @@ -20,7 +20,7 @@ from app.external.remnawave_api import RemnaWaveAPI, test_api_connection from app.utils.timezone import format_local_datetime -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # Константы DEFAULT_VERSION: Final[str] = 'dev' @@ -91,7 +91,7 @@ class StartupNotificationService: result = await db.execute(select(func.count(User.id)).where(User.status == UserStatus.ACTIVE.value)) return result.scalar() or 0 except Exception as e: - logger.error('Ошибка получения количества пользователей: %s', e) + logger.error('Ошибка получения количества пользователей', e=e) return 0 async def _get_total_balance(self) -> int: @@ -105,7 +105,7 @@ class StartupNotificationService: ) return result.scalar() or 0 except Exception as e: - logger.error('Ошибка получения суммы балансов: %s', e) + logger.error('Ошибка получения суммы балансов', e=e) return 0 async def _get_open_tickets_count(self) -> int: @@ -115,7 +115,7 @@ class StartupNotificationService: result = await db.execute(select(func.count(Ticket.id)).where(Ticket.status == TicketStatus.OPEN.value)) return result.scalar() or 0 except Exception as e: - logger.error('Ошибка получения количества открытых тикетов: %s', e) + logger.error('Ошибка получения количества открытых тикетов', e=e) return 0 async def _get_paid_subscriptions_count(self) -> int: @@ -130,7 +130,7 @@ class StartupNotificationService: ) return result.scalar() or 0 except Exception as e: - logger.error('Ошибка получения количества платных подписок: %s', e) + logger.error('Ошибка получения количества платных подписок', e=e) return 0 async def _get_trial_subscriptions_count(self) -> int: @@ -140,7 +140,7 @@ class StartupNotificationService: result = await db.execute(select(func.count(Subscription.id)).where(Subscription.is_trial == True)) return result.scalar() or 0 except Exception as e: - logger.error('Ошибка получения количества триальных подписок: %s', e) + logger.error('Ошибка получения количества триальных подписок', e=e) return 0 async def _check_remnawave_connection(self) -> tuple[bool, str]: @@ -181,7 +181,7 @@ class StartupNotificationService: return False, 'Недоступна' except Exception as e: - logger.error('Ошибка проверки соединения с Remnawave: %s', e) + logger.error('Ошибка проверки соединения с Remnawave', e=e) return False, 'Ошибка подключения' def _format_balance(self, kopeks: int) -> str: @@ -272,11 +272,11 @@ class StartupNotificationService: message_kwargs['message_thread_id'] = self.topic_id await self.bot.send_message(**message_kwargs) - logger.info('Стартовое уведомление отправлено в чат %s', self.chat_id) + logger.info('Стартовое уведомление отправлено в чат', chat_id=self.chat_id) return True except Exception as e: - logger.error('Ошибка отправки стартового уведомления: %s', e) + logger.error('Ошибка отправки стартового уведомления', e=e) return False @@ -452,9 +452,9 @@ async def send_crash_notification(bot: Bot, error: Exception, traceback_str: str message_kwargs['message_thread_id'] = topic_id await bot.send_document(**message_kwargs) - logger.info('Уведомление о падении отправлено в чат %s', chat_id) + logger.info('Уведомление о падении отправлено в чат', chat_id=chat_id) return True except Exception as e: - logger.error('Ошибка отправки уведомления о падении: %s', e) + logger.error('Ошибка отправки уведомления о падении', e=e) return False diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 9808cf0c..ef10ed44 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -2,9 +2,9 @@ from __future__ import annotations -import logging from dataclasses import dataclass +import structlog from aiogram import Bot from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession @@ -31,7 +31,7 @@ from app.utils.pricing_utils import format_period_description from app.utils.timezone import format_local_datetime -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _format_user_id(user: User) -> str: @@ -75,8 +75,8 @@ async def _prepare_auto_purchase( period_days = int(cart_data.get('period_days') or 0) if period_days <= 0: logger.info( - '🔁 Автопокупка: у пользователя %s нет корректного периода в сохранённой корзине', - _format_user_id(user), + '🔁 Автопокупка: у пользователя нет корректного периода в сохранённой корзине', + format_user_id=_format_user_id(user), ) return None @@ -84,10 +84,7 @@ async def _prepare_auto_purchase( # т.к. после db.refresh() в payment-сервисах связи сбрасываются fresh_user = await get_user_by_id(db, user.id) if not fresh_user: - logger.warning( - '🔁 Автопокупка: не удалось перезагрузить пользователя %s', - _format_user_id(user), - ) + logger.warning('🔁 Автопокупка: не удалось перезагрузить пользователя', format_user_id=_format_user_id(user)) return None user = fresh_user @@ -97,9 +94,9 @@ async def _prepare_auto_purchase( period_config = context.period_map.get(f'days:{period_days}') if not period_config: logger.warning( - '🔁 Автопокупка: период %s дней недоступен для пользователя %s', - period_days, - _format_user_id(user), + '🔁 Автопокупка: период дней недоступен для пользователя', + period_days=period_days, + format_user_id=_format_user_id(user), ) return None @@ -162,9 +159,9 @@ async def _get_tariff_price_for_period( tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: logger.warning( - '🔁 Автопокупка: тариф %s недоступен для пользователя %s', - tariff_id, - _format_user_id(user), + '🔁 Автопокупка: тариф недоступен для пользователя', + tariff_id=tariff_id, + format_user_id=_format_user_id(user), ) return None @@ -172,9 +169,7 @@ async def _get_tariff_price_for_period( base_price = prices.get(str(period_days)) if base_price is None: logger.warning( - '🔁 Автопокупка: период %s дней недоступен для тарифа %s', - period_days, - tariff_id, + '🔁 Автопокупка: период дней недоступен для тарифа', period_days=period_days, tariff_id=tariff_id ) return None @@ -201,8 +196,7 @@ async def _prepare_auto_extend_context( subscription = await get_subscription_by_user_id(db, user.id) if subscription is None: logger.info( - '🔁 Автопокупка: у пользователя %s нет активной подписки для продления', - _format_user_id(user), + '🔁 Автопокупка: у пользователя нет активной подписки для продления', format_user_id=_format_user_id(user) ) return None @@ -211,10 +205,10 @@ async def _prepare_auto_extend_context( saved_subscription_id = _safe_int(saved_subscription_id, subscription.id) if saved_subscription_id != subscription.id: logger.warning( - '🔁 Автопокупка: сохранённая подписка %s не совпадает с текущей %s у пользователя %s', - saved_subscription_id, - subscription.id, - _format_user_id(user), + '🔁 Автопокупка: сохранённая подписка не совпадает с текущей у пользователя', + saved_subscription_id=saved_subscription_id, + subscription_id=subscription.id, + format_user_id=_format_user_id(user), ) return None @@ -222,9 +216,9 @@ async def _prepare_auto_extend_context( if period_days <= 0: logger.warning( - '🔁 Автопокупка: некорректное количество дней продления (%s) у пользователя %s', - period_days, - _format_user_id(user), + '🔁 Автопокупка: некорректное количество дней продления у пользователя', + period_days=period_days, + format_user_id=_format_user_id(user), ) return None @@ -239,9 +233,9 @@ async def _prepare_auto_extend_context( cart_data.get('total_price') or cart_data.get('price') or cart_data.get('final_price'), ) logger.warning( - '🔁 Автопокупка: не удалось пересчитать цену тарифа %s, используем сохранённую: %s', - tariff_id, - price_kopeks, + '🔁 Автопокупка: не удалось пересчитать цену тарифа , используем сохранённую', + tariff_id=tariff_id, + price_kopeks=price_kopeks, ) # Добавляем стоимость докупленных устройств при продлении того же тарифа elif subscription.tariff_id == tariff_id: @@ -263,9 +257,9 @@ async def _prepare_auto_extend_context( if price_kopeks <= 0: logger.warning( - '🔁 Автопокупка: некорректная цена продления (%s) у пользователя %s', - price_kopeks, - _format_user_id(user), + '🔁 Автопокупка: некорректная цена продления у пользователя', + price_kopeks=price_kopeks, + format_user_id=_format_user_id(user), ) return None @@ -354,9 +348,9 @@ async def _auto_extend_subscription( prepared = await _prepare_auto_extend_context(db, user, cart_data) except Exception as error: # pragma: no cover - defensive logging logger.error( - '❌ Автопокупка: ошибка подготовки данных продления для пользователя %s: %s', - _format_user_id(user), - error, + '❌ Автопокупка: ошибка подготовки данных продления для пользователя', + format_user_id=_format_user_id(user), + error=error, exc_info=True, ) return False @@ -366,10 +360,10 @@ async def _auto_extend_subscription( if user.balance_kopeks < prepared.price_kopeks: logger.info( - '🔁 Автопокупка: у пользователя %s недостаточно средств для продления (%s < %s)', - _format_user_id(user), - user.balance_kopeks, - prepared.price_kopeks, + '🔁 Автопокупка: у пользователя недостаточно средств для продления (<)', + format_user_id=_format_user_id(user), + balance_kopeks=user.balance_kopeks, + price_kopeks=prepared.price_kopeks, ) return False @@ -383,17 +377,17 @@ async def _auto_extend_subscription( ) except Exception as error: # pragma: no cover - defensive logging logger.error( - '❌ Автопокупка: ошибка списания средств при продлении пользователя %s: %s', - _format_user_id(user), - error, + '❌ Автопокупка: ошибка списания средств при продлении пользователя', + format_user_id=_format_user_id(user), + error=error, exc_info=True, ) return False if not deducted: logger.warning( - '❌ Автопокупка: списание средств для продления подписки пользователя %s не выполнено', - _format_user_id(user), + '❌ Автопокупка: списание средств для продления подписки пользователя не выполнено', + format_user_id=_format_user_id(user), ) return False @@ -425,16 +419,16 @@ async def _auto_extend_subscription( user.has_had_paid_subscription = True await db.commit() logger.info( - '✅ Триал конвертирован в платную подписку %s для пользователя %s', - subscription.id, - _format_user_id(user), + '✅ Триал конвертирован в платную подписку для пользователя', + subscription_id=subscription.id, + format_user_id=_format_user_id(user), ) except Exception as error: # pragma: no cover - defensive logging logger.error( - '❌ Автопокупка: не удалось продлить подписку пользователя %s: %s', - _format_user_id(user), - error, + '❌ Автопокупка: не удалось продлить подписку пользователя', + format_user_id=_format_user_id(user), + error=error, exc_info=True, ) # НОВОЕ: Откатываем изменения при ошибке @@ -452,9 +446,9 @@ async def _auto_extend_subscription( ) except Exception as error: # pragma: no cover - defensive logging logger.error( - '⚠️ Автопокупка: не удалось зафиксировать транзакцию продления для пользователя %s: %s', - _format_user_id(user), - error, + '⚠️ Автопокупка: не удалось зафиксировать транзакцию продления для пользователя', + format_user_id=_format_user_id(user), + error=error, exc_info=True, ) @@ -470,9 +464,9 @@ async def _auto_extend_subscription( ) except Exception as error: # pragma: no cover - defensive logging logger.error( - '⚠️ Автопокупка: не удалось обновить RemnaWave пользователя %s после продления: %s', - _format_user_id(user), - error, + '⚠️ Автопокупка: не удалось обновить RemnaWave пользователя после продления', + format_user_id=_format_user_id(user), + error=error, ) await user_cart_service.delete_user_cart(user.id) @@ -501,9 +495,9 @@ async def _auto_extend_subscription( ) except Exception as error: # pragma: no cover - defensive logging logger.error( - '⚠️ Автопокупка: не удалось уведомить администраторов о продлении пользователя %s: %s', - _format_user_id(user), - error, + '⚠️ Автопокупка: не удалось уведомить администраторов о продлении пользователя', + format_user_id=_format_user_id(user), + error=error, ) # Send user notification only for Telegram users @@ -551,15 +545,15 @@ async def _auto_extend_subscription( ) except Exception as error: # pragma: no cover - defensive logging logger.error( - '⚠️ Автопокупка: не удалось уведомить пользователя %s о продлении: %s', - user.telegram_id or user.id, - error, + '⚠️ Автопокупка: не удалось уведомить пользователя о продлении', + telegram_id=user.telegram_id or user.id, + error=error, ) logger.info( - '✅ Автопокупка: подписка продлена на %s дней для пользователя %s', - prepared.period_days, - _format_user_id(user), + '✅ Автопокупка: подписка продлена на дней для пользователя', + period_days=prepared.period_days, + format_user_id=_format_user_id(user), ) # Send WebSocket notification to cabinet frontend @@ -571,9 +565,9 @@ async def _auto_extend_subscription( ) except Exception as ws_error: logger.warning( - '⚠️ Автопокупка: не удалось отправить WS уведомление о продлении для %s: %s', - _format_user_id(user), - ws_error, + '⚠️ Автопокупка: не удалось отправить WS уведомление о продлении для', + format_user_id=_format_user_id(user), + ws_error=ws_error, ) return True @@ -609,19 +603,19 @@ async def _auto_purchase_tariff( if not tariff_id or period_days <= 0: logger.warning( - '🔁 Автопокупка тарифа: некорректные данные корзины для пользователя %s (tariff_id=%s, period=%s)', - _format_user_id(user), - tariff_id, - period_days, + '🔁 Автопокупка тарифа: некорректные данные корзины для пользователя (tariff_id period=)', + format_user_id=_format_user_id(user), + tariff_id=tariff_id, + period_days=period_days, ) return False tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: logger.warning( - '🔁 Автопокупка тарифа: тариф %s недоступен для пользователя %s', - tariff_id, - _format_user_id(user), + '🔁 Автопокупка тарифа: тариф недоступен для пользователя', + tariff_id=tariff_id, + format_user_id=_format_user_id(user), ) return False @@ -630,9 +624,7 @@ async def _auto_purchase_tariff( base_price = prices.get(str(period_days)) if base_price is None: logger.warning( - '🔁 Автопокупка тарифа: период %s дней недоступен для тарифа %s', - period_days, - tariff_id, + '🔁 Автопокупка тарифа: период дней недоступен для тарифа', period_days=period_days, tariff_id=tariff_id ) return False @@ -654,10 +646,10 @@ async def _auto_purchase_tariff( if user.balance_kopeks < final_price: logger.info( - '🔁 Автопокупка тарифа: у пользователя %s недостаточно средств (%s < %s)', - _format_user_id(user), - user.balance_kopeks, - final_price, + '🔁 Автопокупка тарифа: у пользователя недостаточно средств (<)', + format_user_id=_format_user_id(user), + balance_kopeks=user.balance_kopeks, + final_price=final_price, ) return False @@ -667,15 +659,14 @@ async def _auto_purchase_tariff( success = await subtract_user_balance(db, user, final_price, description) if not success: logger.warning( - '❌ Автопокупка тарифа: не удалось списать баланс пользователя %s', - _format_user_id(user), + '❌ Автопокупка тарифа: не удалось списать баланс пользователя', format_user_id=_format_user_id(user) ) return False except Exception as error: logger.error( - '❌ Автопокупка тарифа: ошибка списания баланса пользователя %s: %s', - _format_user_id(user), - error, + '❌ Автопокупка тарифа: ошибка списания баланса пользователя', + format_user_id=_format_user_id(user), + error=error, exc_info=True, ) return False @@ -723,9 +714,9 @@ async def _auto_purchase_tariff( was_trial_conversion = False except Exception as error: logger.error( - '❌ Автопокупка тарифа: ошибка создания подписки для пользователя %s: %s', - _format_user_id(user), - error, + '❌ Автопокупка тарифа: ошибка создания подписки для пользователя', + format_user_id=_format_user_id(user), + error=error, exc_info=True, ) await db.rollback() @@ -742,9 +733,9 @@ async def _auto_purchase_tariff( ) except Exception as error: logger.warning( - '⚠️ Автопокупка тарифа: не удалось создать транзакцию для пользователя %s: %s', - _format_user_id(user), - error, + '⚠️ Автопокупка тарифа: не удалось создать транзакцию для пользователя', + format_user_id=_format_user_id(user), + error=error, ) transaction = None @@ -760,9 +751,9 @@ async def _auto_purchase_tariff( ) except Exception as error: logger.warning( - '⚠️ Автопокупка тарифа: не удалось обновить Remnawave для пользователя %s: %s', - _format_user_id(user), - error, + '⚠️ Автопокупка тарифа: не удалось обновить Remnawave для пользователя', + format_user_id=_format_user_id(user), + error=error, ) # Очищаем корзину @@ -781,9 +772,9 @@ async def _auto_purchase_tariff( ) except Exception as error: logger.warning( - '⚠️ Автопокупка тарифа: не удалось уведомить админов о покупке пользователя %s: %s', - _format_user_id(user), - error, + '⚠️ Автопокупка тарифа: не удалось уведомить админов о покупке пользователя', + format_user_id=_format_user_id(user), + error=error, ) # Send user notification only for Telegram users @@ -824,16 +815,16 @@ async def _auto_purchase_tariff( ) except Exception as error: logger.warning( - '⚠️ Автопокупка тарифа: не удалось уведомить пользователя %s: %s', - user.telegram_id or user.id, - error, + '⚠️ Автопокупка тарифа: не удалось уведомить пользователя', + telegram_id=user.telegram_id or user.id, + error=error, ) logger.info( - '✅ Автопокупка тарифа: подписка на тариф %s (%s дней) оформлена для пользователя %s', - tariff.name, - period_days, - _format_user_id(user), + '✅ Автопокупка тарифа: подписка на тариф (дней) оформлена для пользователя', + tariff_name=tariff.name, + period_days=period_days, + format_user_id=_format_user_id(user), ) # Send WebSocket notification to cabinet frontend @@ -854,9 +845,9 @@ async def _auto_purchase_tariff( ) except Exception as ws_error: logger.warning( - '⚠️ Автопокупка тарифа: не удалось отправить WS уведомление для %s: %s', - _format_user_id(user), - ws_error, + '⚠️ Автопокупка тарифа: не удалось отправить WS уведомление для', + format_user_id=_format_user_id(user), + ws_error=ws_error, ) return True @@ -887,43 +878,43 @@ async def _auto_purchase_daily_tariff( tariff_id = _safe_int(cart_data.get('tariff_id')) if not tariff_id: logger.warning( - '🔁 Автопокупка суточного тарифа: нет tariff_id в корзине пользователя %s', - _format_user_id(user), + '🔁 Автопокупка суточного тарифа: нет tariff_id в корзине пользователя', + format_user_id=_format_user_id(user), ) return False tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: logger.warning( - '🔁 Автопокупка суточного тарифа: тариф %s недоступен для пользователя %s', - tariff_id, - _format_user_id(user), + '🔁 Автопокупка суточного тарифа: тариф недоступен для пользователя', + tariff_id=tariff_id, + format_user_id=_format_user_id(user), ) return False if not getattr(tariff, 'is_daily', False): logger.warning( - '🔁 Автопокупка суточного тарифа: тариф %s не является суточным для пользователя %s', - tariff_id, - _format_user_id(user), + '🔁 Автопокупка суточного тарифа: тариф не является суточным для пользователя', + tariff_id=tariff_id, + format_user_id=_format_user_id(user), ) return False daily_price = getattr(tariff, 'daily_price_kopeks', 0) if daily_price <= 0: logger.warning( - '🔁 Автопокупка суточного тарифа: некорректная цена тарифа %s для пользователя %s', - tariff_id, - _format_user_id(user), + '🔁 Автопокупка суточного тарифа: некорректная цена тарифа для пользователя', + tariff_id=tariff_id, + format_user_id=_format_user_id(user), ) return False if user.balance_kopeks < daily_price: logger.info( - '🔁 Автопокупка суточного тарифа: у пользователя %s недостаточно средств (%s < %s)', - _format_user_id(user), - user.balance_kopeks, - daily_price, + '🔁 Автопокупка суточного тарифа: у пользователя недостаточно средств (<)', + format_user_id=_format_user_id(user), + balance_kopeks=user.balance_kopeks, + daily_price=daily_price, ) return False @@ -933,15 +924,15 @@ async def _auto_purchase_daily_tariff( success = await subtract_user_balance(db, user, daily_price, description) if not success: logger.warning( - '❌ Автопокупка суточного тарифа: не удалось списать баланс пользователя %s', - _format_user_id(user), + '❌ Автопокупка суточного тарифа: не удалось списать баланс пользователя', + format_user_id=_format_user_id(user), ) return False except Exception as error: logger.error( - '❌ Автопокупка суточного тарифа: ошибка списания баланса пользователя %s: %s', - _format_user_id(user), - error, + '❌ Автопокупка суточного тарифа: ошибка списания баланса пользователя', + format_user_id=_format_user_id(user), + error=error, exc_info=True, ) return False @@ -993,9 +984,9 @@ async def _auto_purchase_daily_tariff( was_trial_conversion = False except Exception as error: logger.error( - '❌ Автопокупка суточного тарифа: ошибка создания подписки для пользователя %s: %s', - _format_user_id(user), - error, + '❌ Автопокупка суточного тарифа: ошибка создания подписки для пользователя', + format_user_id=_format_user_id(user), + error=error, exc_info=True, ) await db.rollback() @@ -1012,9 +1003,9 @@ async def _auto_purchase_daily_tariff( ) except Exception as error: logger.warning( - '⚠️ Автопокупка суточного тарифа: не удалось создать транзакцию для пользователя %s: %s', - _format_user_id(user), - error, + '⚠️ Автопокупка суточного тарифа: не удалось создать транзакцию для пользователя', + format_user_id=_format_user_id(user), + error=error, ) transaction = None @@ -1030,9 +1021,9 @@ async def _auto_purchase_daily_tariff( ) except Exception as error: logger.warning( - '⚠️ Автопокупка суточного тарифа: не удалось обновить Remnawave для пользователя %s: %s', - _format_user_id(user), - error, + '⚠️ Автопокупка суточного тарифа: не удалось обновить Remnawave для пользователя', + format_user_id=_format_user_id(user), + error=error, ) # Очищаем корзину @@ -1050,9 +1041,9 @@ async def _auto_purchase_daily_tariff( ) except Exception as error: logger.warning( - '⚠️ Автопокупка суточного тарифа: не удалось уведомить админов о покупке пользователя %s: %s', - _format_user_id(user), - error, + '⚠️ Автопокупка суточного тарифа: не удалось уведомить админов о покупке пользователя', + format_user_id=_format_user_id(user), + error=error, ) # Send user notification only for Telegram users @@ -1090,15 +1081,15 @@ async def _auto_purchase_daily_tariff( ) except Exception as error: logger.warning( - '⚠️ Автопокупка суточного тарифа: не удалось уведомить пользователя %s: %s', - user.telegram_id or user.id, - error, + '⚠️ Автопокупка суточного тарифа: не удалось уведомить пользователя', + telegram_id=user.telegram_id or user.id, + error=error, ) logger.info( - '✅ Автопокупка суточного тарифа: тариф %s активирован для пользователя %s', - tariff.name, - _format_user_id(user), + '✅ Автопокупка суточного тарифа: тариф активирован для пользователя', + tariff_name=tariff.name, + format_user_id=_format_user_id(user), ) # Send WebSocket notification to cabinet frontend @@ -1119,9 +1110,9 @@ async def _auto_purchase_daily_tariff( ) except Exception as ws_error: logger.warning( - '⚠️ Автопокупка суточного тарифа: не удалось отправить WS уведомление для %s: %s', - _format_user_id(user), - ws_error, + '⚠️ Автопокупка суточного тарифа: не удалось отправить WS уведомление для', + format_user_id=_format_user_id(user), + ws_error=ws_error, ) return True @@ -1146,38 +1137,35 @@ async def _auto_add_devices( if devices_to_add <= 0 or price_kopeks <= 0: logger.warning( - '🔁 Автопокупка устройств: некорректные данные корзины для пользователя %s (devices=%s, price=%s)', - _format_user_id(user), - devices_to_add, - price_kopeks, + '🔁 Автопокупка устройств: некорректные данные корзины для пользователя (devices price=)', + format_user_id=_format_user_id(user), + devices_to_add=devices_to_add, + price_kopeks=price_kopeks, ) return False # Проверяем баланс if user.balance_kopeks < price_kopeks: logger.info( - '🔁 Автопокупка устройств: у пользователя %s недостаточно средств (%s < %s)', - _format_user_id(user), - user.balance_kopeks, - price_kopeks, + '🔁 Автопокупка устройств: у пользователя недостаточно средств (<)', + format_user_id=_format_user_id(user), + balance_kopeks=user.balance_kopeks, + price_kopeks=price_kopeks, ) return False # Проверяем подписку subscription = await get_subscription_by_user_id(db, user.id) if not subscription: - logger.warning( - '🔁 Автопокупка устройств: у пользователя %s нет подписки', - _format_user_id(user), - ) + logger.warning('🔁 Автопокупка устройств: у пользователя нет подписки', format_user_id=_format_user_id(user)) await user_cart_service.delete_user_cart(user.id) return False if subscription.status not in ('active', 'trial', 'ACTIVE', 'TRIAL'): logger.warning( - '🔁 Автопокупка устройств: подписка пользователя %s не активна (status=%s)', - _format_user_id(user), - subscription.status, + '🔁 Автопокупка устройств: подписка пользователя не активна (status=)', + format_user_id=_format_user_id(user), + subscription_status=subscription.status, ) await user_cart_service.delete_user_cart(user.id) return False @@ -1195,15 +1183,14 @@ async def _auto_add_devices( ) if not success: logger.warning( - '❌ Автопокупка устройств: не удалось списать баланс пользователя %s', - _format_user_id(user), + '❌ Автопокупка устройств: не удалось списать баланс пользователя', format_user_id=_format_user_id(user) ) return False except Exception as error: logger.error( - '❌ Автопокупка устройств: ошибка списания баланса пользователя %s: %s', - _format_user_id(user), - error, + '❌ Автопокупка устройств: ошибка списания баланса пользователя', + format_user_id=_format_user_id(user), + error=error, exc_info=True, ) return False @@ -1217,9 +1204,9 @@ async def _auto_add_devices( await db.refresh(subscription) except Exception as error: logger.error( - '❌ Автопокупка устройств: ошибка сохранения подписки пользователя %s: %s', - _format_user_id(user), - error, + '❌ Автопокупка устройств: ошибка сохранения подписки пользователя', + format_user_id=_format_user_id(user), + error=error, exc_info=True, ) await db.rollback() @@ -1231,21 +1218,21 @@ async def _auto_add_devices( await subscription_service.update_remnawave_user(db, subscription) except Exception as error: logger.warning( - '⚠️ Автопокупка устройств: не удалось обновить Remnawave для пользователя %s: %s', - _format_user_id(user), - error, + '⚠️ Автопокупка устройств: не удалось обновить Remnawave для пользователя', + format_user_id=_format_user_id(user), + error=error, ) # Очищаем корзину (транзакция уже создана в subtract_user_balance) await user_cart_service.delete_user_cart(user.id) logger.info( - '✅ Автопокупка устройств: пользователь %s добавил %s устройств (было %s, стало %s) за %s коп.', - _format_user_id(user), - devices_to_add, - old_device_limit, - subscription.device_limit, - price_kopeks, + '✅ Автопокупка устройств: пользователь добавил устройств (было , стало) за коп.', + format_user_id=_format_user_id(user), + devices_to_add=devices_to_add, + old_device_limit=old_device_limit, + device_limit=subscription.device_limit, + price_kopeks=price_kopeks, ) # WebSocket уведомление для кабинета @@ -1259,10 +1246,7 @@ async def _auto_add_devices( amount_kopeks=price_kopeks, ) except Exception as ws_error: - logger.warning( - '⚠️ Автопокупка устройств: не удалось отправить WebSocket уведомление: %s', - ws_error, - ) + logger.warning('⚠️ Автопокупка устройств: не удалось отправить WebSocket уведомление', ws_error=ws_error) # Уведомление пользователю if bot and user.telegram_id: @@ -1307,9 +1291,7 @@ async def _auto_add_devices( ) except Exception as error: logger.warning( - '⚠️ Автопокупка устройств: не удалось уведомить пользователя %s: %s', - user.telegram_id, - error, + '⚠️ Автопокупка устройств: не удалось уведомить пользователя', telegram_id=user.telegram_id, error=error ) # Уведомление админам @@ -1326,10 +1308,7 @@ async def _auto_add_devices( price_kopeks, ) except Exception as error: - logger.warning( - '⚠️ Автопокупка устройств: не удалось уведомить админов: %s', - error, - ) + logger.warning('⚠️ Автопокупка устройств: не удалось уведомить админов', error=error) return True @@ -1353,54 +1332,47 @@ async def _auto_add_traffic( if traffic_gb <= 0 or price_kopeks <= 0: logger.warning( - '🔁 Автопокупка трафика: некорректные данные корзины для пользователя %s (traffic_gb=%s, price=%s)', - _format_user_id(user), - traffic_gb, - price_kopeks, + '🔁 Автопокупка трафика: некорректные данные корзины для пользователя (traffic_gb price=)', + format_user_id=_format_user_id(user), + traffic_gb=traffic_gb, + price_kopeks=price_kopeks, ) return False # Verify balance if user.balance_kopeks < price_kopeks: logger.info( - '🔁 Автопокупка трафика: у пользователя %s недостаточно средств (%s < %s)', - _format_user_id(user), - user.balance_kopeks, - price_kopeks, + '🔁 Автопокупка трафика: у пользователя недостаточно средств (<)', + format_user_id=_format_user_id(user), + balance_kopeks=user.balance_kopeks, + price_kopeks=price_kopeks, ) return False # Verify subscription subscription = await get_subscription_by_user_id(db, user.id) if not subscription: - logger.warning( - '🔁 Автопокупка трафика: у пользователя %s нет подписки', - _format_user_id(user), - ) + logger.warning('🔁 Автопокупка трафика: у пользователя нет подписки', format_user_id=_format_user_id(user)) await user_cart_service.delete_user_cart(user.id) return False if subscription.status not in ('active', 'trial', 'ACTIVE', 'TRIAL'): logger.warning( - '🔁 Автопокупка трафика: подписка пользователя %s не активна (status=%s)', - _format_user_id(user), - subscription.status, + '🔁 Автопокупка трафика: подписка пользователя не активна (status=)', + format_user_id=_format_user_id(user), + subscription_status=subscription.status, ) await user_cart_service.delete_user_cart(user.id) return False if subscription.is_trial: - logger.warning( - '🔁 Автопокупка трафика: у пользователя %s пробная подписка', - _format_user_id(user), - ) + logger.warning('🔁 Автопокупка трафика: у пользователя пробная подписка', format_user_id=_format_user_id(user)) await user_cart_service.delete_user_cart(user.id) return False if subscription.traffic_limit_gb == 0: logger.warning( - '🔁 Автопокупка трафика: у пользователя %s уже безлимитный трафик', - _format_user_id(user), + '🔁 Автопокупка трафика: у пользователя уже безлимитный трафик', format_user_id=_format_user_id(user) ) await user_cart_service.delete_user_cart(user.id) return False @@ -1418,15 +1390,14 @@ async def _auto_add_traffic( ) if not success: logger.warning( - '❌ Автопокупка трафика: не удалось списать баланс пользователя %s', - _format_user_id(user), + '❌ Автопокупка трафика: не удалось списать баланс пользователя', format_user_id=_format_user_id(user) ) return False except Exception as error: logger.error( - '❌ Автопокупка трафика: ошибка списания баланса пользователя %s: %s', - _format_user_id(user), - error, + '❌ Автопокупка трафика: ошибка списания баланса пользователя', + format_user_id=_format_user_id(user), + error=error, exc_info=True, ) return False @@ -1439,9 +1410,9 @@ async def _auto_add_traffic( await db.refresh(subscription) except Exception as error: logger.error( - '❌ Автопокупка трафика: ошибка добавления трафика пользователю %s: %s', - _format_user_id(user), - error, + '❌ Автопокупка трафика: ошибка добавления трафика пользователю', + format_user_id=_format_user_id(user), + error=error, exc_info=True, ) await db.rollback() @@ -1453,21 +1424,21 @@ async def _auto_add_traffic( await subscription_service.update_remnawave_user(db, subscription) except Exception as error: logger.warning( - '⚠️ Автопокупка трафика: не удалось обновить Remnawave для пользователя %s: %s', - _format_user_id(user), - error, + '⚠️ Автопокупка трафика: не удалось обновить Remnawave для пользователя', + format_user_id=_format_user_id(user), + error=error, ) # Clear cart (transaction already created in subtract_user_balance) await user_cart_service.delete_user_cart(user.id) logger.info( - '✅ Автопокупка трафика: пользователь %s добавил %s ГБ (было %s, стало %s) за %s коп.', - _format_user_id(user), - traffic_gb, - old_traffic_limit, - subscription.traffic_limit_gb, - price_kopeks, + '✅ Автопокупка трафика: пользователь добавил ГБ (было , стало) за коп.', + format_user_id=_format_user_id(user), + traffic_gb=traffic_gb, + old_traffic_limit=old_traffic_limit, + traffic_limit_gb=subscription.traffic_limit_gb, + price_kopeks=price_kopeks, ) # WebSocket notification for cabinet @@ -1481,10 +1452,7 @@ async def _auto_add_traffic( amount_kopeks=price_kopeks, ) except Exception as ws_error: - logger.warning( - '⚠️ Автопокупка трафика: не удалось отправить WebSocket уведомление: %s', - ws_error, - ) + logger.warning('⚠️ Автопокупка трафика: не удалось отправить WebSocket уведомление', ws_error=ws_error) # User notification if bot and user.telegram_id: @@ -1529,9 +1497,7 @@ async def _auto_add_traffic( ) except Exception as error: logger.warning( - '⚠️ Автопокупка трафика: не удалось уведомить пользователя %s: %s', - user.telegram_id, - error, + '⚠️ Автопокупка трафика: не удалось уведомить пользователя', telegram_id=user.telegram_id, error=error ) # Admin notification @@ -1548,10 +1514,7 @@ async def _auto_add_traffic( price_kopeks, ) except Exception as error: - logger.warning( - '⚠️ Автопокупка трафика: не удалось уведомить админов: %s', - error, - ) + logger.warning('⚠️ Автопокупка трафика: не удалось уведомить админов', error=error) return True @@ -1582,7 +1545,7 @@ async def auto_purchase_saved_cart_after_topup( if not cart_data: return False - logger.info('🔁 Автопокупка: обнаружена сохранённая корзина у пользователя %s', _format_user_id(user)) + logger.info('🔁 Автопокупка: обнаружена сохранённая корзина у пользователя', format_user_id=_format_user_id(user)) cart_mode = cart_data.get('cart_mode') or cart_data.get('mode') @@ -1599,18 +1562,18 @@ async def auto_purchase_saved_cart_after_topup( and (datetime.utcnow() - last_tx.created_at) < timedelta(seconds=60) ): logger.info( - '🔁 Автопокупка: пропускаем для пользователя %s - подписка уже куплена %s секунд назад', - _format_user_id(user), - (datetime.utcnow() - last_tx.created_at).total_seconds(), + '🔁 Автопокупка: пропускаем для пользователя - подписка уже куплена секунд назад', + format_user_id=_format_user_id(user), + total_seconds=(datetime.utcnow() - last_tx.created_at).total_seconds(), ) # Очищаем корзину чтобы не срабатывало повторно await user_cart_service.delete_user_cart(user.id) return False except Exception as check_error: logger.warning( - '🔁 Автопокупка: ошибка проверки последней транзакции для %s: %s', - _format_user_id(user), - check_error, + '🔁 Автопокупка: ошибка проверки последней транзакции для', + format_user_id=_format_user_id(user), + check_error=check_error, ) # Обработка продления подписки @@ -1637,16 +1600,14 @@ async def auto_purchase_saved_cart_after_topup( prepared = await _prepare_auto_purchase(db, user, cart_data) except PurchaseValidationError as error: logger.error( - '❌ Автопокупка: ошибка валидации корзины пользователя %s: %s', - _format_user_id(user), - error, + '❌ Автопокупка: ошибка валидации корзины пользователя', format_user_id=_format_user_id(user), error=error ) return False except Exception as error: # pragma: no cover - defensive logging logger.error( - '❌ Автопокупка: непредвиденная ошибка при подготовке корзины %s: %s', - _format_user_id(user), - error, + '❌ Автопокупка: непредвиденная ошибка при подготовке корзины', + format_user_id=_format_user_id(user), + error=error, exc_info=True, ) return False @@ -1659,18 +1620,18 @@ async def auto_purchase_saved_cart_after_topup( if pricing.final_total <= 0: logger.warning( - '❌ Автопокупка: итоговая сумма для пользователя %s некорректна (%s)', - _format_user_id(user), - pricing.final_total, + '❌ Автопокупка: итоговая сумма для пользователя некорректна', + format_user_id=_format_user_id(user), + final_total=pricing.final_total, ) return False if user.balance_kopeks < pricing.final_total: logger.info( - '🔁 Автопокупка: у пользователя %s недостаточно средств (%s < %s)', - _format_user_id(user), - user.balance_kopeks, - pricing.final_total, + '🔁 Автопокупка: у пользователя недостаточно средств (<)', + format_user_id=_format_user_id(user), + balance_kopeks=user.balance_kopeks, + final_total=pricing.final_total, ) return False @@ -1684,22 +1645,21 @@ async def auto_purchase_saved_cart_after_topup( ) except PurchaseBalanceError: logger.info( - '🔁 Автопокупка: баланс пользователя %s изменился и стал недостаточным', - _format_user_id(user), + '🔁 Автопокупка: баланс пользователя изменился и стал недостаточным', format_user_id=_format_user_id(user) ) return False except PurchaseValidationError as error: logger.error( - '❌ Автопокупка: не удалось подтвердить корзину пользователя %s: %s', - _format_user_id(user), - error, + '❌ Автопокупка: не удалось подтвердить корзину пользователя', + format_user_id=_format_user_id(user), + error=error, ) return False except Exception as error: # pragma: no cover - defensive logging logger.error( - '❌ Автопокупка: ошибка оформления подписки для пользователя %s: %s', - _format_user_id(user), - error, + '❌ Автопокупка: ошибка оформления подписки для пользователя', + format_user_id=_format_user_id(user), + error=error, exc_info=True, ) return False @@ -1725,9 +1685,9 @@ async def auto_purchase_saved_cart_after_topup( ) except Exception as error: # pragma: no cover - defensive logging logger.error( - '⚠️ Автопокупка: не удалось отправить уведомление админам (%s): %s', - _format_user_id(user), - error, + '⚠️ Автопокупка: не удалось отправить уведомление админам', + format_user_id=_format_user_id(user), + error=error, ) # Send user notification only for Telegram users @@ -1777,15 +1737,15 @@ async def auto_purchase_saved_cart_after_topup( ) except Exception as error: # pragma: no cover - defensive logging logger.error( - '⚠️ Автопокупка: не удалось уведомить пользователя %s: %s', - user.telegram_id or user.id, - error, + '⚠️ Автопокупка: не удалось уведомить пользователя', + telegram_id=user.telegram_id or user.id, + error=error, ) logger.info( - '✅ Автопокупка: подписка на %s дней оформлена для пользователя %s', - selection.period.days, - _format_user_id(user), + '✅ Автопокупка: подписка на дней оформлена для пользователя', + days=selection.period.days, + format_user_id=_format_user_id(user), ) # Send WebSocket notification to cabinet frontend @@ -1806,9 +1766,9 @@ async def auto_purchase_saved_cart_after_topup( ) except Exception as ws_error: logger.warning( - '⚠️ Автопокупка: не удалось отправить WS уведомление для %s: %s', - _format_user_id(user), - ws_error, + '⚠️ Автопокупка: не удалось отправить WS уведомление для', + format_user_id=_format_user_id(user), + ws_error=ws_error, ) return True diff --git a/app/services/subscription_checkout_service.py b/app/services/subscription_checkout_service.py index 6d2ad17d..1d7a7fcd 100644 --- a/app/services/subscription_checkout_service.py +++ b/app/services/subscription_checkout_service.py @@ -1,12 +1,11 @@ -import logging - +import structlog from sqlalchemy.exc import MissingGreenlet from app.database.models import Subscription, User from app.utils.cache import UserCache -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) _CHECKOUT_SESSION_KEY = 'subscription_checkout' @@ -57,9 +56,9 @@ def should_offer_checkout_resume( subscription = getattr(user, 'subscription', None) except MissingGreenlet as error: logger.warning( - 'Не удалось лениво загрузить подписку пользователя %s при проверке возврата к checkout: %s', - getattr(user, 'id', None), - error, + 'Не удалось лениво загрузить подписку пользователя при проверке возврата к checkout', + getattr=getattr(user, 'id', None), + error=error, ) subscription = None diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py index 20b389f1..d7c3b395 100644 --- a/app/services/subscription_purchase_service.py +++ b/app/services/subscription_purchase_service.py @@ -1,9 +1,9 @@ -import logging from collections.abc import Sequence from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Any +import structlog from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -35,7 +35,7 @@ from app.utils.promo_offer import get_user_active_promo_discount_percent from app.utils.user_utils import mark_user_as_had_paid_subscription -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @dataclass @@ -349,7 +349,7 @@ class MiniAppSubscriptionPurchaseService: try: existing = await get_server_squad_by_uuid(db, uuid) except Exception as error: # pragma: no cover - defensive logging - logger.warning('Failed to load server squad %s: %s', uuid, error) + logger.warning('Failed to load server squad', uuid=uuid, error=error) existing = None if existing: server_catalog[uuid] = existing @@ -1053,9 +1053,9 @@ class MiniAppSubscriptionPurchaseService: await db.refresh(subscription) except Exception as refresh_error: # pragma: no cover - defensive logging logger.warning( - 'Failed to refresh existing subscription %s: %s', - getattr(subscription, 'id', None), - refresh_error, + 'Failed to refresh existing subscription', + getattr=getattr(subscription, 'id', None), + refresh_error=refresh_error, ) else: result = await db.execute(select(Subscription).where(Subscription.user_id == user.id)) @@ -1085,7 +1085,7 @@ class MiniAppSubscriptionPurchaseService: first_paid_period_days=pricing.selection.period.days, ) except Exception as conversion_error: # pragma: no cover - defensive logging - logger.error('Failed to create subscription conversion record: %s', conversion_error) + logger.error('Failed to create subscription conversion record', conversion_error=conversion_error) subscription.is_trial = False subscription.status = SubscriptionStatus.ACTIVE.value @@ -1128,7 +1128,7 @@ class MiniAppSubscriptionPurchaseService: ) await add_user_to_servers(db, pricing.server_ids) except Exception as error: # pragma: no cover - defensive logging - logger.error('Failed to register subscription servers: %s', error) + logger.error('Failed to register subscription servers', error=error) subscription_service = SubscriptionService() # При покупке подписки ВСЕГДА сбрасываем трафик в панели @@ -1148,7 +1148,7 @@ class MiniAppSubscriptionPurchaseService: reset_reason='miniapp purchase', ) except Exception as remnawave_error: # pragma: no cover - defensive logging - logger.error('Failed to sync subscription with RemnaWave: %s', remnawave_error) + logger.error('Failed to sync subscription with RemnaWave', remnawave_error=remnawave_error) transaction = await create_transaction( db=db, diff --git a/app/services/subscription_renewal_service.py b/app/services/subscription_renewal_service.py index c8ec39b6..d55b05fd 100644 --- a/app/services/subscription_renewal_service.py +++ b/app/services/subscription_renewal_service.py @@ -2,13 +2,13 @@ from __future__ import annotations import base64 import json -import logging from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import datetime from typing import Any from uuid import uuid4 +import structlog from aiogram import Bot from sqlalchemy.ext.asyncio import AsyncSession @@ -33,7 +33,7 @@ from app.utils.pricing_utils import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class SubscriptionRenewalError(Exception): @@ -300,7 +300,7 @@ async def with_admin_notification_service( service = AdminNotificationService(bot) await handler(service) except Exception as error: # pragma: no cover - defensive logging - logger.error('Failed to send admin notification from renewal service: %s', error) + logger.error('Failed to send admin notification from renewal service', error=error) finally: if bot is not None: await bot.session.close() @@ -371,9 +371,9 @@ class SubscriptionRenewalService: discounted_total, ): logger.warning( - 'Renewal pricing validation failed for subscription %s (period %s)', - subscription.id, - period_days, + 'Renewal pricing validation failed for subscription (period)', + subscription_id=subscription.id, + period_days=period_days, ) from app.utils.promo_offer import get_user_active_promo_discount_percent @@ -467,10 +467,10 @@ class SubscriptionRenewalService: ) except Exception as refund_error: logger.critical( - 'CRITICAL: Failed to refund %s kopeks to user %s after extension failure: %s', - charge_from_balance, - user.id, - refund_error, + 'CRITICAL: Failed to refund kopeks to user after extension failure', + charge_from_balance=charge_from_balance, + user_id=user.id, + refund_error=refund_error, ) raise @@ -486,9 +486,9 @@ class SubscriptionRenewalService: ) except Exception as error: # pragma: no cover - defensive logging logger.warning( - 'Failed to record renewal server prices for subscription %s: %s', - subscription_after.id, - error, + 'Failed to record renewal server prices for subscription', + subscription_after_id=subscription_after.id, + error=error, ) subscription_service = SubscriptionService() @@ -500,12 +500,12 @@ class SubscriptionRenewalService: reset_reason='subscription renewal', ) except RemnaWaveConfigurationError as error: # pragma: no cover - configuration issues - logger.warning('RemnaWave update skipped: %s', error) + logger.warning('RemnaWave update skipped', error=error) except Exception as error: # pragma: no cover - defensive logging logger.error( - 'Failed to update RemnaWave user for subscription %s: %s', - subscription_after.id, - error, + 'Failed to update RemnaWave user for subscription', + subscription_after_id=subscription_after.id, + error=error, ) transaction: Transaction | None = None @@ -520,9 +520,9 @@ class SubscriptionRenewalService: ) except Exception as error: # pragma: no cover - defensive logging logger.warning( - 'Failed to create renewal transaction for subscription %s: %s', - subscription_after.id, - error, + 'Failed to create renewal transaction for subscription', + subscription_after_id=subscription_after.id, + error=error, ) await db.refresh(user) @@ -584,10 +584,10 @@ class SubscriptionRenewalService: if unavailable_servers: user_identifier = user.telegram_id or user.email or f'user#{user.id}' logger.warning( - f'⚠️ Пользователь {user_identifier} (promo_group={user_promo_group.name}) ' - f'продлевает подписку с серверами, недоступными для его промогруппы: ' - f'{", ".join(unavailable_servers)}. ' - f'Это может привести к неправильному расчёту цены!' + '⚠️ Пользователь (promo_group=) продлевает подписку с серверами, недоступными для его промогруппы: . Это может привести к неправильному расчёту цены!', + user_identifier=user_identifier, + user_promo_group_name=user_promo_group.name, + value=', '.join(unavailable_servers), ) def build_option_payload( diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index c5f99548..95674be1 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -1,7 +1,7 @@ -import logging from contextlib import asynccontextmanager from datetime import datetime +import structlog from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -17,7 +17,7 @@ from app.utils.subscription_utils import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _resolve_discount_percent( @@ -79,14 +79,17 @@ def get_traffic_reset_strategy(tariff=None): if tariff_mode is not None: mapped_strategy = strategy_mapping.get(tariff_mode.upper(), 'NO_RESET') logger.info( - f"🔄 Стратегия сброса трафика из тарифа '{getattr(tariff, 'name', 'N/A')}': {tariff_mode} -> {mapped_strategy}" + '🔄 Стратегия сброса трафика из тарифа', + value=getattr(tariff, 'name', 'N/A'), + tariff_mode=tariff_mode, + mapped_strategy=mapped_strategy, ) return getattr(TrafficLimitStrategy, mapped_strategy) # Используем глобальную настройку strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper() mapped_strategy = strategy_mapping.get(strategy, 'NO_RESET') - logger.info(f'🔄 Стратегия сброса трафика из конфига: {strategy} -> {mapped_strategy}') + logger.info('🔄 Стратегия сброса трафика из конфига', strategy=strategy, mapped_strategy=mapped_strategy) return getattr(TrafficLimitStrategy, mapped_strategy) @@ -141,7 +144,8 @@ class SubscriptionService: if self._config_error: logger.warning( - 'RemnaWave API недоступен: %s. Подписочный сервис будет работать в оффлайн-режиме.', self._config_error + 'RemnaWave API недоступен: . Подписочный сервис будет работать в оффлайн-режиме.', + config_error=self._config_error, ) self._last_config_signature = config_signature @@ -184,12 +188,12 @@ class SubscriptionService: try: user = await get_user_by_id(db, subscription.user_id) if not user: - logger.error(f'Пользователь {subscription.user_id} не найден') + logger.error('Пользователь не найден', user_id=subscription.user_id) return None validation_success = await self.validate_and_clean_subscription(db, subscription, user) if not validation_success: - logger.error(f'Ошибка валидации подписки для пользователя {self._format_user_log(user)}') + logger.error('Ошибка валидации подписки для пользователя', _format_user_log=self._format_user_log(user)) return None # Загружаем tariff заранее, чтобы избежать lazy loading в async контексте @@ -224,14 +228,16 @@ class SubscriptionService: pass if existing_users: - logger.info(f'🔄 Найден существующий пользователь в панели для {self._format_user_log(user)}') + logger.info( + '🔄 Найден существующий пользователь в панели для', _format_user_log=self._format_user_log(user) + ) remnawave_user = existing_users[0] try: await api.reset_user_devices(remnawave_user.uuid) - logger.info(f'🔧 Сброшены HWID устройства для {self._format_user_log(user)}') + logger.info('🔧 Сброшены HWID устройства для', _format_user_log=self._format_user_log(user)) except Exception as hwid_error: - logger.warning(f'⚠️ Не удалось сбросить HWID: {hwid_error}') + logger.warning('⚠️ Не удалось сбросить HWID', hwid_error=hwid_error) update_kwargs = dict( uuid=remnawave_user.uuid, @@ -267,7 +273,9 @@ class SubscriptionService: ) else: - logger.info(f'🆕 Создаем нового пользователя в панели для {self._format_user_log(user)}') + logger.info( + '🆕 Создаем нового пользователя в панели для', _format_user_log=self._format_user_log(user) + ) username = settings.format_remnawave_username( full_name=user.full_name, username=user.username, @@ -316,17 +324,17 @@ class SubscriptionService: await db.commit() - logger.info(f'✅ Создан/обновлен RemnaWave пользователь для подписки {subscription.id}') - logger.info(f'🔗 Ссылка на подписку: {updated_user.subscription_url}') + logger.info('✅ Создан/обновлен RemnaWave пользователь для подписки', subscription_id=subscription.id) + logger.info('🔗 Ссылка на подписку', subscription_url=updated_user.subscription_url) strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY - logger.info(f'📊 Стратегия сброса трафика: {strategy_name}') + logger.info('📊 Стратегия сброса трафика', strategy_name=strategy_name) return updated_user except RemnaWaveAPIError as e: - logger.error(f'Ошибка RemnaWave API: {e}') + logger.error('Ошибка RemnaWave API', error=e) return None except Exception as e: - logger.error(f'Ошибка создания RemnaWave пользователя: {e}') + logger.error('Ошибка создания RemnaWave пользователя', error=e) return None async def update_remnawave_user( @@ -340,7 +348,7 @@ class SubscriptionService: try: user = await get_user_by_id(db, subscription.user_id) if not user or not user.remnawave_uuid: - logger.error(f'RemnaWave UUID не найден для пользователя {subscription.user_id}') + logger.error('RemnaWave UUID не найден для пользователя', user_id=subscription.user_id) return None # Загружаем tariff заранее, чтобы избежать lazy loading в async контексте @@ -359,9 +367,10 @@ class SubscriptionService: # Логируем если статус и end_date не согласованы (для отладки) if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time: logger.warning( - f'⚠️ update_remnawave_user: подписка {subscription.id} имеет статус ACTIVE, ' - f'но end_date ({subscription.end_date}) <= now ({current_time}). ' - f'Отправляем в RemnaWave как EXPIRED, но НЕ меняем статус в БД.' + '⚠️ update_remnawave_user: подписка имеет статус ACTIVE, но end_date <= now . Отправляем в RemnaWave как EXPIRED, но НЕ меняем статус в БД.', + subscription_id=subscription.id, + end_date=subscription.end_date, + current_time=current_time, ) user_tag = self._resolve_user_tag(subscription) @@ -407,16 +416,20 @@ class SubscriptionService: await db.commit() status_text = 'активным' if is_actually_active else 'истёкшим' - logger.info(f'✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}') + logger.info( + '✅ Обновлен RemnaWave пользователь со статусом', + remnawave_uuid=user.remnawave_uuid, + status_text=status_text, + ) strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY - logger.info(f'📊 Стратегия сброса трафика: {strategy_name}') + logger.info('📊 Стратегия сброса трафика', strategy_name=strategy_name) return updated_user except RemnaWaveAPIError as e: - logger.error(f'Ошибка RemnaWave API: {e}') + logger.error('Ошибка RemnaWave API', error=e) return None except Exception as e: - logger.error(f'Ошибка обновления RemnaWave пользователя: {e}') + logger.error('Ошибка обновления RemnaWave пользователя', error=e) return None @staticmethod @@ -441,24 +454,28 @@ class SubscriptionService: try: await api.reset_user_traffic(user_uuid) reason_text = f' ({reset_reason})' if reset_reason else '' - logger.info(f'🔄 Сброшен трафик RemnaWave для {self._format_user_log(user)}{reason_text}') + logger.info( + '🔄 Сброшен трафик RemnaWave для', _format_user_log=self._format_user_log(user), reason_text=reason_text + ) except Exception as exc: - logger.warning(f'⚠️ Не удалось сбросить трафик RemnaWave для {self._format_user_log(user)}: {exc}') + logger.warning( + '⚠️ Не удалось сбросить трафик RemnaWave для', _format_user_log=self._format_user_log(user), error=exc + ) async def disable_remnawave_user(self, user_uuid: str) -> bool: try: async with self.get_api_client() as api: await api.disable_user(user_uuid) - logger.info(f'✅ Отключен RemnaWave пользователь {user_uuid}') + logger.info('✅ Отключен RemnaWave пользователь', user_uuid=user_uuid) return True except Exception as e: error_msg = str(e).lower() # "User already disabled" - считаем успехом if 'already disabled' in error_msg: - logger.info(f'✅ RemnaWave пользователь {user_uuid} уже отключен') + logger.info('✅ RemnaWave пользователь уже отключен', user_uuid=user_uuid) return True - logger.error(f'Ошибка отключения RemnaWave пользователя: {e}') + logger.error('Ошибка отключения RemnaWave пользователя', error=e) return False async def enable_remnawave_user(self, user_uuid: str) -> bool: @@ -466,16 +483,16 @@ class SubscriptionService: try: async with self.get_api_client() as api: await api.enable_user(user_uuid) - logger.info(f'✅ Включен RemnaWave пользователь {user_uuid}') + logger.info('✅ Включен RemnaWave пользователь', user_uuid=user_uuid) return True except Exception as e: error_msg = str(e).lower() # "User already enabled" - считаем успехом if 'already enabled' in error_msg: - logger.info(f'✅ RemnaWave пользователь {user_uuid} уже включен') + logger.info('✅ RemnaWave пользователь уже включен', user_uuid=user_uuid) return True - logger.error(f'Ошибка включения RemnaWave пользователя: {e}') + logger.error('Ошибка включения RemnaWave пользователя', error=e) return False async def get_remnawave_squads(self) -> list[dict] | None: @@ -492,11 +509,11 @@ class SubscriptionService: 'name': squad.name, } ) - logger.info(f'✅ Получено {len(result)} серверов из RemnaWave') + logger.info('✅ Получено серверов из RemnaWave', result_count=len(result)) return result except Exception as e: - logger.error(f'Ошибка получения серверов из RemnaWave: {e}') + logger.error('Ошибка получения серверов из RemnaWave', error=e) return None async def revoke_subscription(self, db: AsyncSession, subscription: Subscription) -> str | None: @@ -513,11 +530,11 @@ class SubscriptionService: subscription.subscription_crypto_link = updated_user.happ_crypto_link await db.commit() - logger.info(f'✅ Обновлена ссылка подписки для {self._format_user_log(user)}') + logger.info('✅ Обновлена ссылка подписки для', _format_user_log=self._format_user_log(user)) return updated_user.subscription_url except Exception as e: - logger.error(f'Ошибка обновления ссылки подписки: {e}') + logger.error('Ошибка обновления ссылки подписки', error=e) return None async def get_subscription_info(self, short_uuid: str) -> dict | None: @@ -527,7 +544,7 @@ class SubscriptionService: return info except Exception as e: - logger.error(f'Ошибка получения информации о подписке: {e}') + logger.error('Ошибка получения информации о подписке', error=e) return None async def sync_subscription_usage(self, db: AsyncSession, subscription: Subscription) -> bool: @@ -546,11 +563,11 @@ class SubscriptionService: await db.commit() - logger.debug(f'Синхронизирован трафик для подписки {subscription.id}: {used_gb} ГБ') + logger.debug('Синхронизирован трафик для подписки ГБ', subscription_id=subscription.id, used_gb=used_gb) return True except Exception as e: - logger.error(f'Ошибка синхронизации трафика: {e}') + logger.error('Ошибка синхронизации трафика', error=e) return False async def ensure_subscription_synced( @@ -570,7 +587,7 @@ class SubscriptionService: try: user = await get_user_by_id(db, subscription.user_id) if not user: - logger.error(f'Пользователь не найден для подписки {subscription.id}') + logger.error('Пользователь не найден для подписки', subscription_id=subscription.id) return False, 'user_not_found' # Проверяем, нужна ли синхронизация @@ -584,19 +601,21 @@ class SubscriptionService: if not remnawave_user: needs_sync = True logger.warning( - f'Пользователь {user.remnawave_uuid} не найден в RemnaWave, требуется синхронизация' + 'Пользователь не найден в RemnaWave, требуется синхронизация', + remnawave_uuid=user.remnawave_uuid, ) except Exception as check_error: - logger.warning(f'Не удалось проверить пользователя в RemnaWave: {check_error}') + logger.warning('Не удалось проверить пользователя в RemnaWave', check_error=check_error) # Продолжаем, возможно проблема временная if not needs_sync: return True, None logger.info( - f'Синхронизация подписки {subscription.id} с RemnaWave ' - f'(subscription_url={bool(subscription.subscription_url)}, ' - f'remnawave_uuid={bool(user.remnawave_uuid)})' + 'Синхронизация подписки с RemnaWave (subscription_url=, remnawave_uuid=)', + subscription_id=subscription.id, + subscription_url=bool(subscription.subscription_url), + remnawave_uuid=bool(user.remnawave_uuid), ) # Пытаемся синхронизировать @@ -611,7 +630,8 @@ class SubscriptionService: # Если update не удался (пользователь удалён из RemnaWave) — пробуем создать if not result: logger.warning( - f'Не удалось обновить пользователя {user.remnawave_uuid} в RemnaWave, пробуем создать заново' + 'Не удалось обновить пользователя в RemnaWave, пробуем создать заново', + remnawave_uuid=user.remnawave_uuid, ) # Сбрасываем старый UUID, create_remnawave_user установит новый user.remnawave_uuid = None @@ -632,18 +652,21 @@ class SubscriptionService: await db.refresh(subscription) await db.refresh(user) logger.info( - f'Подписка {subscription.id} успешно синхронизирована с RemnaWave. ' - f'URL: {subscription.subscription_url}' + 'Подписка успешно синхронизирована с RemnaWave. URL', + subscription_id=subscription.id, + subscription_url=subscription.subscription_url, ) return True, None - logger.error(f'Не удалось синхронизировать подписку {subscription.id} с RemnaWave') + logger.error('Не удалось синхронизировать подписку с RemnaWave', subscription_id=subscription.id) return False, 'sync_failed' except RemnaWaveAPIError as api_error: - logger.error(f'Ошибка RemnaWave API при синхронизации подписки {subscription.id}: {api_error}') + logger.error( + 'Ошибка RemnaWave API при синхронизации подписки', subscription_id=subscription.id, api_error=api_error + ) return False, 'api_error' except Exception as e: - logger.error(f'Ошибка синхронизации подписки {subscription.id}: {e}') + logger.error('Ошибка синхронизации подписки', subscription_id=subscription.id, error=e) return False, 'unknown_error' async def calculate_subscription_price( @@ -708,7 +731,7 @@ class SubscriptionService: logger.debug(log_message) else: server_prices.append(0) - logger.warning(f'Сервер ID {server_id} недоступен') + logger.warning('Сервер ID недоступен', server_id=server_id) devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE devices_discount_percent = _resolve_discount_percent( @@ -742,7 +765,7 @@ class SubscriptionService: if devices_discount > 0: message += f' (скидка {devices_discount_percent}%: -{devices_discount / 100}₽ → {discounted_devices_price / 100}₽)' logger.debug(message) - logger.debug(f' ИТОГО: {total_price / 100}₽') + logger.debug('ИТОГО: ₽', total_price=total_price / 100) return total_price, server_prices @@ -826,7 +849,9 @@ class SubscriptionService: total_price = base_price + discounted_servers_price + discounted_devices_price + discounted_traffic_price - logger.debug(f'💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):') + logger.debug( + '💰 Расчет стоимости продления для подписки (по текущим ценам)', subscription_id=subscription.id + ) base_log = f' 📅 Период {period_days} дней: {base_price_original / 100}₽' if base_discount_total > 0: base_log += f' → {base_price / 100}₽ (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)' @@ -852,12 +877,12 @@ class SubscriptionService: f' (скидка {traffic_discount_percent}%: -{traffic_discount / 100}₽ от {traffic_price / 100}₽)' ) logger.debug(message) - logger.debug(f' 💎 ИТОГО: {total_price / 100}₽') + logger.debug('💎 ИТОГО: ₽', total_price=total_price / 100) return total_price except Exception as e: - logger.error(f'Ошибка расчета стоимости продления: {e}') + logger.error('Ошибка расчета стоимости продления', error=e) from app.config import PERIOD_PRICES return PERIOD_PRICES.get(period_days, 0) @@ -874,7 +899,9 @@ class SubscriptionService: if not remnawave_user: logger.warning( - f'⚠️ Пользователь {user_log} имеет UUID {user.remnawave_uuid}, но не найден в панели' + '⚠️ Пользователь имеет UUID но не найден в панели', + user_log=user_log, + remnawave_uuid=user.remnawave_uuid, ) needs_cleanup = True # Проверяем telegram_id только если он задан у обоих @@ -884,11 +911,13 @@ class SubscriptionService: and remnawave_user.telegram_id != user.telegram_id ): logger.warning( - f'⚠️ Несоответствие telegram_id для {user_log}: panel={remnawave_user.telegram_id}' + '⚠️ Несоответствие telegram_id для panel', + user_log=user_log, + telegram_id=remnawave_user.telegram_id, ) needs_cleanup = True except Exception as api_error: - logger.error(f'❌ Ошибка проверки пользователя в панели: {api_error}') + logger.error('❌ Ошибка проверки пользователя в панели', api_error=api_error) needs_cleanup = True if subscription.remnawave_short_uuid and not user.remnawave_uuid: @@ -896,7 +925,7 @@ class SubscriptionService: needs_cleanup = True if needs_cleanup: - logger.info(f'🧹 Очищаем мусорные данные подписки для {user_log}') + logger.info('🧹 Очищаем мусорные данные подписки для', user_log=user_log) subscription.remnawave_short_uuid = None subscription.subscription_url = '' @@ -906,12 +935,12 @@ class SubscriptionService: user.remnawave_uuid = None await db.commit() - logger.info(f'✅ Мусорные данные очищены для {user_log}') + logger.info('✅ Мусорные данные очищены для', user_log=user_log) return True except Exception as e: - logger.error(f'❌ Ошибка валидации подписки для {self._format_user_log(user)}: {e}') + logger.error('❌ Ошибка валидации подписки для', _format_user_log=self._format_user_log(user), error=e) await db.rollback() return False @@ -939,20 +968,22 @@ class SubscriptionService: price = server.price_kopeks total_price += price prices_list.append(price) - logger.debug(f'🏷️ Страна {server.display_name}: {price / 100}₽') + logger.debug('🏷️ Страна ₽', display_name=server.display_name, price=price / 100) else: default_price = 0 total_price += default_price prices_list.append(default_price) logger.warning( - f'⚠️ Сервер {country_uuid} недоступен, используем базовую цену: {default_price / 100}₽' + '⚠️ Сервер недоступен, используем базовую цену: ₽', + country_uuid=country_uuid, + default_price=default_price / 100, ) - logger.info(f'💰 Общая стоимость стран: {total_price / 100}₽') + logger.info('💰 Общая стоимость стран: ₽', total_price=total_price / 100) return total_price, prices_list except Exception as e: - logger.error(f'Ошибка получения цен стран: {e}') + logger.error('Ошибка получения цен стран', error=e) default_prices = [0] * len(country_uuids) return sum(default_prices), default_prices @@ -961,7 +992,7 @@ class SubscriptionService: total_price, _ = await self.get_countries_price_by_uuids(country_uuids, db) return total_price except Exception as e: - logger.error(f'Ошибка получения цен стран: {e}') + logger.error('Ошибка получения цен стран', error=e) return len(country_uuids) * 1000 async def calculate_subscription_price_with_months( @@ -1032,7 +1063,7 @@ class SubscriptionService: logger.debug(log_message) else: server_prices.append(0) - logger.warning(f'Сервер ID {server_id} недоступен') + logger.warning('Сервер ID недоступен', server_id=server_id) additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE @@ -1048,7 +1079,9 @@ class SubscriptionService: total_price = base_price + total_traffic_price + total_servers_price + total_devices_price - logger.debug(f'Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):') + logger.debug( + 'Расчет стоимости новой подписки на дней ( мес)', period_days=period_days, months_in_period=months_in_period + ) base_log = f' Период {period_days} дней: {base_price_original / 100}₽' if base_discount_total > 0: base_log += f' → {base_price / 100}₽ (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)' @@ -1072,7 +1105,7 @@ class SubscriptionService: f' (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period / 100}₽)' ) logger.debug(message) - logger.debug(f' ИТОГО: {total_price / 100}₽') + logger.debug('ИТОГО: ₽', total_price=total_price / 100) return total_price, server_prices @@ -1162,7 +1195,10 @@ class SubscriptionService: total_price = base_price + total_servers_price + total_devices_price + total_traffic_price logger.debug( - f'💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):' + '💰 Расчет стоимости продления подписки на дней ( мес)', + subscription_id=subscription.id, + period_days=period_days, + months_in_period=months_in_period, ) base_log = f' 📅 Период {period_days} дней: {base_price_original / 100}₽' if base_discount_total > 0: @@ -1183,12 +1219,12 @@ class SubscriptionService: if traffic_discount_per_month > 0: message += f' (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period / 100}₽)' logger.debug(message) - logger.debug(f' 💎 ИТОГО: {total_price / 100}₽') + logger.debug('💎 ИТОГО: ₽', total_price=total_price / 100) return total_price except Exception as e: - logger.error(f'Ошибка расчета стоимости продления: {e}') + logger.error('Ошибка расчета стоимости продления', error=e) from app.config import PERIOD_PRICES return PERIOD_PRICES.get(period_days, 0) @@ -1284,7 +1320,7 @@ class SubscriptionService: ) logger.info(message) - logger.info(f'Итого доплата за {months_to_pay} мес: {total_price / 100}₽') + logger.info('Итого доплата за мес: ₽', months_to_pay=months_to_pay, total_price=total_price / 100) return total_price def _gb_to_bytes(self, gb: int | None) -> int: diff --git a/app/services/support_settings_service.py b/app/services/support_settings_service.py index d948f255..774cb0bf 100644 --- a/app/services/support_settings_service.py +++ b/app/services/support_settings_service.py @@ -1,11 +1,12 @@ import json -import logging from pathlib import Path +import structlog + from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class SupportSettingsService: @@ -20,7 +21,7 @@ class SupportSettingsService: try: cls._storage_path.parent.mkdir(parents=True, exist_ok=True) except Exception as e: - logger.error(f'Failed to ensure settings dir: {e}') + logger.error('Failed to ensure settings dir', error=e) @classmethod def _load(cls) -> None: @@ -33,7 +34,7 @@ class SupportSettingsService: else: cls._data = {} except Exception as e: - logger.error(f'Failed to load support settings: {e}') + logger.error('Failed to load support settings', error=e) cls._data = {} cls._loaded = True @@ -44,7 +45,7 @@ class SupportSettingsService: cls._storage_path.write_text(json.dumps(cls._data, ensure_ascii=False, indent=2), encoding='utf-8') return True except Exception as e: - logger.error(f'Failed to save support settings: {e}') + logger.error('Failed to save support settings', error=e) return False # Mode diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index eef645fc..3e6a6164 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -1,9 +1,9 @@ import hashlib import json -import logging from dataclasses import dataclass from typing import Any, Optional, Union, get_args, get_origin +import structlog from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -23,7 +23,7 @@ from app.database.models import SystemSetting from app.database.universal_migration import ensure_default_web_api_token -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _title_from_key(key: str) -> str: @@ -1226,7 +1226,7 @@ class BotConfigurationService: if isinstance(price_value, int): label = f'{label} — {settings.format_price(price_value)}' except Exception: - logger.debug('Не удалось форматировать цену для периода %s', days, exc_info=True) + logger.debug('Не удалось форматировать цену для периода', days=days, exc_info=True) options.append(ChoiceOption(days, label)) @@ -1269,7 +1269,7 @@ class BotConfigurationService: try: packages = settings.get_traffic_packages() except Exception as error: - logger.warning('Не удалось получить пакеты трафика: %s', error, exc_info=True) + logger.warning('Не удалось получить пакеты трафика', error=error, exc_info=True) packages = [] traffic_values: set[int] = {0} @@ -1429,15 +1429,12 @@ class BotConfigurationService: for key, raw_value in overrides.items(): if cls._is_env_override(key): - logger.debug( - 'Пропускаем настройку %s из БД: используется значение из окружения', - key, - ) + logger.debug('Пропускаем настройку из БД: используется значение из окружения', key=key) continue try: parsed_value = cls.deserialize_value(key, raw_value) except Exception as error: - logger.error('Не удалось применить настройку %s: %s', key, error) + logger.error('Не удалось применить настройку', key=key, error=error) continue cls._overrides_raw[key] = raw_value @@ -1548,10 +1545,7 @@ class BotConfigurationService: raw_value = cls.serialize_value(key, value) await upsert_system_setting(db, key, raw_value) if cls._is_env_override(key): - logger.info( - 'Настройка %s сохранена в БД, но не применена: значение задаётся через окружение', - key, - ) + logger.info('Настройка сохранена в БД, но не применена: значение задаётся через окружение', key=key) cls._overrides_raw.pop(key, None) else: cls._overrides_raw[key] = raw_value @@ -1574,10 +1568,7 @@ class BotConfigurationService: await delete_system_setting(db, key) cls._overrides_raw.pop(key, None) if cls._is_env_override(key): - logger.info( - 'Настройка %s сброшена в БД, используется значение из окружения', - key, - ) + logger.info('Настройка сброшена в БД, используется значение из окружения', key=key) else: original = cls.get_original_value(key) cls._apply_to_settings(key, original) @@ -1588,10 +1579,7 @@ class BotConfigurationService: @classmethod def _apply_to_settings(cls, key: str, value: Any) -> None: if cls._is_env_override(key): - logger.debug( - 'Пропуск применения настройки %s: значение задано через окружение', - key, - ) + logger.debug('Пропуск применения настройки : значение задано через окружение', key=key) return try: setattr(settings, key, value) @@ -1614,10 +1602,7 @@ class BotConfigurationService: run_immediately=(key == 'REMNAWAVE_AUTO_SYNC_ENABLED' and bool(value)) ) except Exception as error: - logger.error( - 'Не удалось обновить сервис автосинхронизации RemnaWave: %s', - error, - ) + logger.error('Не удалось обновить сервис автосинхронизации RemnaWave', error=error) elif key in { 'REMNAWAVE_API_URL', 'REMNAWAVE_API_KEY', @@ -1631,12 +1616,9 @@ class BotConfigurationService: remnawave_sync_service.refresh_configuration() except Exception as error: - logger.error( - 'Не удалось обновить конфигурацию сервиса автосинхронизации RemnaWave: %s', - error, - ) + logger.error('Не удалось обновить конфигурацию сервиса автосинхронизации RemnaWave', error=error) except Exception as error: - logger.error('Не удалось применить значение %s=%s: %s', key, value, error) + logger.error('Не удалось применить значение', key=key, setting_value=value, error=error) @staticmethod async def _sync_default_web_api_token() -> None: diff --git a/app/services/traffic_monitoring_service.py b/app/services/traffic_monitoring_service.py index 37796043..b760b4e7 100644 --- a/app/services/traffic_monitoring_service.py +++ b/app/services/traffic_monitoring_service.py @@ -4,10 +4,10 @@ """ import asyncio -import logging from dataclasses import dataclass from datetime import datetime, time, timedelta +import structlog from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -18,7 +18,7 @@ from app.services.remnawave_service import RemnaWaveService from app.utils.cache import cache, cache_key -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # Ключи для хранения snapshot в Redis TRAFFIC_SNAPSHOT_KEY = 'traffic:snapshot' @@ -122,12 +122,16 @@ class TrafficMonitoringServiceV2: if success: # Сохраняем время создания snapshot await cache.set(TRAFFIC_SNAPSHOT_TIME_KEY, datetime.utcnow().isoformat(), expire=ttl) - logger.info(f'📦 Snapshot сохранён в Redis: {len(snapshot)} пользователей, TTL {ttl // 3600}ч') + logger.info( + '📦 Snapshot сохранён в Redis: пользователей, TTL ч', + snapshot_count=len(snapshot), + value=ttl // 3600, + ) else: logger.warning('⚠️ Не удалось сохранить snapshot в Redis') return success except Exception as e: - logger.error(f'❌ Ошибка сохранения snapshot в Redis: {e}') + logger.error('❌ Ошибка сохранения snapshot в Redis', error=e) return False async def _load_snapshot_from_redis(self) -> dict[str, float] | None: @@ -138,11 +142,11 @@ class TrafficMonitoringServiceV2: if snapshot_data is not None and isinstance(snapshot_data, dict): # Конвертируем обратно в float result = {uuid: float(bytes_val) for uuid, bytes_val in snapshot_data.items()} - logger.debug(f'📦 Snapshot загружен из Redis: {len(result)} пользователей') + logger.debug('📦 Snapshot загружен из Redis: пользователей', result_count=len(result)) return result return None except Exception as e: - logger.error(f'❌ Ошибка загрузки snapshot из Redis: {e}') + logger.error('❌ Ошибка загрузки snapshot из Redis', error=e) return None async def _get_snapshot_time_from_redis(self) -> datetime | None: @@ -153,7 +157,7 @@ class TrafficMonitoringServiceV2: return datetime.fromisoformat(time_str) return None except Exception as e: - logger.error(f'❌ Ошибка получения времени snapshot: {e}') + logger.error('❌ Ошибка получения времени snapshot', error=e) return None async def _save_notification_to_redis(self, user_uuid: str) -> bool: @@ -163,7 +167,7 @@ class TrafficMonitoringServiceV2: ttl = 24 * 3600 # 24 часа return await cache.set(key, datetime.utcnow().isoformat(), expire=ttl) except Exception as e: - logger.error(f'❌ Ошибка сохранения уведомления в Redis: {e}') + logger.error('❌ Ошибка сохранения уведомления в Redis', error=e) return False async def _get_notification_time_from_redis(self, user_uuid: str) -> datetime | None: @@ -175,7 +179,7 @@ class TrafficMonitoringServiceV2: return datetime.fromisoformat(time_str) return None except Exception as e: - logger.error(f'❌ Ошибка получения времени уведомления: {e}') + logger.error('❌ Ошибка получения времени уведомления', error=e) return None # ============== Работа с нодами ============== @@ -185,9 +189,9 @@ class TrafficMonitoringServiceV2: try: nodes = await self.remnawave_service.get_all_nodes() self._nodes_cache = {node['uuid']: node['name'] for node in nodes if node.get('uuid') and node.get('name')} - logger.debug(f'📋 Загружено {len(self._nodes_cache)} нод в кеш') + logger.debug('📋 Загружено нод в кеш', _nodes_cache_count=len(self._nodes_cache)) except Exception as e: - logger.error(f'❌ Ошибка загрузки нод в кеш: {e}') + logger.error('❌ Ошибка загрузки нод в кеш', error=e) def get_node_name(self, node_uuid: str | None) -> str | None: """Возвращает название ноды по UUID из кеша""" @@ -249,7 +253,7 @@ class TrafficMonitoringServiceV2: for uuid in expired: del self._memory_notification_cache[uuid] if expired: - logger.debug(f'🧹 Очищено {len(expired)} записей из памяти уведомлений о трафике') + logger.debug('🧹 Очищено записей из памяти уведомлений о трафике', expired_count=len(expired)) # ============== Получение пользователей ============== @@ -272,18 +276,18 @@ class TrafficMonitoringServiceV2: break all_users.extend(users) - logger.debug(f'📊 Загружено {len(all_users)} пользователей...') + logger.debug('📊 Загружено пользователей...', all_users_count=len(all_users)) if len(users) < batch_size: break offset += batch_size - logger.info(f'✅ Всего загружено {len(all_users)} пользователей из Remnawave') + logger.info('✅ Всего загружено пользователей из Remnawave', all_users_count=len(all_users)) return all_users except Exception as e: - logger.error(f'❌ Ошибка при получении пользователей: {e}') + logger.error('❌ Ошибка при получении пользователей', error=e) return [] # ============== Быстрая проверка ============== @@ -349,8 +353,9 @@ class TrafficMonitoringServiceV2: if existing_snapshot is not None: age = await self.get_snapshot_age_minutes() logger.info( - f'📦 Найден существующий snapshot в Redis: {len(existing_snapshot)} пользователей, ' - f'возраст {age:.1f} мин' + '📦 Найден существующий snapshot в Redis: пользователей, возраст мин', + existing_snapshot_count=len(existing_snapshot), + age=round(age, 1), ) return len(existing_snapshot) @@ -373,13 +378,15 @@ class TrafficMonitoringServiceV2: new_snapshot[user.uuid] = current_bytes except Exception as e: - logger.error(f'❌ Ошибка при создании snapshot для {user.uuid}: {e}') + logger.error('❌ Ошибка при создании snapshot для', uuid=user.uuid, error=e) # Сохраняем в Redis (с fallback на память) await self._save_snapshot(new_snapshot) elapsed = (datetime.utcnow() - start_time).total_seconds() - logger.info(f'✅ Snapshot создан за {elapsed:.1f}с: {len(new_snapshot)} пользователей') + logger.info( + '✅ Snapshot создан за с: пользователей', elapsed=round(elapsed, 1), new_snapshot_count=len(new_snapshot) + ) return len(new_snapshot) @@ -407,21 +414,23 @@ class TrafficMonitoringServiceV2: excluded_user_uuids = self.get_excluded_user_uuids() if monitored_nodes: - logger.info(f'🔍 Мониторим только ноды: {monitored_nodes}') + logger.info('🔍 Мониторим только ноды', monitored_nodes=monitored_nodes) elif ignored_nodes: - logger.info(f'🚫 Игнорируем ноды: {ignored_nodes}') + logger.info('🚫 Игнорируем ноды', ignored_nodes=ignored_nodes) else: logger.info('📊 Мониторим все ноды') if excluded_user_uuids: - logger.info(f'🚫 Исключены пользователи: {excluded_user_uuids}') + logger.info('🚫 Исключены пользователи', excluded_user_uuids=excluded_user_uuids) if is_first_run: logger.info('🚀 Первый запуск быстрой проверки — создаём snapshot...') else: age = await self.get_snapshot_age_minutes() logger.info( - f'🚀 Быстрая проверка трафика (snapshot {age:.1f} мин назад, порог {self.get_fast_check_threshold_gb()} ГБ)...' + '🚀 Быстрая проверка трафика (snapshot мин назад, порог ГБ)...', + age=round(age, 1), + get_fast_check_threshold_gb=self.get_fast_check_threshold_gb(), ) violations: list[TrafficViolation] = [] @@ -432,7 +441,11 @@ class TrafficMonitoringServiceV2: # Загружаем предыдущий snapshot (из Redis или памяти) previous_snapshot = await self._get_current_snapshot() - logger.info(f'📦 Предыдущий snapshot: {len(previous_snapshot)} пользователей (is_first_run={is_first_run})') + logger.info( + '📦 Предыдущий snapshot: пользователей (is_first_run=)', + previous_snapshot_count=len(previous_snapshot), + is_first_run=is_first_run, + ) users_with_delta = 0 @@ -455,7 +468,7 @@ class TrafficMonitoringServiceV2: # Пользователя не было в предыдущем snapshot — пропускаем (новый пользователь) if user.uuid not in previous_snapshot: - logger.debug(f'Пользователь {user.uuid[:8]} не найден в предыдущем snapshot, пропускаем') + logger.debug('Пользователь не найден в предыдущем snapshot, пропускаем', uuid=user.uuid[:8]) continue # Получаем предыдущее значение @@ -474,13 +487,18 @@ class TrafficMonitoringServiceV2: continue logger.info( - f'⚠️ Превышение дельты: {user.uuid[:8]}... +{delta_gb:.2f} ГБ (порог {self.get_fast_check_threshold_gb()} ГБ, previous={previous_bytes / (1024**3):.2f} ГБ, current={current_bytes / (1024**3):.2f} ГБ)' + '⚠️ Превышение дельты: ... + ГБ (порог ГБ, previous= ГБ, current= ГБ)', + uuid=user.uuid[:8], + delta_gb=round(delta_gb, 2), + get_fast_check_threshold_gb=self.get_fast_check_threshold_gb(), + previous_bytes=round(previous_bytes / 1024**3, 2), + current_bytes=round(current_bytes / 1024**3, 2), ) # Проверяем исключённых пользователей (служебные/тунельные) if user.uuid.lower() in excluded_user_uuids: logger.info( - f'⏭️ Пропускаем {user.uuid[:8]}... - пользователь в списке исключений (служебный/тунельный)' + '⏭️ Пропускаем ... пользователь в списке исключений (служебный/тунельный)', uuid=user.uuid[:8] ) continue @@ -488,7 +506,9 @@ class TrafficMonitoringServiceV2: last_node_uuid = user_traffic.last_connected_node_uuid if not self.should_monitor_node(last_node_uuid): logger.warning( - f'⏭️ Пропускаем {user.uuid[:8]} - нода {last_node_uuid or "неизвестна"} не в списке мониторинга' + '⏭️ Пропускаем нода не в списке мониторинга', + uuid=user.uuid[:8], + last_node_uuid=last_node_uuid or 'неизвестна', ) continue @@ -509,23 +529,27 @@ class TrafficMonitoringServiceV2: violations.append(violation) except Exception as e: - logger.error(f'❌ Ошибка обработки пользователя {user.uuid}: {e}') + logger.error('❌ Ошибка обработки пользователя', uuid=user.uuid, error=e) # Обновляем snapshot (в Redis с fallback на память) await self._save_snapshot(new_snapshot) - logger.info(f'💾 Новый snapshot сохранён: {len(new_snapshot)} пользователей') + logger.info('💾 Новый snapshot сохранён: пользователей', new_snapshot_count=len(new_snapshot)) elapsed = (datetime.utcnow() - start_time).total_seconds() if is_first_run: logger.info( - f'✅ Snapshot создан за {elapsed:.1f}с: {len(new_snapshot)} пользователей. ' - f'Следующая проверка покажет превышения.' + '✅ Snapshot создан за с: пользователей. Следующая проверка покажет превышения.', + elapsed=round(elapsed, 1), + new_snapshot_count=len(new_snapshot), ) else: logger.info( - f'✅ Быстрая проверка завершена за {elapsed:.1f}с: ' - f'{len(users)} пользователей, {users_with_delta} с дельтой >0, {len(violations)} превышений' + '✅ Быстрая проверка завершена за с: пользователей, с дельтой >0, превышений', + elapsed=round(elapsed, 1), + users_count=len(users), + users_with_delta=users_with_delta, + violations_count=len(violations), ) # Отправляем уведомления только если это не первый запуск await self._send_violation_notifications(violations, bot) @@ -604,7 +628,7 @@ class TrafficMonitoringServiceV2: ) except Exception as e: - logger.error(f'❌ Ошибка суточной проверки для {user.uuid}: {e}') + logger.error('❌ Ошибка суточной проверки для', uuid=user.uuid, error=e) return None # Параллельная проверка @@ -617,8 +641,10 @@ class TrafficMonitoringServiceV2: elapsed = (datetime.utcnow() - start_time).total_seconds() logger.info( - f'✅ Суточная проверка завершена за {elapsed:.1f}с: ' - f'{len(users)} пользователей, {len(violations)} превышений' + '✅ Суточная проверка завершена за с: пользователей, превышений', + elapsed=round(elapsed, 1), + users_count=len(users), + violations_count=len(violations), ) # Отправляем уведомления @@ -640,7 +666,9 @@ class TrafficMonitoringServiceV2: max_notifications = 10 if len(violations) > max_notifications: logger.warning( - f'⚠️ Слишком много превышений ({len(violations)}), отправляем только первые {max_notifications}' + '⚠️ Слишком много превышений отправляем только первые', + violations_count=len(violations), + max_notifications=max_notifications, ) violations = violations[:max_notifications] @@ -648,7 +676,9 @@ class TrafficMonitoringServiceV2: try: if not await self.should_send_notification(violation.user_uuid): logger.info( - f'⏭️ Кулдаун для {violation.user_uuid[:8]}... - пропускаем уведомление (кулдаун {self.get_notification_cooldown_seconds() // 60} мин)' + '⏭️ Кулдаун для ... пропускаем уведомление (кулдаун мин)', + user_uuid=violation.user_uuid[:8], + value=self.get_notification_cooldown_seconds() // 60, ) continue @@ -700,14 +730,14 @@ class TrafficMonitoringServiceV2: await admin_service.send_suspicious_traffic_notification(message, bot, topic_id) await self.record_notification(violation.user_uuid) - logger.info(f'📨 Уведомление отправлено для {violation.user_uuid}') + logger.info('📨 Уведомление отправлено для', user_uuid=violation.user_uuid) # Задержка между отправками (защита от flood) if i < len(violations) - 1: await asyncio.sleep(0.5) except Exception as e: - logger.error(f'❌ Ошибка отправки уведомления для {violation.user_uuid}: {e}') + logger.error('❌ Ошибка отправки уведомления для', user_uuid=violation.user_uuid, error=e) class TrafficMonitoringSchedulerV2: @@ -747,14 +777,14 @@ class TrafficMonitoringSchedulerV2: # Запускаем быструю проверку if self.service.is_fast_check_enabled(): interval = self.service.get_fast_check_interval_seconds() - logger.info(f'🚀 Запуск быстрой проверки трафика каждые {interval // 60} мин') + logger.info('🚀 Запуск быстрой проверки трафика каждые мин', value=interval // 60) self._fast_check_task = asyncio.create_task(self._run_fast_check_loop(interval)) # Запускаем суточную проверку if self.service.is_daily_check_enabled(): check_time = self.service.get_daily_check_time() if check_time: - logger.info(f'🚀 Запуск суточной проверки трафика в {check_time.strftime("%H:%M")}') + logger.info('🚀 Запуск суточной проверки трафика в', check_time=check_time.strftime('%H:%M')) self._daily_check_task = asyncio.create_task(self._run_daily_check_loop(check_time)) async def stop(self): @@ -782,7 +812,7 @@ class TrafficMonitoringSchedulerV2: async def _run_fast_check_loop(self, interval_seconds: int): """Цикл быстрой проверки""" # Сначала ждём интервал (snapshot уже создан в start()) - logger.info(f'⏳ Первая проверка через {interval_seconds // 60} минут...') + logger.info('⏳ Первая проверка через минут...', value=interval_seconds // 60) await asyncio.sleep(interval_seconds) while self._is_running: @@ -793,7 +823,7 @@ class TrafficMonitoringSchedulerV2: except asyncio.CancelledError: break except Exception as e: - logger.error(f'❌ Ошибка в цикле быстрой проверки: {e}') + logger.error('❌ Ошибка в цикле быстрой проверки', error=e) await asyncio.sleep(interval_seconds) async def _run_daily_check_loop(self, check_time: time): @@ -807,7 +837,7 @@ class TrafficMonitoringSchedulerV2: next_run += timedelta(days=1) delay = (next_run - now).total_seconds() - logger.debug(f'⏰ Следующая суточная проверка через {delay / 3600:.1f}ч') + logger.debug('⏰ Следующая суточная проверка через ч', delay=round(delay / 3600, 1)) await asyncio.sleep(delay) @@ -817,7 +847,7 @@ class TrafficMonitoringSchedulerV2: except asyncio.CancelledError: break except Exception as e: - logger.error(f'❌ Ошибка в цикле суточной проверки: {e}') + logger.error('❌ Ошибка в цикле суточной проверки', error=e) await asyncio.sleep(3600) # Ждём час при ошибке async def run_fast_check_now(self) -> list[TrafficViolation]: @@ -878,7 +908,7 @@ class TrafficMonitoringService: return is_exceeded, traffic_info except Exception as e: - logger.error(f'Ошибка проверки трафика для {user_uuid}: {e}') + logger.error('Ошибка проверки трафика для', user_uuid=user_uuid, error=e) return False, {'total_gb': 0, 'nodes': []} async def process_suspicious_traffic(self, db: AsyncSession, user_uuid: str, traffic_info: dict, bot): diff --git a/app/services/trial_activation_service.py b/app/services/trial_activation_service.py index 4fc2c7bb..1b7eaf34 100644 --- a/app/services/trial_activation_service.py +++ b/app/services/trial_activation_service.py @@ -1,8 +1,8 @@ from __future__ import annotations -import logging from dataclasses import dataclass +import structlog from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -11,7 +11,7 @@ from app.database.crud.user import add_user_balance, subtract_user_balance from app.database.models import Subscription, TransactionType, User -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class TrialPaymentError(Exception): @@ -121,9 +121,9 @@ async def refund_trial_activation_charge( if not success: logger.error( - 'Failed to refund %s kopeks for user %s during trial activation rollback', - amount_kopeks, - getattr(user, 'id', ''), + 'Failed to refund kopeks for user during trial activation rollback', + amount_kopeks=amount_kopeks, + getattr=getattr(user, 'id', ''), ) return success @@ -147,9 +147,7 @@ async def rollback_trial_subscription_activation( await decrement_subscription_server_counts(db, subscription) except Exception as error: # pragma: no cover - defensive logging logger.error( - 'Failed to decrement server counters during trial rollback for %s: %s', - subscription.user_id, - error, + 'Failed to decrement server counters during trial rollback for', user_id=subscription.user_id, error=error ) try: @@ -157,9 +155,9 @@ async def rollback_trial_subscription_activation( await db.commit() except Exception as error: # pragma: no cover - defensive logging logger.error( - 'Failed to remove trial subscription %s after charge failure: %s', - getattr(subscription, 'id', ''), - error, + 'Failed to remove trial subscription after charge failure', + getattr=getattr(subscription, 'id', ''), + error=error, ) await db.rollback() return False @@ -189,9 +187,9 @@ async def revert_trial_activation( await db.refresh(user) except Exception as error: # pragma: no cover - defensive logging logger.warning( - 'Failed to refresh user %s after reverting trial activation: %s', - getattr(user, 'id', ''), - error, + 'Failed to refresh user after reverting trial activation', + getattr=getattr(user, 'id', ''), + error=error, ) return TrialActivationReversionResult( diff --git a/app/services/tribute_service.py b/app/services/tribute_service.py index c45508dc..38e9bc27 100644 --- a/app/services/tribute_service.py +++ b/app/services/tribute_service.py @@ -1,8 +1,8 @@ import json -import logging from datetime import datetime from typing import Any +import structlog from aiogram import Bot from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup @@ -19,7 +19,7 @@ from app.services.subscription_auto_purchase_service import ( from app.utils.user_utils import format_referrer_info -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class TributeService: @@ -46,7 +46,7 @@ class TributeService: try: await self.bot.delete_message(chat_id, message_id) except Exception as error: # pragma: no cover - depends on bot rights - logger.warning('Не удалось удалить Tribute счёт %s: %s', message_id, error) + logger.warning('Не удалось удалить Tribute счёт', message_id=message_id, error=error) async def create_payment_link( self, user_id: int, amount_kopeks: int, description: str = 'Пополнение баланса' @@ -66,7 +66,7 @@ class TributeService: return payment_url except Exception as e: - logger.error(f'Ошибка создания Tribute платежа: {e}') + logger.error('Ошибка создания Tribute платежа', error=e) return None async def process_webhook(self, payload: str) -> dict[str, Any]: @@ -76,7 +76,7 @@ class TributeService: logger.error('Некорректный JSON в Tribute webhook') return {'status': 'error', 'reason': 'invalid_json'} - logger.info(f'Получен Tribute webhook: {json.dumps(webhook_data, ensure_ascii=False)}') + logger.info('Получен Tribute webhook', dumps=json.dumps(webhook_data, ensure_ascii=False)) processed_data = await self.tribute_api.process_webhook(webhook_data) if not processed_data: @@ -101,16 +101,23 @@ class TributeService: payment_id = payment_data['payment_id'] logger.info( - f'Обрабатываем успешный Tribute платеж: user_telegram_id={user_telegram_id}, amount={amount_kopeks}, payment_id={payment_id}' + 'Обрабатываем успешный Tribute платеж: user_telegram_id=, amount=, payment_id', + user_telegram_id=user_telegram_id, + amount_kopeks=amount_kopeks, + payment_id=payment_id, ) async for session in get_db(): user = await get_user_by_telegram_id(session, user_telegram_id) if not user: - logger.error(f'Пользователь {user_telegram_id} не найден') + logger.error('Пользователь не найден', user_telegram_id=user_telegram_id) return - logger.info(f'Найден пользователь {user.telegram_id}, текущий баланс: {user.balance_kopeks} коп') + logger.info( + 'Найден пользователь текущий баланс: коп', + telegram_id=user.telegram_id, + balance_kopeks=user.balance_kopeks, + ) from app.database.crud.transaction import check_tribute_payment_duplicate @@ -120,10 +127,10 @@ class TributeService: if duplicate_transaction: logger.warning('Найден дубликат платежа в течение 24ч:') - logger.warning(f' Transaction ID: {duplicate_transaction.id}') - logger.warning(f' Amount: {duplicate_transaction.amount_kopeks} коп') - logger.warning(f' Created: {duplicate_transaction.created_at}') - logger.warning(f' External ID: {duplicate_transaction.external_id}') + logger.warning('Transaction ID', duplicate_transaction_id=duplicate_transaction.id) + logger.warning('Amount: коп', amount_kopeks=duplicate_transaction.amount_kopeks) + logger.warning('Created', created_at=duplicate_transaction.created_at) + logger.warning('External ID', external_id=duplicate_transaction.external_id) logger.warning('Платеж игнорирован - это дубликат свежего платежа') return @@ -155,7 +162,7 @@ class TributeService: await process_referral_topup(session, user.id, amount_kopeks, self.bot) except Exception as e: - logger.error(f'Ошибка обработки реферального пополнения Tribute: {e}') + logger.error('Ошибка обработки реферального пополнения Tribute', error=e) if was_first_topup and not user.has_made_first_topup: user.has_made_first_topup = True @@ -164,12 +171,16 @@ class TributeService: await session.refresh(user) logger.info( - f'✅ Баланс пользователя {user_telegram_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})' + '✅ Баланс пользователя обновлен: коп (+)', + user_telegram_id=user_telegram_id, + old_balance=old_balance, + balance_kopeks=user.balance_kopeks, + amount_kopeks=amount_kopeks, ) - logger.info(f'✅ Создана транзакция ID: {transaction.id}') + logger.info('✅ Создана транзакция ID', transaction_id=transaction.id) if was_first_topup: - logger.info(f'Отмечен первый топап для пользователя {user_telegram_id}') + logger.info('Отмечен первый топап для пользователя', user_telegram_id=user_telegram_id) try: from app.services.admin_notification_service import AdminNotificationService @@ -186,18 +197,20 @@ class TributeService: db=session, ) except Exception as e: - logger.error(f'Ошибка отправки уведомления о Tribute пополнении: {e}') + logger.error('Ошибка отправки уведомления о Tribute пополнении', error=e) await self._cleanup_invoice_message(user_telegram_id) await self._send_success_notification(user_telegram_id, amount_kopeks) logger.info( - f'🎉 Успешно обработан Tribute платеж: {amount_kopeks / 100}₽ для пользователя {user_telegram_id}' + '🎉 Успешно обработан Tribute платеж: ₽ для пользователя', + amount_kopeks=amount_kopeks / 100, + user_telegram_id=user_telegram_id, ) break except Exception as e: - logger.error(f'Ошибка обработки успешного Tribute платежа: {e}', exc_info=True) + logger.error('Ошибка обработки успешного Tribute платежа', error=e, exc_info=True) async def _handle_failed_payment(self, payment_data: dict[str, Any]): try: @@ -215,11 +228,11 @@ class TributeService: await self._send_failure_notification(user_id) - logger.info(f'Обработан неудачный Tribute платеж для пользователя {user_id}') + logger.info('Обработан неудачный Tribute платеж для пользователя', user_id=user_id) break except Exception as e: - logger.error(f'Ошибка обработки неудачного Tribute платежа: {e}') + logger.error('Ошибка обработки неудачного Tribute платежа', error=e) async def _handle_refund(self, refund_data: dict[str, Any]): try: @@ -246,11 +259,13 @@ class TributeService: await self._send_refund_notification(user_id, amount_kopeks) - logger.info(f'Обработан возврат Tribute: {amount_kopeks / 100}₽ для пользователя {user_id}') + logger.info( + 'Обработан возврат Tribute: ₽ для пользователя', amount_kopeks=amount_kopeks / 100, user_id=user_id + ) break except Exception as e: - logger.error(f'Ошибка обработки возврата Tribute: {e}') + logger.error('Ошибка обработки возврата Tribute', error=e) async def _send_success_notification(self, user_id: int, amount_kopeks: int): # Skip if no telegram_id (email-only user) @@ -297,9 +312,9 @@ class TributeService: ) except Exception as auto_error: logger.error( - 'Ошибка автоматической покупки подписки для пользователя %s: %s', - user.id, - auto_error, + 'Ошибка автоматической покупки подписки для пользователя', + user_id=user.id, + auto_error=auto_error, exc_info=True, ) @@ -340,12 +355,11 @@ class TributeService: reply_markup=keyboard, ) logger.info( - 'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s', - user_id, + 'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю', user_id=user_id ) except Exception as e: - logger.error(f'Ошибка отправки уведомления об успешном платеже: {e}') + logger.error('Ошибка отправки уведомления об успешном платеже', error=e) async def _send_failure_notification(self, user_id: int): # Skip if no telegram_id (email-only user) @@ -374,7 +388,7 @@ class TributeService: await self.bot.send_message(user_id, text, reply_markup=keyboard, parse_mode='Markdown') except Exception as e: - logger.error(f'Ошибка отправки уведомления о неудачном платеже: {e}') + logger.error('Ошибка отправки уведомления о неудачном платеже', error=e) async def _send_refund_notification(self, user_id: int, amount_kopeks: int): # Skip if no telegram_id (email-only user) @@ -403,7 +417,7 @@ class TributeService: await self.bot.send_message(user_id, text, reply_markup=keyboard, parse_mode='Markdown') except Exception as e: - logger.error(f'Ошибка отправки уведомления о возврате: {e}') + logger.error('Ошибка отправки уведомления о возврате', error=e) async def force_process_payment( self, @@ -414,13 +428,16 @@ class TributeService: ) -> bool: try: logger.info( - f'🔧 ПРИНУДИТЕЛЬНАЯ ОБРАБОТКА: payment_id={payment_id}, user_id={user_id}, amount={amount_kopeks}' + '🔧 ПРИНУДИТЕЛЬНАЯ ОБРАБОТКА: payment_id=, user_id=, amount', + payment_id=payment_id, + user_id=user_id, + amount_kopeks=amount_kopeks, ) async for session in get_db(): user = await get_user_by_telegram_id(session, user_id) if not user: - logger.error(f'⌘ Пользователь {user_id} не найден') + logger.error('⌘ Пользователь не найден', user_id=user_id) return False external_id = f'force_donation_{payment_id}_{int(datetime.utcnow().timestamp())}' @@ -442,15 +459,17 @@ class TributeService: await session.commit() - logger.info(f'💰 ПРИНУДИТЕЛЬНО обновлен баланс: {old_balance} -> {user.balance_kopeks} коп') + logger.info( + '💰 ПРИНУДИТЕЛЬНО обновлен баланс: коп', old_balance=old_balance, balance_kopeks=user.balance_kopeks + ) await self._send_success_notification(user_id, amount_kopeks) - logger.info(f'✅ Принудительно обработан платеж {payment_id}') + logger.info('✅ Принудительно обработан платеж', payment_id=payment_id) return True except Exception as e: - logger.error(f'Ошибка принудительной обработки: {e}', exc_info=True) + logger.error('Ошибка принудительной обработки', error=e, exc_info=True) return False async def get_payment_status(self, payment_id: str) -> dict[str, Any] | None: diff --git a/app/services/user_cart_service.py b/app/services/user_cart_service.py index f9522d28..36476b5e 100644 --- a/app/services/user_cart_service.py +++ b/app/services/user_cart_service.py @@ -1,13 +1,13 @@ import json -import logging from typing import Any import redis.asyncio as redis +import structlog from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class UserCartService: @@ -32,7 +32,7 @@ class UserCartService: self._initialized = True logger.debug('Redis клиент для корзины инициализирован') except Exception as e: - logger.warning(f'Не удалось подключиться к Redis для корзины: {e}') + logger.warning('Не удалось подключиться к Redis для корзины', error=e) self._redis_client = None self._initialized = True @@ -52,7 +52,7 @@ class UserCartService: """ client = self._get_redis_client() if client is None: - logger.warning(f'🛒 Redis недоступен, корзина пользователя {user_id} НЕ сохранена') + logger.warning('🛒 Redis недоступен, корзина пользователя НЕ сохранена', user_id=user_id) return False try: @@ -61,10 +61,15 @@ class UserCartService: effective_ttl = ttl if ttl is not None else settings.CART_TTL_SECONDS await client.setex(key, effective_ttl, json_data) cart_mode = cart_data.get('cart_mode', 'unknown') - logger.info(f'🛒 Корзина пользователя {user_id} сохранена в Redis (mode={cart_mode}, ttl={effective_ttl}s)') + logger.info( + '🛒 Корзина пользователя сохранена в Redis (mode=, ttl=s)', + user_id=user_id, + cart_mode=cart_mode, + effective_ttl=effective_ttl, + ) return True except Exception as e: - logger.error(f'🛒 Ошибка сохранения корзины пользователя {user_id}: {e}') + logger.error('🛒 Ошибка сохранения корзины пользователя', user_id=user_id, error=e) return False async def get_user_cart(self, user_id: int) -> dict[str, Any] | None: @@ -86,11 +91,11 @@ class UserCartService: json_data = await client.get(key) if json_data: cart_data = json.loads(json_data) - logger.debug(f'Корзина пользователя {user_id} загружена из Redis') + logger.debug('Корзина пользователя загружена из Redis', user_id=user_id) return cart_data return None except Exception as e: - logger.error(f'Ошибка получения корзины пользователя {user_id}: {e}') + logger.error('Ошибка получения корзины пользователя', user_id=user_id, error=e) return None async def delete_user_cart(self, user_id: int) -> bool: @@ -111,10 +116,10 @@ class UserCartService: key = f'user_cart:{user_id}' result = await client.delete(key) if result: - logger.debug(f'Корзина пользователя {user_id} удалена из Redis') + logger.debug('Корзина пользователя удалена из Redis', user_id=user_id) return bool(result) except Exception as e: - logger.error(f'Ошибка удаления корзины пользователя {user_id}: {e}') + logger.error('Ошибка удаления корзины пользователя', user_id=user_id, error=e) return False async def has_user_cart(self, user_id: int) -> bool: @@ -129,17 +134,19 @@ class UserCartService: """ client = self._get_redis_client() if client is None: - logger.warning(f'🛒 Redis недоступен, проверка корзины пользователя {user_id} невозможна') + logger.warning('🛒 Redis недоступен, проверка корзины пользователя невозможна', user_id=user_id) return False try: key = f'user_cart:{user_id}' exists = await client.exists(key) result = bool(exists) - logger.info(f'🛒 Проверка корзины пользователя {user_id}: {"найдена" if result else "не найдена"}') + logger.info( + '🛒 Проверка корзины пользователя', user_id=user_id, value='найдена' if result else 'не найдена' + ) return result except Exception as e: - logger.error(f'🛒 Ошибка проверки наличия корзины пользователя {user_id}: {e}') + logger.error('🛒 Ошибка проверки наличия корзины пользователя', user_id=user_id, error=e) return False diff --git a/app/services/user_service.py b/app/services/user_service.py index 77d77ee9..ac0f923d 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -1,7 +1,7 @@ -import logging from datetime import datetime, timedelta from typing import Any +import structlog from aiogram import Bot, types from sqlalchemy import delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -62,7 +62,7 @@ from app.services.notification_delivery_service import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class UserService: @@ -213,7 +213,7 @@ class UserService: } except Exception as e: - logger.error(f'Ошибка получения профиля пользователя {user_id}: {e}') + logger.error('Ошибка получения профиля пользователя', user_id=user_id, error=e) return None async def search_users(self, db: AsyncSession, query: str, page: int = 1, limit: int = 20) -> dict[str, Any]: @@ -235,7 +235,7 @@ class UserService: } except Exception as e: - logger.error(f'Ошибка поиска пользователей: {e}') + logger.error('Ошибка поиска пользователей', error=e) return { 'users': [], 'current_page': 1, @@ -285,7 +285,7 @@ class UserService: } except Exception as e: - logger.error(f'Ошибка получения страницы пользователей: {e}') + logger.error('Ошибка получения страницы пользователей', error=e) return { 'users': [], 'current_page': 1, @@ -339,7 +339,7 @@ class UserService: } except Exception as e: - logger.error(f'Ошибка получения пользователей для продления: {e}') + logger.error('Ошибка получения пользователей для продления', error=e) return { 'users': [], 'current_page': 1, @@ -405,7 +405,7 @@ class UserService: } except Exception as e: - logger.error(f'Ошибка получения потенциальных клиентов: {e}') + logger.error('Ошибка получения потенциальных клиентов', error=e) return { 'users': [], 'current_page': 1, @@ -417,7 +417,7 @@ class UserService: try: return await get_users_spending_stats(db, user_ids) except Exception as e: - logger.error(f'Ошибка получения статистики трат пользователей: {e}') + logger.error('Ошибка получения статистики трат пользователей', error=e) return {} async def get_users_by_campaign_page(self, db: AsyncSession, page: int = 1, limit: int = 20) -> dict[str, Any]: @@ -493,7 +493,7 @@ class UserService: } except Exception as e: - logger.error(f'Ошибка получения пользователей по кампаниям: {e}') + logger.error('Ошибка получения пользователей по кампаниям', error=e) return { 'users': [], 'campaigns': {}, @@ -525,7 +525,12 @@ class UserService: await add_user_balance( db, user, amount_kopeks, description=description, payment_method=PaymentMethod.MANUAL ) - logger.info(f'Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks / 100}₽') + logger.info( + 'Админ пополнил баланс пользователя на ₽', + admin_id=admin_id, + user_id=user_id, + amount_kopeks=amount_kopeks / 100, + ) success = True else: success = await subtract_user_balance( @@ -537,7 +542,12 @@ class UserService: payment_method=PaymentMethod.MANUAL, ) if success: - logger.info(f'Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks) / 100}₽') + logger.info( + 'Админ списал с баланса пользователя ₽', + admin_id=admin_id, + user_id=user_id, + value=abs(amount_kopeks) / 100, + ) # Отправляем уведомление пользователю, если операция прошла успешно if success and bot: @@ -555,7 +565,7 @@ class UserService: return success except Exception as e: - logger.error(f'Ошибка изменения баланса пользователя: {e}') + logger.error('Ошибка изменения баланса пользователя', error=e) return False async def update_user_promo_group( @@ -580,16 +590,16 @@ class UserService: await db.refresh(user) logger.info( - "👥 Промогруппа пользователя %s обновлена на '%s'", - user.telegram_id, - promo_group.name, + "👥 Промогруппа пользователя обновлена на ''", + telegram_id=user.telegram_id, + promo_group_name=promo_group.name, ) return True, user, promo_group, old_group except Exception as e: await db.rollback() - logger.error(f'Ошибка обновления промогруппы пользователя {user_id}: {e}') + logger.error('Ошибка обновления промогруппы пользователя', user_id=user_id, error=e) return False, None, None, None async def update_user_referrals( @@ -627,12 +637,12 @@ class UserService: await db.commit() logger.info( - 'Админ %s обновил рефералов пользователя %s: добавлено %s, удалено %s, всего %s', - admin_id, - user_id, - len(to_add), - len(to_remove), - len(unique_ids), + 'Админ обновил рефералов пользователя : добавлено , удалено , всего', + admin_id=admin_id, + user_id=user_id, + to_add_count=len(to_add), + to_remove_count=len(to_remove), + unique_ids_count=len(unique_ids), ) return True, { @@ -643,11 +653,7 @@ class UserService: except Exception as e: await db.rollback() - logger.error( - 'Ошибка обновления рефералов пользователя %s: %s', - user_id, - e, - ) + logger.error('Ошибка обновления рефералов пользователя', user_id=user_id, e=e) return False, {'error': 'update_failed'} async def block_user( @@ -664,9 +670,11 @@ class UserService: subscription_service = SubscriptionService() await subscription_service.disable_remnawave_user(user.remnawave_uuid) - logger.info(f'✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован при блокировке') + logger.info( + '✅ RemnaWave пользователь деактивирован при блокировке', remnawave_uuid=user.remnawave_uuid + ) except Exception as e: - logger.error(f'❌ Ошибка деактивации RemnaWave пользователя при блокировке: {e}') + logger.error('❌ Ошибка деактивации RemnaWave пользователя при блокировке', error=e) if user.subscription: from app.database.crud.subscription import deactivate_subscription @@ -675,11 +683,11 @@ class UserService: await update_user(db, user, status=UserStatus.BLOCKED.value) - logger.info(f'Админ {admin_id} заблокировал пользователя {user_id}: {reason}') + logger.info('Админ заблокировал пользователя', admin_id=admin_id, user_id=user_id, reason=reason) return True except Exception as e: - logger.error(f'Ошибка блокировки пользователя: {e}') + logger.error('Ошибка блокировки пользователя', error=e) return False async def unblock_user(self, db: AsyncSession, user_id: int, admin_id: int) -> bool: @@ -699,7 +707,7 @@ class UserService: user.subscription.status = SubscriptionStatus.ACTIVE.value await db.commit() await db.refresh(user.subscription) - logger.info(f'🔄 Подписка пользователя {user_id} восстановлена') + logger.info('🔄 Подписка пользователя восстановлена', user_id=user_id) if user.remnawave_uuid: try: @@ -708,29 +716,32 @@ class UserService: subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, user.subscription) logger.info( - f'✅ RemnaWave пользователь {user.remnawave_uuid} восстановлен при разблокировке' + '✅ RemnaWave пользователь восстановлен при разблокировке', + remnawave_uuid=user.remnawave_uuid, ) except Exception as e: - logger.error(f'❌ Ошибка восстановления RemnaWave пользователя при разблокировке: {e}') + logger.error('❌ Ошибка восстановления RemnaWave пользователя при разблокировке', error=e) else: - logger.info(f'⏰ Подписка пользователя {user_id} истекла, восстановление невозможно') + logger.info('⏰ Подписка пользователя истекла, восстановление невозможно', user_id=user_id) - logger.info(f'Админ {admin_id} разблокировал пользователя {user_id}') + logger.info('Админ разблокировал пользователя', admin_id=admin_id, user_id=user_id) return True except Exception as e: - logger.error(f'Ошибка разблокировки пользователя: {e}') + logger.error('Ошибка разблокировки пользователя', error=e) return False async def delete_user_account(self, db: AsyncSession, user_id: int, admin_id: int) -> bool: try: user = await get_user_by_id(db, user_id) if not user: - logger.warning(f'Пользователь {user_id} не найден для удаления') + logger.warning('Пользователь не найден для удаления', user_id=user_id) return False user_id_display = user.telegram_id or user.email or f'#{user.id}' - logger.info(f'🗑️ Начинаем полное удаление пользователя {user_id} (ID: {user_id_display})') + logger.info( + '🗑️ Начинаем полное удаление пользователя (ID: )', user_id=user_id, user_id_display=user_id_display + ) if user.remnawave_uuid: from app.config import settings @@ -747,10 +758,13 @@ class UserService: async with remnawave_service.get_api_client() as api: delete_success = await api.delete_user(user.remnawave_uuid) if delete_success: - logger.info(f'✅ RemnaWave пользователь {user.remnawave_uuid} удален из панели') + logger.info( + '✅ RemnaWave пользователь удален из панели', remnawave_uuid=user.remnawave_uuid + ) else: logger.warning( - f'⚠️ Не удалось удалить пользователя {user.remnawave_uuid} из панели Remnawave' + '⚠️ Не удалось удалить пользователя из панели Remnawave', + remnawave_uuid=user.remnawave_uuid, ) else: # Деактивируем пользователя в панели Remnawave @@ -759,11 +773,15 @@ class UserService: subscription_service = SubscriptionService() await subscription_service.disable_remnawave_user(user.remnawave_uuid) logger.info( - f'✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован (режим: {delete_mode})' + '✅ RemnaWave пользователь деактивирован (режим: )', + remnawave_uuid=user.remnawave_uuid, + delete_mode=delete_mode, ) except Exception as e: - logger.warning(f'⚠️ Ошибка обработки пользователя в Remnawave (режим: {delete_mode}): {e}') + logger.warning( + '⚠️ Ошибка обработки пользователя в Remnawave (режим: )', delete_mode=delete_mode, error=e + ) # Если основное действие не удалось, попытаемся хотя бы деактивировать if delete_mode == 'delete': try: @@ -771,9 +789,12 @@ class UserService: subscription_service = SubscriptionService() await subscription_service.disable_remnawave_user(user.remnawave_uuid) - logger.info(f'✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован как fallback') + logger.info( + '✅ RemnaWave пользователь деактивирован как fallback', + remnawave_uuid=user.remnawave_uuid, + ) except Exception as fallback_e: - logger.error(f'❌ Ошибка деактивации RemnaWave как fallback: {fallback_e}') + logger.error('❌ Ошибка деактивации RemnaWave как fallback', fallback_e=fallback_e) try: sent_notifications_result = await db.execute( @@ -782,11 +803,11 @@ class UserService: sent_notifications = sent_notifications_result.scalars().all() if sent_notifications: - logger.info(f'🔄 Удаляем {len(sent_notifications)} уведомлений') + logger.info('🔄 Удаляем уведомлений', sent_notifications_count=len(sent_notifications)) await db.execute(delete(SentNotification).where(SentNotification.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка удаления уведомлений: {e}') + logger.error('❌ Ошибка удаления уведомлений', error=e) try: if user.subscription: @@ -802,60 +823,62 @@ class UserService: ) if subscription_servers: - logger.info(f'🔄 Удаляем {len(subscription_servers)} связей подписка-сервер') + logger.info( + '🔄 Удаляем связей подписка-сервер', subscription_servers_count=len(subscription_servers) + ) await db.execute( delete(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id) ) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка удаления связей подписка-сервер: {e}') + logger.error('❌ Ошибка удаления связей подписка-сервер', error=e) try: user_messages_result = await db.execute( update(UserMessage).where(UserMessage.created_by == user_id).values(created_by=None) ) if user_messages_result.rowcount > 0: - logger.info(f'🔄 Обновлено {user_messages_result.rowcount} пользовательских сообщений') + logger.info('🔄 Обновлено пользовательских сообщений', rowcount=user_messages_result.rowcount) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка обновления пользовательских сообщений: {e}') + logger.error('❌ Ошибка обновления пользовательских сообщений', error=e) try: promocodes_result = await db.execute( update(PromoCode).where(PromoCode.created_by == user_id).values(created_by=None) ) if promocodes_result.rowcount > 0: - logger.info(f'🔄 Обновлено {promocodes_result.rowcount} промокодов') + logger.info('🔄 Обновлено промокодов', rowcount=promocodes_result.rowcount) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка обновления промокодов: {e}') + logger.error('❌ Ошибка обновления промокодов', error=e) try: welcome_texts_result = await db.execute( update(WelcomeText).where(WelcomeText.created_by == user_id).values(created_by=None) ) if welcome_texts_result.rowcount > 0: - logger.info(f'🔄 Обновлено {welcome_texts_result.rowcount} приветственных текстов') + logger.info('🔄 Обновлено приветственных текстов', rowcount=welcome_texts_result.rowcount) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка обновления приветственных текстов: {e}') + logger.error('❌ Ошибка обновления приветственных текстов', error=e) try: referrals_result = await db.execute( update(User).where(User.referred_by_id == user_id).values(referred_by_id=None) ) if referrals_result.rowcount > 0: - logger.info(f'🔗 Очищены реферальные ссылки у {referrals_result.rowcount} рефералов') + logger.info('🔗 Очищены реферальные ссылки у рефералов', rowcount=referrals_result.rowcount) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка очистки реферальных ссылок: {e}') + logger.error('❌ Ошибка очистки реферальных ссылок', error=e) try: yookassa_result = await db.execute(select(YooKassaPayment).where(YooKassaPayment.user_id == user_id)) yookassa_payments = yookassa_result.scalars().all() if yookassa_payments: - logger.info(f'🔄 Удаляем {len(yookassa_payments)} YooKassa платежей') + logger.info('🔄 Удаляем YooKassa платежей', yookassa_payments_count=len(yookassa_payments)) await db.execute( update(YooKassaPayment).where(YooKassaPayment.user_id == user_id).values(transaction_id=None) ) @@ -863,14 +886,14 @@ class UserService: await db.execute(delete(YooKassaPayment).where(YooKassaPayment.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка удаления YooKassa платежей: {e}') + logger.error('❌ Ошибка удаления YooKassa платежей', error=e) try: cryptobot_result = await db.execute(select(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)) cryptobot_payments = cryptobot_result.scalars().all() if cryptobot_payments: - logger.info(f'🔄 Удаляем {len(cryptobot_payments)} CryptoBot платежей') + logger.info('🔄 Удаляем CryptoBot платежей', cryptobot_payments_count=len(cryptobot_payments)) await db.execute( update(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id).values(transaction_id=None) ) @@ -878,14 +901,14 @@ class UserService: await db.execute(delete(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка удаления CryptoBot платежей: {e}') + logger.error('❌ Ошибка удаления CryptoBot платежей', error=e) try: platega_result = await db.execute(select(PlategaPayment).where(PlategaPayment.user_id == user_id)) platega_payments = platega_result.scalars().all() if platega_payments: - logger.info(f'🔄 Удаляем {len(platega_payments)} Platega платежей') + logger.info('🔄 Удаляем Platega платежей', platega_payments_count=len(platega_payments)) await db.execute( update(PlategaPayment).where(PlategaPayment.user_id == user_id).values(transaction_id=None) ) @@ -893,7 +916,7 @@ class UserService: await db.execute(delete(PlategaPayment).where(PlategaPayment.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка удаления Platega платежей: {e}') + logger.error('❌ Ошибка удаления Platega платежей', error=e) try: mulenpay_result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.user_id == user_id)) @@ -901,7 +924,11 @@ class UserService: if mulenpay_payments: mulenpay_name = settings.get_mulenpay_display_name() - logger.info(f'🔄 Удаляем {len(mulenpay_payments)} {mulenpay_name} платежей') + logger.info( + '🔄 Удаляем платежей', + mulenpay_payments_count=len(mulenpay_payments), + mulenpay_name=mulenpay_name, + ) await db.execute( update(MulenPayPayment).where(MulenPayPayment.user_id == user_id).values(transaction_id=None) ) @@ -909,14 +936,18 @@ class UserService: await db.execute(delete(MulenPayPayment).where(MulenPayPayment.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка удаления {settings.get_mulenpay_display_name()} платежей: {e}') + logger.error( + '❌ Ошибка удаления платежей', + get_mulenpay_display_name=settings.get_mulenpay_display_name(), + error=e, + ) try: pal24_result = await db.execute(select(Pal24Payment).where(Pal24Payment.user_id == user_id)) pal24_payments = pal24_result.scalars().all() if pal24_payments: - logger.info(f'🔄 Удаляем {len(pal24_payments)} Pal24 платежей') + logger.info('🔄 Удаляем Pal24 платежей', pal24_payments_count=len(pal24_payments)) await db.execute( update(Pal24Payment).where(Pal24Payment.user_id == user_id).values(transaction_id=None) ) @@ -924,14 +955,14 @@ class UserService: await db.execute(delete(Pal24Payment).where(Pal24Payment.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка удаления Pal24 платежей: {e}') + logger.error('❌ Ошибка удаления Pal24 платежей', error=e) try: heleket_result = await db.execute(select(HeleketPayment).where(HeleketPayment.user_id == user_id)) heleket_payments = heleket_result.scalars().all() if heleket_payments: - logger.info(f'🔄 Удаляем {len(heleket_payments)} Heleket платежей') + logger.info('🔄 Удаляем Heleket платежей', heleket_payments_count=len(heleket_payments)) await db.execute( update(HeleketPayment).where(HeleketPayment.user_id == user_id).values(transaction_id=None) ) @@ -939,7 +970,7 @@ class UserService: await db.execute(delete(HeleketPayment).where(HeleketPayment.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка удаления Heleket платежей: {e}') + logger.error('❌ Ошибка удаления Heleket платежей', error=e) # Удаляем Freekassa платежи try: @@ -949,7 +980,7 @@ class UserService: freekassa_payments = freekassa_payments_result.scalars().all() if freekassa_payments: - logger.info(f'🔄 Удаляем {len(freekassa_payments)} Freekassa платежей') + logger.info('🔄 Удаляем Freekassa платежей', freekassa_payments_count=len(freekassa_payments)) await db.execute( update(FreekassaPayment).where(FreekassaPayment.user_id == user_id).values(transaction_id=None) ) @@ -957,7 +988,7 @@ class UserService: await db.execute(delete(FreekassaPayment).where(FreekassaPayment.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка удаления Freekassa платежей: {e}') + logger.error('❌ Ошибка удаления Freekassa платежей', error=e) # Удаляем Wata платежи (до транзакций, т.к. wata_payments.transaction_id -> transactions.id) try: @@ -965,7 +996,7 @@ class UserService: wata_payments = wata_payments_result.scalars().all() if wata_payments: - logger.info(f'🔄 Удаляем {len(wata_payments)} Wata платежей') + logger.info('🔄 Удаляем Wata платежей', wata_payments_count=len(wata_payments)) await db.execute( update(WataPayment).where(WataPayment.user_id == user_id).values(transaction_id=None) ) @@ -973,7 +1004,7 @@ class UserService: await db.execute(delete(WataPayment).where(WataPayment.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка удаления Wata платежей: {e}') + logger.error('❌ Ошибка удаления Wata платежей', error=e) # Удаляем CloudPayments платежи try: @@ -983,7 +1014,9 @@ class UserService: cloudpayments_payments = cloudpayments_result.scalars().all() if cloudpayments_payments: - logger.info(f'🔄 Удаляем {len(cloudpayments_payments)} CloudPayments платежей') + logger.info( + '🔄 Удаляем CloudPayments платежей', cloudpayments_payments_count=len(cloudpayments_payments) + ) await db.execute( update(CloudPaymentsPayment) .where(CloudPaymentsPayment.user_id == user_id) @@ -993,7 +1026,7 @@ class UserService: await db.execute(delete(CloudPaymentsPayment).where(CloudPaymentsPayment.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка удаления CloudPayments платежей: {e}') + logger.error('❌ Ошибка удаления CloudPayments платежей', error=e) # Удаляем KassaAi платежи try: @@ -1001,7 +1034,7 @@ class UserService: kassa_ai_payments = kassa_ai_result.scalars().all() if kassa_ai_payments: - logger.info(f'🔄 Удаляем {len(kassa_ai_payments)} KassaAi платежей') + logger.info('🔄 Удаляем KassaAi платежей', kassa_ai_payments_count=len(kassa_ai_payments)) await db.execute( update(KassaAiPayment).where(KassaAiPayment.user_id == user_id).values(transaction_id=None) ) @@ -1009,29 +1042,29 @@ class UserService: await db.execute(delete(KassaAiPayment).where(KassaAiPayment.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка удаления KassaAi платежей: {e}') + logger.error('❌ Ошибка удаления KassaAi платежей', error=e) try: transactions_result = await db.execute(select(Transaction).where(Transaction.user_id == user_id)) transactions = transactions_result.scalars().all() if transactions: - logger.info(f'🔄 Удаляем {len(transactions)} транзакций') + logger.info('🔄 Удаляем транзакций', transactions_count=len(transactions)) await db.execute(delete(Transaction).where(Transaction.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка удаления транзакций: {e}') + logger.error('❌ Ошибка удаления транзакций', error=e) try: promocode_uses_result = await db.execute(select(PromoCodeUse).where(PromoCodeUse.user_id == user_id)) promocode_uses = promocode_uses_result.scalars().all() if promocode_uses: - logger.info(f'🔄 Удаляем {len(promocode_uses)} использований промокодов') + logger.info('🔄 Удаляем использований промокодов', promocode_uses_count=len(promocode_uses)) await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка удаления использований промокодов: {e}') + logger.error('❌ Ошибка удаления использований промокодов', error=e) try: referral_earnings_result = await db.execute( @@ -1040,11 +1073,11 @@ class UserService: referral_earnings = referral_earnings_result.scalars().all() if referral_earnings: - logger.info(f'🔄 Удаляем {len(referral_earnings)} реферальных доходов') + logger.info('🔄 Удаляем реферальных доходов', referral_earnings_count=len(referral_earnings)) await db.execute(delete(ReferralEarning).where(ReferralEarning.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка удаления реферальных доходов: {e}') + logger.error('❌ Ошибка удаления реферальных доходов', error=e) try: referral_records_result = await db.execute( @@ -1053,11 +1086,11 @@ class UserService: referral_records = referral_records_result.scalars().all() if referral_records: - logger.info(f'🔄 Удаляем {len(referral_records)} записей о рефералах') + logger.info('🔄 Удаляем записей о рефералах', referral_records_count=len(referral_records)) await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user_id)) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка удаления записей о рефералах: {e}') + logger.error('❌ Ошибка удаления записей о рефералах', error=e) try: conversions_result = await db.execute( @@ -1066,11 +1099,11 @@ class UserService: conversions = conversions_result.scalars().all() if conversions: - logger.info(f'🔄 Удаляем {len(conversions)} записей конверсий') + logger.info('🔄 Удаляем записей конверсий', conversions_count=len(conversions)) await db.execute(delete(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка удаления записей конверсий: {e}') + logger.error('❌ Ошибка удаления записей конверсий', error=e) try: broadcast_history_result = await db.execute( @@ -1079,11 +1112,11 @@ class UserService: broadcast_history = broadcast_history_result.scalars().all() if broadcast_history: - logger.info(f'🔄 Удаляем {len(broadcast_history)} записей истории рассылок') + logger.info('🔄 Удаляем записей истории рассылок', broadcast_history_count=len(broadcast_history)) await db.execute(delete(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка удаления истории рассылок: {e}') + logger.error('❌ Ошибка удаления истории рассылок', error=e) try: campaigns_result = await db.execute( @@ -1092,7 +1125,7 @@ class UserService: campaigns = campaigns_result.scalars().all() if campaigns: - logger.info(f'🔄 Очищаем создателя у {len(campaigns)} рекламных кампаний') + logger.info('🔄 Очищаем создателя у рекламных кампаний', campaigns_count=len(campaigns)) await db.execute( update(AdvertisingCampaign) .where(AdvertisingCampaign.created_by == user_id) @@ -1100,35 +1133,38 @@ class UserService: ) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка обновления рекламных кампаний: {e}') + logger.error('❌ Ошибка обновления рекламных кампаний', error=e) try: if user.subscription: - logger.info(f'🔄 Удаляем подписку {user.subscription.id}') + logger.info('🔄 Удаляем подписку', subscription_id=user.subscription.id) await db.execute( delete(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id) ) await db.execute(delete(Subscription).where(Subscription.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f'❌ Ошибка удаления подписки: {e}') + logger.error('❌ Ошибка удаления подписки', error=e) try: await db.execute(delete(User).where(User.id == user_id)) await db.commit() - logger.info(f'✅ Пользователь {user_id} окончательно удален из базы') + logger.info('✅ Пользователь окончательно удален из базы', user_id=user_id) except Exception as e: - logger.error(f'❌ Ошибка финального удаления пользователя: {e}') + logger.error('❌ Ошибка финального удаления пользователя', error=e) await db.rollback() return False logger.info( - f'✅ Пользователь {user_id_display} (ID: {user_id}) полностью удален администратором {admin_id}' + '✅ Пользователь (ID: ) полностью удален администратором', + user_id_display=user_id_display, + user_id=user_id, + admin_id=admin_id, ) return True except Exception as e: - logger.error(f'❌ Критическая ошибка удаления пользователя {user_id}: {e}') + logger.error('❌ Критическая ошибка удаления пользователя', user_id=user_id, error=e) await db.rollback() return False @@ -1138,7 +1174,7 @@ class UserService: return stats except Exception as e: - logger.error(f'Ошибка получения статистики пользователей: {e}') + logger.error('Ошибка получения статистики пользователей', error=e) return { 'total_users': 0, 'active_users': 0, @@ -1174,14 +1210,13 @@ class UserService: if skipped_active_sub > 0: logger.info( - 'Пропущено %d неактивных пользователей с активной подпиской', - skipped_active_sub, + 'Пропущено неактивных пользователей с активной подпиской', skipped_active_sub=skipped_active_sub ) - logger.info('Удалено %d неактивных пользователей', deleted_count) + logger.info('Удалено неактивных пользователей', deleted_count=deleted_count) return deleted_count, skipped_active_sub except Exception as e: - logger.error('Ошибка очистки неактивных пользователей: %s', e) + logger.error('Ошибка очистки неактивных пользователей', e=e) return 0, 0 async def get_user_activity_summary(self, db: AsyncSession, user_id: int) -> dict[str, Any]: @@ -1218,7 +1253,7 @@ class UserService: } except Exception as e: - logger.error(f'Ошибка получения сводки активности пользователя {user_id}: {e}') + logger.error('Ошибка получения сводки активности пользователя', user_id=user_id, error=e) return {} async def get_users_by_criteria(self, db: AsyncSession, criteria: dict[str, Any]) -> list[User]: @@ -1257,5 +1292,5 @@ class UserService: return filtered_users except Exception as e: - logger.error(f'Ошибка получения пользователей по критериям: {e}') + logger.error('Ошибка получения пользователей по критериям', error=e) return [] diff --git a/app/services/version_service.py b/app/services/version_service.py index 63f1dff5..c36ed6cd 100644 --- a/app/services/version_service.py +++ b/app/services/version_service.py @@ -1,15 +1,15 @@ import asyncio -import logging import re from datetime import datetime, timedelta import aiohttp +import structlog from packaging import version from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class VersionInfo: @@ -127,7 +127,7 @@ class VersionService: return has_updates, newer_releases except Exception as e: - logger.error(f'Ошибка проверки обновлений: {e}') + logger.error('Ошибка проверки обновлений', error=e) return False, [] async def _fetch_releases(self, force: bool = False) -> list[VersionInfo]: @@ -157,16 +157,16 @@ class VersionService: self._cache['releases'] = releases self._last_check = datetime.now() - logger.info(f'Получено {len(releases)} релизов из GitHub') + logger.info('Получено релизов из GitHub', releases_count=len(releases)) return releases - logger.warning(f'GitHub API вернул статус {response.status}') + logger.warning('GitHub API вернул статус', status=response.status) return [] except TimeoutError: logger.warning('Таймаут при запросе к GitHub API') return [] except Exception as e: - logger.error(f'Ошибка запроса к GitHub API: {e}') + logger.error('Ошибка запроса к GitHub API', error=e) return [] def _parse_version(self, version_str: str): @@ -199,7 +199,7 @@ class VersionService: self._cache[cache_key] = True except Exception as e: - logger.error(f'Ошибка отправки уведомления об обновлении: {e}') + logger.error('Ошибка отправки уведомления об обновлении', error=e) async def get_version_info(self) -> dict: try: @@ -225,7 +225,7 @@ class VersionService: } except Exception as e: - logger.error(f'Ошибка получения информации о версиях: {e}') + logger.error('Ошибка получения информации о версиях', error=e) return { 'current_version': self.current_version, 'current_release': None, @@ -242,8 +242,8 @@ class VersionService: logger.info('Проверка версий отключена') return - logger.info(f'Запуск периодической проверки обновлений для {self.repo}') - logger.info(f'Текущая версия: {self.current_version}') + logger.info('Запуск периодической проверки обновлений для', repo=self.repo) + logger.info('Текущая версия', current_version=self.current_version) while True: try: @@ -254,7 +254,7 @@ class VersionService: logger.info('Остановка проверки обновлений') break except Exception as e: - logger.error(f'Ошибка в периодической проверке обновлений: {e}') + logger.error('Ошибка в периодической проверке обновлений', error=e) await asyncio.sleep(300) def format_version_display(self, version_info: VersionInfo) -> str: diff --git a/app/services/wata_service.py b/app/services/wata_service.py index e1ce794a..ecd2825b 100644 --- a/app/services/wata_service.py +++ b/app/services/wata_service.py @@ -3,17 +3,17 @@ from __future__ import annotations import asyncio -import logging import re from datetime import UTC, datetime, timedelta from typing import Any import aiohttp +import structlog from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class WataAPIError(RuntimeError): @@ -100,21 +100,21 @@ class WataService: retry_delay = self._parse_retry_after(response, response_text) if attempt < self._MAX_RETRIES: logger.warning( - 'WATA API 429 on %s %s, retry %d/%d after %.0fs', - method, - path, - attempt + 1, - self._MAX_RETRIES, - retry_delay, + 'WATA API 429 on , retry / after s', + method=method, + path=path, + attempt=attempt + 1, + MAX_RETRIES=self._MAX_RETRIES, + retry_delay=retry_delay, ) await asyncio.sleep(retry_delay) continue - logger.warning('WATA API 429 on %s %s, retries exhausted', method, path) + logger.warning('WATA API 429 on , retries exhausted', method=method, path=path) last_error = WataAPIError(f'WATA API rate limited on {method} {path}') break if response.status >= 400: - logger.error('WATA API error %s: %s', response.status, response_text) + logger.error('WATA API error', response_status=response.status, response_text=response_text) raise WataAPIError(f'WATA API returned status {response.status}: {response_text}') if not response_text: @@ -123,12 +123,12 @@ class WataService: try: data = await response.json() except aiohttp.ContentTypeError as error: - logger.error('WATA API returned non-JSON response: %s', error) + logger.error('WATA API returned non-JSON response', error=error) raise WataAPIError('WATA API returned invalid JSON') from error return data except aiohttp.ClientError as error: - logger.error('Error communicating with WATA API: %s', error) + logger.error('Error communicating with WATA API', error=error) raise WataAPIError('Failed to communicate with WATA API') from error raise last_error or WataAPIError('WATA API request failed') @@ -156,7 +156,7 @@ class WataService: return parsed return parsed.astimezone(UTC).replace(tzinfo=None) except (ValueError, TypeError): - logger.debug('Failed to parse WATA datetime: %s', raw) + logger.debug('Failed to parse WATA datetime', raw=raw) return None async def create_payment_link( @@ -201,18 +201,18 @@ class WataService: payload['arbitraryAmountPrompts'] = arbitrary_amount_prompts logger.info( - 'Создаем WATA платежную ссылку: order_id=%s, amount=%s %s', - order_id, - payload['amount'], - currency, + 'Создаем WATA платежную ссылку: order_id amount', + order_id=order_id, + payload=payload['amount'], + currency=currency, ) response = await self._request('POST', '/links', json=payload) - logger.debug('WATA create link response: %s', response) + logger.debug('WATA create link response', response=response) return response async def get_payment_link(self, payment_link_id: str) -> dict[str, Any]: - logger.debug('Запрашиваем WATA ссылку %s', payment_link_id) + logger.debug('Запрашиваем WATA ссылку', payment_link_id=payment_link_id) return await self._request('GET', f'/links/{payment_link_id}') async def search_transactions( @@ -234,9 +234,11 @@ class WataService: if payment_link_id: params['paymentLinkId'] = payment_link_id - logger.debug('Ищем WATA транзакции: order_id=%s, payment_link_id=%s', order_id, payment_link_id) + logger.debug( + 'Ищем WATA транзакции: order_id payment_link_id', order_id=order_id, payment_link_id=payment_link_id + ) return await self._request('GET', '/transactions', params=params) async def get_transaction(self, transaction_id: str) -> dict[str, Any]: - logger.debug('Получаем WATA транзакцию %s', transaction_id) + logger.debug('Получаем WATA транзакцию', transaction_id=transaction_id) return await self._request('GET', f'/transactions/{transaction_id}') diff --git a/app/services/webhook_service.py b/app/services/webhook_service.py index af7221ab..a0e0678b 100644 --- a/app/services/webhook_service.py +++ b/app/services/webhook_service.py @@ -4,11 +4,11 @@ import asyncio import hashlib import hmac import json -import logging from dataclasses import dataclass from typing import Any import aiohttp +import structlog from sqlalchemy.ext.asyncio import AsyncSession from app.database.crud.webhook import ( @@ -18,7 +18,7 @@ from app.database.crud.webhook import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @dataclass @@ -70,7 +70,7 @@ class WebhookService: webhooks = await get_active_webhooks_for_event(db, event_type) if not webhooks: - logger.debug('No active webhooks for event type: %s', event_type) + logger.debug('No active webhooks for event type', event_type=event_type) return # Выполняем HTTP запросы параллельно (без операций с БД) @@ -80,7 +80,7 @@ class WebhookService: # Записываем результаты в БД последовательно (избегаем concurrent session access) for result in results: if isinstance(result, Exception): - logger.exception('Unexpected error during webhook delivery: %s', result) + logger.exception('Unexpected error during webhook delivery', result=result) continue if isinstance(result, DeliveryResult): await self._record_result(db, result) @@ -166,23 +166,11 @@ class WebhookService: await update_webhook_stats(db, result.webhook, result.status == 'success') if result.status == 'success': - logger.info( - 'Webhook %s delivered successfully to %s', - result.webhook.id, - result.webhook.url, - ) + logger.info('Webhook delivered successfully to', id=result.webhook.id, url=result.webhook.url) else: - logger.warning( - 'Webhook %s delivery failed: %s', - result.webhook.id, - result.error_message, - ) + logger.warning('Webhook delivery failed', id=result.webhook.id, error_message=result.error_message) except Exception as error: - logger.exception( - 'Failed to record webhook delivery result for %s: %s', - result.webhook.id, - error, - ) + logger.exception('Failed to record webhook delivery result for', id=result.webhook.id, error=error) # Глобальный экземпляр сервиса diff --git a/app/services/wheel_service.py b/app/services/wheel_service.py index cd7de244..0236d06c 100644 --- a/app/services/wheel_service.py +++ b/app/services/wheel_service.py @@ -2,7 +2,6 @@ Сервис колеса удачи (Fortune Wheel) с RTP алгоритмом. """ -import logging import random import secrets from dataclasses import dataclass @@ -10,6 +9,7 @@ from datetime import datetime, timedelta from decimal import Decimal from typing import Any +import structlog from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -34,7 +34,7 @@ from app.database.models import ( from app.services.subscription_service import SubscriptionService -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @dataclass @@ -281,7 +281,12 @@ class FortuneWheelService: # Списываем с баланса user.balance_kopeks -= kopeks - logger.info(f'💫 Списано {kopeks / 100:.2f}₽ ({config.spin_cost_stars}⭐) с баланса user_id={user.id}') + logger.info( + '💫 Списано ₽ (⭐) с баланса user_id', + kopeks=round(kopeks / 100, 2), + spin_cost_stars=config.spin_cost_stars, + user_id=user.id, + ) return kopeks @@ -310,15 +315,15 @@ class FortuneWheelService: daily_price = price_30_days / 30 kopeks = int(daily_price * config.spin_cost_days) - logger.info(f'📅 Списано {config.spin_cost_days} дней подписки у user_id={user.id}') + logger.info('📅 Списано дней подписки у user_id', spin_cost_days=config.spin_cost_days, user_id=user.id) # Синхронизируем с RemnaWave try: subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - logger.info(f'✅ Списание дней синхронизировано с RemnaWave для user_id={user.id}') + logger.info('✅ Списание дней синхронизировано с RemnaWave для user_id', user_id=user.id) except Exception as e: - logger.error(f'⚠️ Ошибка синхронизации списания дней с RemnaWave: {e}') + logger.error('⚠️ Ошибка синхронизации списания дней с RemnaWave', error=e) return kopeks @@ -330,7 +335,7 @@ class FortuneWheelService: prize_type = prize.prize_type if prize_type == WheelPrizeType.NOTHING.value: - logger.info(f'🎰 Пустой приз для user_id={user.id}') + logger.info('🎰 Пустой приз для user_id', user_id=user.id) return None if prize_type == WheelPrizeType.BALANCE_BONUS.value: @@ -342,7 +347,9 @@ class FortuneWheelService: description=f'Выигрыш в колесе удачи: {prize.prize_value / 100:.2f}₽', create_transaction=True, ) - logger.info(f'💰 Начислено {prize.prize_value / 100:.2f}₽ на баланс user_id={user.id}') + logger.info( + '💰 Начислено ₽ на баланс user_id', prize_value=round(prize.prize_value / 100, 2), user_id=user.id + ) return None if prize_type == WheelPrizeType.SUBSCRIPTION_DAYS.value: @@ -370,7 +377,10 @@ class FortuneWheelService: create_transaction=True, ) logger.info( - f'💰 Суточный тариф: {prize.prize_value} дней конвертированы в {balance_bonus / 100:.2f}₽ для user_id={user.id}' + '💰 Суточный тариф: дней конвертированы в ₽ для user_id', + prize_value=prize.prize_value, + balance_bonus=round(balance_bonus / 100, 2), + user_id=user.id, ) else: # Если нет цены - используем prize_value_kopeks @@ -381,20 +391,20 @@ class FortuneWheelService: description=f'Выигрыш в колесе удачи: {prize.prize_value} дней (на баланс)', create_transaction=True, ) - logger.info(f'💰 Дни конвертированы в баланс для user_id={user.id}') + logger.info('💰 Дни конвертированы в баланс для user_id', user_id=user.id) else: # Обычная подписка - добавляем дни и синхронизируем с RemnaWave subscription.end_date += timedelta(days=prize.prize_value) subscription.updated_at = datetime.utcnow() - logger.info(f'📅 Начислено {prize.prize_value} дней подписки user_id={user.id}') + logger.info('📅 Начислено дней подписки user_id', prize_value=prize.prize_value, user_id=user.id) # Синхронизируем с RemnaWave try: subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - logger.info(f'✅ Синхронизировано с RemnaWave для user_id={user.id}') + logger.info('✅ Синхронизировано с RemnaWave для user_id', user_id=user.id) except Exception as e: - logger.error(f'⚠️ Ошибка синхронизации с RemnaWave: {e}') + logger.error('⚠️ Ошибка синхронизации с RemnaWave', error=e) else: # Если нет подписки - начисляем на баланс эквивалент await add_user_balance( @@ -404,7 +414,7 @@ class FortuneWheelService: description=f'Выигрыш в колесе удачи: {prize.prize_value} дней (на баланс)', create_transaction=True, ) - logger.info(f'💰 Дни конвертированы в баланс для user_id={user.id}') + logger.info('💰 Дни конвертированы в баланс для user_id', user_id=user.id) return None if prize_type == WheelPrizeType.TRAFFIC_GB.value: @@ -413,15 +423,15 @@ class FortuneWheelService: if subscription and subscription.traffic_limit_gb > 0: subscription.traffic_limit_gb += prize.prize_value subscription.updated_at = datetime.utcnow() - logger.info(f'📊 Начислено {prize.prize_value}GB трафика user_id={user.id}') + logger.info('📊 Начислено трафика user_id', prize_value=prize.prize_value, user_id=user.id) # Синхронизируем с RemnaWave try: subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - logger.info(f'✅ Трафик синхронизирован с RemnaWave для user_id={user.id}') + logger.info('✅ Трафик синхронизирован с RemnaWave для user_id', user_id=user.id) except Exception as e: - logger.error(f'⚠️ Ошибка синхронизации трафика с RemnaWave: {e}') + logger.error('⚠️ Ошибка синхронизации трафика с RemnaWave', error=e) else: # Если безлимит или нет подписки - на баланс await add_user_balance( @@ -436,7 +446,7 @@ class FortuneWheelService: if prize_type == WheelPrizeType.PROMOCODE.value: # Генерация промокода promocode = await self._generate_prize_promocode(db, user, prize, config) - logger.info(f'🎟️ Сгенерирован промокод {promocode.code} для user_id={user.id}') + logger.info('🎟️ Сгенерирован промокод для user_id', code=promocode.code, user_id=user.id) return promocode.code return None @@ -588,7 +598,7 @@ class FortuneWheelService: ) except Exception as e: await db.rollback() - logger.exception(f'Ошибка спина колеса для user_id={user.id}: {e}') + logger.exception('Ошибка спина колеса для user_id', user_id=user.id, error=e) return SpinResult( success=False, error='internal_error', diff --git a/app/services/yookassa_service.py b/app/services/yookassa_service.py index 11231cbe..671a5fa6 100644 --- a/app/services/yookassa_service.py +++ b/app/services/yookassa_service.py @@ -1,8 +1,8 @@ import asyncio -import logging import uuid from typing import Any +import structlog from yookassa import Configuration, Payment as YooKassaPayment from yookassa.domain.common.confirmation_type import ConfirmationType from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder @@ -10,7 +10,7 @@ from yookassa.domain.request.payment_request_builder import PaymentRequestBuilde from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class YooKassaService: @@ -35,34 +35,27 @@ class YooKassaService: try: Configuration.configure(shop_id, secret_key) self.configured = True - logger.info(f'YooKassa SDK сконфигурирован для shop_id: {shop_id[:5]}...') + logger.info('YooKassa SDK сконфигурирован для shop_id: ...', shop_id=shop_id[:5]) except Exception as error: - logger.error( - 'Ошибка конфигурации YooKassa SDK: %s', - error, - exc_info=True, - ) + logger.error('Ошибка конфигурации YooKassa SDK', error=error, exc_info=True) self.configured = False if not self.configured: self.return_url = 'https://t.me/' - logger.warning( - 'YooKassa не активна, используем заглушку return_url: %s', - self.return_url, - ) + logger.warning('YooKassa не активна, используем заглушку return_url', return_url=self.return_url) elif configured_return_url: self.return_url = configured_return_url elif bot_username_for_default_return: self.return_url = f'https://t.me/{bot_username_for_default_return}' - logger.info(f'YOOKASSA_RETURN_URL не установлен, используем бота: {self.return_url}') + logger.info('YOOKASSA_RETURN_URL не установлен, используем бота', return_url=self.return_url) else: self.return_url = 'https://t.me/' logger.warning( - f'КРИТИЧНО: YOOKASSA_RETURN_URL не установлен И username бота не предоставлен. ' - f'Используем заглушку: {self.return_url}. Платежи могут работать некорректно.' + 'КРИТИЧНО: YOOKASSA_RETURN_URL не установлен И username бота не предоставлен. Используем заглушку: . Платежи могут работать некорректно.', + return_url=self.return_url, ) - logger.info(f'YooKassa Service return_url: {self.return_url}') + logger.info('YooKassa Service return_url', return_url=self.return_url) async def create_payment( self, @@ -122,8 +115,12 @@ class YooKassaService: payment_request = builder.build() logger.info( - f'Создание платежа YooKassa (Idempotence-Key: {idempotence_key}). ' - f'Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}' + 'Создание платежа YooKassa (Idempotence-Key: ). Сумма: . Метаданные: . Чек', + idempotence_key=idempotence_key, + amount=amount, + currency=currency, + metadata=metadata, + receipt_data_dict=receipt_data_dict, ) loop = asyncio.get_running_loop() @@ -132,7 +129,10 @@ class YooKassaService: ) logger.info( - f'Ответ YooKassa Payment.create: ID={response.id}, Status={response.status}, Paid={response.paid}' + 'Ответ YooKassa Payment.create: ID=, Status=, Paid', + response_id=response.id, + status=response.status, + paid=response.paid, ) return { @@ -152,7 +152,7 @@ class YooKassaService: 'test_mode': response.test if hasattr(response, 'test') else None, } except Exception as e: - logger.error(f'Ошибка создания платежа YooKassa: {e}', exc_info=True) + logger.error('Ошибка создания платежа YooKassa', error=e, exc_info=True) return None async def create_sbp_payment( @@ -222,8 +222,12 @@ class YooKassaService: payment_request = builder.build() logger.info( - f"Создание платежа YooKassa СБП с подтверждением 'qr' (Idempotence-Key: {idempotence_key}). " - f'Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}' + "Создание платежа YooKassa СБП с подтверждением 'qr' (Idempotence-Key: ). Сумма: . Метаданные: . Чек", + idempotence_key=idempotence_key, + amount=amount, + currency=currency, + metadata=metadata, + receipt_data_dict=receipt_data_dict, ) loop = asyncio.get_running_loop() @@ -232,7 +236,10 @@ class YooKassaService: ) logger.info( - f'Ответ YooKassa Payment.create (СБП, qr): ID={response.id}, Status={response.status}, Paid={response.paid}' + 'Ответ YooKassa Payment.create (СБП, qr): ID=, Status=, Paid', + response_id=response.id, + status=response.status, + paid=response.paid, ) # Возвращаем данные платежа с QR-подтверждением @@ -259,7 +266,7 @@ class YooKassaService: 'test_mode': response.test if hasattr(response, 'test') else None, } except Exception as e: - logger.error(f'Ошибка создания платежа YooKassa СБП: {e}', exc_info=True) + logger.error('Ошибка создания платежа YooKassa СБП', error=e, exc_info=True) return None async def _create_sbp_payment_with_confirmation_type( @@ -310,8 +317,13 @@ class YooKassaService: payment_request = builder.build() logger.info( - f"Создание платежа YooKassa СБП с подтверждением '{confirmation_type}' (Idempotence-Key: {idempotence_key}). " - f'Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}' + 'Создание платежа YooKassa СБП с подтверждением (Idempotence-Key: ). Сумма: . Метаданные: . Чек', + confirmation_type=confirmation_type, + idempotence_key=idempotence_key, + amount=amount, + currency=currency, + metadata=metadata, + receipt_data_dict=receipt_data_dict, ) loop = asyncio.get_running_loop() @@ -320,7 +332,11 @@ class YooKassaService: ) logger.info( - f'Ответ YooKassa Payment.create (СБП, {confirmation_type}): ID={response.id}, Status={response.status}, Paid={response.paid}' + 'Ответ YooKassa Payment.create (СБП, ): ID=, Status=, Paid', + confirmation_type=confirmation_type, + response_id=response.id, + status=response.status, + paid=response.paid, ) result = { @@ -349,7 +365,10 @@ class YooKassaService: return result except Exception as e: logger.error( - f"Ошибка создания платежа YooKassa СБП с подтверждением '{confirmation_type}': {e}", exc_info=True + 'Ошибка создания платежа YooKassa СБП с подтверждением', + confirmation_type=confirmation_type, + error=e, + exc_info=True, ) return None @@ -359,15 +378,17 @@ class YooKassaService: return None try: - logger.info(f'Получение информации о платеже YooKassa ID: {payment_id_in_yookassa}') + logger.info('Получение информации о платеже YooKassa ID', payment_id_in_yookassa=payment_id_in_yookassa) loop = asyncio.get_running_loop() payment_info_yk = await loop.run_in_executor(None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa)) if payment_info_yk: logger.info( - f'Информация о платеже YooKassa {payment_id_in_yookassa}: ' - f'Status={payment_info_yk.status}, Paid={payment_info_yk.paid}' + 'Информация о платеже YooKassa Status=, Paid', + payment_id_in_yookassa=payment_id_in_yookassa, + status=payment_info_yk.status, + paid=payment_info_yk.paid, ) return { 'id': payment_info_yk.id, @@ -389,8 +410,13 @@ class YooKassaService: else None, 'test_mode': payment_info_yk.test if hasattr(payment_info_yk, 'test') else None, } - logger.warning(f'Платеж не найден в YooKassa ID: {payment_id_in_yookassa}') + logger.warning('Платеж не найден в YooKassa ID', payment_id_in_yookassa=payment_id_in_yookassa) return None except Exception as e: - logger.error(f'Ошибка получения информации о платеже YooKassa {payment_id_in_yookassa}: {e}', exc_info=True) + logger.error( + 'Ошибка получения информации о платеже YooKassa', + payment_id_in_yookassa=payment_id_in_yookassa, + error=e, + exc_info=True, + ) return None diff --git a/app/utils/button_styles_cache.py b/app/utils/button_styles_cache.py index 2b67da80..d419db9f 100644 --- a/app/utils/button_styles_cache.py +++ b/app/utils/button_styles_cache.py @@ -5,12 +5,13 @@ by keeping the cache and its helpers in a dedicated module. """ import json -import logging + +import structlog from app.database.database import AsyncSessionLocal -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # ---- Defaults per section ------------------------------------------------ @@ -116,5 +117,5 @@ async def load_button_styles_cache() -> dict[str, dict]: logger.exception('Failed to load button styles from DB, using defaults') _cached_styles = merged - logger.info('Button styles cache loaded: %s', list(merged.keys())) + logger.info('Button styles cache loaded', list=list(merged.keys())) return merged diff --git a/app/utils/cache.py b/app/utils/cache.py index 75a82e68..86e6ed6a 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -1,14 +1,14 @@ import json -import logging from datetime import timedelta from typing import Any import redis.asyncio as redis +import structlog from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class CacheService: @@ -23,7 +23,7 @@ class CacheService: self._connected = True logger.info('✅ Подключение к Redis кешу установлено') except Exception as e: - logger.warning(f'⚠️ Не удалось подключиться к Redis: {e}') + logger.warning('⚠️ Не удалось подключиться к Redis', error=e) self._connected = False async def disconnect(self): @@ -41,7 +41,7 @@ class CacheService: return json.loads(value) return None except Exception as e: - logger.error(f'Ошибка получения из кеша {key}: {e}') + logger.error('Ошибка получения из кеша', key=key, error=e) return None async def set(self, key: str, value: Any, expire: int | timedelta = None) -> bool: @@ -57,7 +57,7 @@ class CacheService: await self.redis_client.set(key, serialized_value, ex=expire) return True except Exception as e: - logger.error(f'Ошибка записи в кеш {key}: {e}') + logger.error('Ошибка записи в кеш', key=key, error=e) return False async def setnx(self, key: str, value: Any, expire: int | timedelta = None) -> bool: @@ -79,7 +79,7 @@ class CacheService: result = await self.redis_client.set(key, serialized_value, ex=expire, nx=True) return result is True except Exception as e: - logger.error(f'Ошибка setnx в кеш {key}: {e}') + logger.error('Ошибка setnx в кеш', key=key, error=e) return False async def delete(self, key: str) -> bool: @@ -90,7 +90,7 @@ class CacheService: deleted = await self.redis_client.delete(key) return deleted > 0 except Exception as e: - logger.error(f'Ошибка удаления из кеша {key}: {e}') + logger.error('Ошибка удаления из кеша', key=key, error=e) return False async def delete_pattern(self, pattern: str) -> int: @@ -105,7 +105,7 @@ class CacheService: deleted = await self.redis_client.delete(*keys) return int(deleted) except Exception as e: - logger.error(f'Ошибка удаления ключей по шаблону {pattern}: {e}') + logger.error('Ошибка удаления ключей по шаблону', pattern=pattern, error=e) return 0 async def exists(self, key: str) -> bool: @@ -115,7 +115,7 @@ class CacheService: try: return await self.redis_client.exists(key) except Exception as e: - logger.error(f'Ошибка проверки существования в кеше {key}: {e}') + logger.error('Ошибка проверки существования в кеше', key=key, error=e) return False async def expire(self, key: str, seconds: int) -> bool: @@ -125,7 +125,7 @@ class CacheService: try: return await self.redis_client.expire(key, seconds) except Exception as e: - logger.error(f'Ошибка установки TTL для {key}: {e}') + logger.error('Ошибка установки TTL для', key=key, error=e) return False async def get_keys(self, pattern: str = '*') -> list: @@ -136,7 +136,7 @@ class CacheService: keys = await self.redis_client.keys(pattern) return [key.decode() if isinstance(key, bytes) else key for key in keys] except Exception as e: - logger.error(f'Ошибка получения ключей по паттерну {pattern}: {e}') + logger.error('Ошибка получения ключей по паттерну', pattern=pattern, error=e) return [] async def flush_all(self) -> bool: @@ -148,7 +148,7 @@ class CacheService: logger.info('🗑️ Кеш полностью очищен') return True except Exception as e: - logger.error(f'Ошибка очистки кеша: {e}') + logger.error('Ошибка очистки кеша', error=e) return False async def increment(self, key: str, amount: int = 1) -> int | None: @@ -158,7 +158,7 @@ class CacheService: try: return await self.redis_client.incrby(key, amount) except Exception as e: - logger.error(f'Ошибка инкремента {key}: {e}') + logger.error('Ошибка инкремента', key=key, error=e) return None async def set_hash(self, name: str, mapping: dict, expire: int = None) -> bool: @@ -171,7 +171,7 @@ class CacheService: await self.redis_client.expire(name, expire) return True except Exception as e: - logger.error(f'Ошибка записи хеша {name}: {e}') + logger.error('Ошибка записи хеша', name=name, error=e) return False async def get_hash(self, name: str, key: str = None) -> dict | str | None: @@ -185,7 +185,7 @@ class CacheService: hash_data = await self.redis_client.hgetall(name) return {k.decode(): v.decode() for k, v in hash_data.items()} except Exception as e: - logger.error(f'Ошибка получения хеша {name}: {e}') + logger.error('Ошибка получения хеша', name=name, error=e) return None async def lpush(self, key: str, value: Any) -> bool: @@ -198,7 +198,7 @@ class CacheService: await self.redis_client.lpush(key, serialized) return True except Exception as e: - logger.error(f'Ошибка добавления в очередь {key}: {e}') + logger.error('Ошибка добавления в очередь', key=key, error=e) return False async def rpop(self, key: str) -> Any | None: @@ -212,7 +212,7 @@ class CacheService: return json.loads(value) return None except Exception as e: - logger.error(f'Ошибка извлечения из очереди {key}: {e}') + logger.error('Ошибка извлечения из очереди', key=key, error=e) return None async def llen(self, key: str) -> int: @@ -223,7 +223,7 @@ class CacheService: try: return await self.redis_client.llen(key) except Exception as e: - logger.error(f'Ошибка получения длины очереди {key}: {e}') + logger.error('Ошибка получения длины очереди', key=key, error=e) return 0 async def lrange(self, key: str, start: int = 0, end: int = -1) -> list: @@ -235,7 +235,7 @@ class CacheService: items = await self.redis_client.lrange(key, start, end) return [json.loads(item) for item in items] except Exception as e: - logger.error(f'Ошибка чтения очереди {key}: {e}') + logger.error('Ошибка чтения очереди', key=key, error=e) return [] diff --git a/app/utils/currency_converter.py b/app/utils/currency_converter.py index 2645dfb2..6ee10738 100644 --- a/app/utils/currency_converter.py +++ b/app/utils/currency_converter.py @@ -1,10 +1,10 @@ -import logging from datetime import datetime import aiohttp +import structlog -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class CurrencyConverter: @@ -33,7 +33,7 @@ class CurrencyConverter: if rate: self._cache[cache_key] = rate self._last_update[cache_key] = now - logger.info(f'Обновлен курс USD/RUB: {rate}') + logger.info('Обновлен курс USD/RUB', rate=rate) return rate # Возвращаем из кеша если API недоступен @@ -56,7 +56,7 @@ class CurrencyConverter: if rate and 50 < rate < 200: # Разумные границы курса return rate except Exception as e: - logger.debug(f'Ошибка получения курса из {source.__name__}: {e}') + logger.debug('Ошибка получения курса из', __name__=source.__name__, error=e) continue return None @@ -71,7 +71,7 @@ class CurrencyConverter: usd_rate = data['Valute']['USD']['Value'] return float(usd_rate) except Exception as e: - logger.debug(f'Ошибка получения курса ЦБ: {e}') + logger.debug('Ошибка получения курса ЦБ', error=e) return None async def _fetch_from_exchangerate_api(self) -> float | None: @@ -84,7 +84,7 @@ class CurrencyConverter: rub_rate = data['rates']['RUB'] return float(rub_rate) except Exception as e: - logger.debug(f'Ошибка получения курса exchangerate-api: {e}') + logger.debug('Ошибка получения курса exchangerate-api', error=e) return None async def _fetch_from_fixer(self) -> float | None: @@ -104,7 +104,7 @@ class CurrencyConverter: usd_rub = rub_eur / usd_eur return float(usd_rub) except Exception as e: - logger.debug(f'Ошибка получения курса fixer: {e}') + logger.debug('Ошибка получения курса fixer', error=e) return None async def usd_to_rub(self, usd_amount: float) -> float: diff --git a/app/utils/decorators.py b/app/utils/decorators.py index b84292a6..4e1e2980 100644 --- a/app/utils/decorators.py +++ b/app/utils/decorators.py @@ -1,8 +1,8 @@ import functools -import logging from collections.abc import Callable from typing import Any +import structlog from aiogram import types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext @@ -11,7 +11,7 @@ from app.config import settings from app.localization.texts import get_texts -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def admin_required(func: Callable) -> Callable: @@ -31,11 +31,13 @@ def admin_required(func: Callable) -> Callable: await event.answer(texts.ACCESS_DENIED, show_alert=True) except TelegramBadRequest as e: if 'query is too old' in str(e).lower(): - logger.warning(f'Попытка ответить на устаревший callback query от {user.id if user else "Unknown"}') + logger.warning( + 'Попытка ответить на устаревший callback query от', user_id=user.id if user else 'Unknown' + ) else: raise - logger.warning(f'Попытка доступа к админской функции от {user.id if user else "Unknown"}') + logger.warning('Попытка доступа к админской функции от', user_id=user.id if user else 'Unknown') return None return await func(event, *args, **kwargs) @@ -76,28 +78,35 @@ def error_handler(func: Callable) -> Callable: user_info = ( f'@{event.from_user.username}' if event.from_user.username else f'ID:{event.from_user.id}' ) - logger.warning(f"🕐 Игнорируем устаревший callback '{event.data}' от {user_info} в {func.__name__}") + logger.warning( + '🕐 Игнорируем устаревший callback от в', + event_data=event.data, + user_info=user_info, + __name__=func.__name__, + ) else: - logger.warning(f'🕐 Игнорируем устаревший запрос в {func.__name__}: {e}') + logger.warning('🕐 Игнорируем устаревший запрос в', __name__=func.__name__, error=e) return None if 'message is not modified' in error_message: - logger.debug(f'📝 Сообщение не изменено в {func.__name__}') + logger.debug('📝 Сообщение не изменено в', __name__=func.__name__) event = _extract_event(args) if event and isinstance(event, types.CallbackQuery): try: await event.answer() except TelegramBadRequest as answer_error: if 'query is too old' not in str(answer_error).lower(): - logger.error(f'Ошибка при ответе на callback в {func.__name__}: {answer_error}') + logger.error( + 'Ошибка при ответе на callback в', __name__=func.__name__, answer_error=answer_error + ) return None - logger.error(f'Telegram API error в {func.__name__}: {e}') + logger.error('Telegram API error в', __name__=func.__name__, error=e) # Уведомление отправляется в _send_error_message await _send_error_message(args, kwargs, e, func.__name__) except Exception as e: - logger.error(f'Ошибка в {func.__name__}: {e}', exc_info=True) + logger.error('Ошибка в', __name__=func.__name__, error=e, exc_info=True) await _send_error_message(args, kwargs, e, func.__name__) return wrapper @@ -130,9 +139,9 @@ async def _send_error_message(args, kwargs, original_error, func_name: str = 'un if 'query is too old' in str(e).lower(): logger.warning('Не удалось отправить сообщение об ошибке - callback query устарел') else: - logger.warning(f'Ошибка при отправке сообщения об ошибке: {e}') + logger.warning('Ошибка при отправке сообщения об ошибке', error=e) except Exception as e: - logger.warning(f'Критическая ошибка при отправке сообщения об ошибке: {e}') + logger.warning('Критическая ошибка при отправке сообщения об ошибке', error=e) def state_cleanup(func: Callable) -> Callable: @@ -157,7 +166,7 @@ def typing_action(func: Callable) -> Callable: try: await event.bot.send_chat_action(chat_id=event.chat.id, action='typing') except Exception as e: - logger.warning(f'Не удалось отправить typing action: {e}') + logger.warning('Не удалось отправить typing action', error=e) return await func(event, *args, **kwargs) diff --git a/app/utils/log_handlers.py b/app/utils/log_handlers.py index 4e989db7..60e03395 100644 --- a/app/utils/log_handlers.py +++ b/app/utils/log_handlers.py @@ -77,12 +77,17 @@ class PaymentLogFilter(logging.Filter): 'app.services.platega_service', 'app.services.pal24_service', 'app.services.wata_service', + 'app.services.kassa_ai_service', + 'app.services.freekassa_service', 'app.external.cryptobot', 'app.external.heleket', 'app.external.tribute', 'app.external.yookassa_webhook', 'app.external.wata_webhook', 'app.external.heleket_webhook', + 'app.external.pal24_client', + 'app.external.telegram_stars', + 'app.webserver.payments', ) def filter(self, record: logging.LogRecord) -> bool: diff --git a/app/utils/payment_logger.py b/app/utils/payment_logger.py index 4ab5f5eb..8e851013 100644 --- a/app/utils/payment_logger.py +++ b/app/utils/payment_logger.py @@ -1,22 +1,35 @@ -"""Специальный логгер для платежей. +"""Specialized logger for payment operations. -Выделенный логгер для всех платежных операций. -Записи идут в отдельный файл payments.log. +Provides a dedicated structlog logger for all payment-related events. +The underlying stdlib logger backend writes to a separate ``payments.log`` +file with ``propagate=False`` to avoid duplicating payment entries in the +general log. + +Usage:: -Использование: from app.utils.payment_logger import payment_logger - payment_logger.info("Создан YooKassa платеж %s на %s", payment_id, amount) - payment_logger.error("Ошибка обработки webhook: %s", error) + payment_logger.info('Created YooKassa payment', payment_id=payment_id, amount=amount) + payment_logger.error('Webhook processing error', error=error) """ from __future__ import annotations import logging +import structlog -# Выделенный логгер для всех платежных операций -payment_logger = logging.getLogger('app.payments') + +# The stdlib backend logger — keeps propagate=False so payment events +# only go to the dedicated file handler, not the root logger. +_stdlib_payment_logger = logging.getLogger('app.payments') + +# Structlog frontend wrapping the stdlib backend. +# All structlog processors (timestamper, notifier, etc.) apply normally. +payment_logger: structlog.stdlib.BoundLogger = structlog.wrap_logger( + _stdlib_payment_logger, + wrapper_class=structlog.stdlib.BoundLogger, +) def configure_payment_logger( @@ -24,31 +37,28 @@ def configure_payment_logger( formatter: logging.Formatter | None = None, level: int = logging.INFO, ) -> None: - """Настроить payment_logger с указанным хэндлером. + """Configure the payment logger with the given handler. Args: - handler: Хэндлер для записи логов (FileHandler, StreamHandler и т.д.) - formatter: Форматтер для логов (опционально) - level: Уровень логирования (по умолчанию INFO) + handler: Handler for writing logs (FileHandler, StreamHandler, etc.) + formatter: Formatter for log output (optional) + level: Logging level (default INFO) """ - payment_logger.setLevel(level) + _stdlib_payment_logger.setLevel(level) if formatter: handler.setFormatter(formatter) - payment_logger.addHandler(handler) + _stdlib_payment_logger.addHandler(handler) - # Предотвращаем дублирование в родительских логгерах - payment_logger.propagate = False + # Prevent duplication in parent loggers + _stdlib_payment_logger.propagate = False -def get_payment_logger() -> logging.Logger: - """Получить экземпляр payment_logger. +def get_payment_logger() -> structlog.stdlib.BoundLogger: + """Return the payment logger instance. - Альтернативный способ получения логгера для модулей, - которые предпочитают явный вызов функции. - - Returns: - Настроенный логгер платежей + Alternative way to obtain the logger for modules that prefer + an explicit function call. """ return payment_logger diff --git a/app/utils/photo_message.py b/app/utils/photo_message.py index 7ca4a1a6..ba6af988 100644 --- a/app/utils/photo_message.py +++ b/app/utils/photo_message.py @@ -1,6 +1,6 @@ import asyncio -import logging +import structlog from aiogram import types from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError from aiogram.types import InaccessibleMessage, InputMediaPhoto @@ -18,7 +18,7 @@ from .message_patch import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) MAX_RETRIES = 3 RETRY_DELAY = 0.5 @@ -103,7 +103,7 @@ async def edit_or_answer_photo( parse_mode=resolved_parse_mode, ) except Exception as e: - logger.warning('Не удалось отправить новое сообщение для InaccessibleMessage: %s', e) + logger.warning('Не удалось отправить новое сообщение для InaccessibleMessage', e=e) try: await callback.message.answer( caption, @@ -160,10 +160,15 @@ async def edit_or_answer_photo( return # Успешно — выходим except TelegramNetworkError as net_error: if attempt < MAX_RETRIES - 1: - logger.warning('Сетевая ошибка edit_media (попытка %d/%d): %s', attempt + 1, MAX_RETRIES, net_error) + logger.warning( + 'Сетевая ошибка edit_media (попытка /)', + attempt=attempt + 1, + MAX_RETRIES=MAX_RETRIES, + net_error=net_error, + ) await asyncio.sleep(RETRY_DELAY * (attempt + 1)) continue - logger.error('Сетевая ошибка edit_media после %d попыток: %s', MAX_RETRIES, net_error) + logger.error('Сетевая ошибка edit_media после попыток', MAX_RETRIES=MAX_RETRIES, net_error=net_error) # После всех попыток — фоллбек на текст try: await callback.message.delete() diff --git a/app/utils/price_display.py b/app/utils/price_display.py index ceb471af..a595c6e9 100644 --- a/app/utils/price_display.py +++ b/app/utils/price_display.py @@ -7,14 +7,15 @@ This module provides a centralized way to: - Ensure uniform discount display throughout the application """ -import logging from dataclasses import dataclass +import structlog + from app.config import settings from app.database.models import User -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @dataclass @@ -71,9 +72,12 @@ def calculate_user_price(user: User | None, base_price: int, period_days: int, c discount_percent = settings.get_base_promo_group_period_discount(period_days) logger.debug( - f'calculate_user_price: user={user.telegram_id if user else "None"}, ' - f'base_price={base_price}, period_days={period_days}, category={category}, ' - f'discount_percent={discount_percent}' + 'calculate_user_price: user=, base_price=, period_days=, category=, discount_percent', + telegram_id=user.telegram_id if user else 'None', + base_price=base_price, + period_days=period_days, + category=category, + discount_percent=discount_percent, ) if discount_percent <= 0: @@ -84,9 +88,13 @@ def calculate_user_price(user: User | None, base_price: int, period_days: int, c final_price = base_price - discount_value logger.debug( - f'Calculated price for user {user.telegram_id if user else "None"}: ' - f'{base_price} -> {final_price} (-{discount_percent}%) ' - f'[category={category}, period={period_days}]' + 'Calculated price for user -> (-%) [category=, period=]', + telegram_id=user.telegram_id if user else 'None', + base_price=base_price, + final_price=final_price, + discount_percent=discount_percent, + category=category, + period_days=period_days, ) return PriceInfo(base_price=base_price, final_price=final_price, discount_percent=discount_percent) @@ -139,7 +147,7 @@ def format_price_button( if emphasize: button_text = f'🔥 {button_text} 🔥' - logger.debug(f'Formatted button: {button_text}') + logger.debug('Formatted button', button_text=button_text) return button_text diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index 6ac48aea..eb0ffb76 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -1,8 +1,8 @@ -import logging from collections.abc import Sequence from datetime import datetime from typing import TYPE_CHECKING, Any, Optional +import structlog from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -11,7 +11,7 @@ from app.config import settings if TYPE_CHECKING: # pragma: no cover from app.database.models import PromoGroup, User -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def calculate_months_from_days(days: int) -> int: @@ -31,7 +31,12 @@ def calculate_period_multiplier(period_days: int) -> tuple[int, float]: exact_months = period_days / 30 months_count = max(1, round(exact_months)) - logger.debug(f'Период {period_days} дней = {exact_months:.2f} точных месяцев ≈ {months_count} месяцев для расчета') + logger.debug( + 'Период дней точных месяцев ≈ месяцев для расчета', + period_days=period_days, + exact_months=round(exact_months, 2), + months_count=months_count, + ) return months_count, exact_months @@ -43,7 +48,10 @@ def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_ total_price = monthly_price * months_to_charge logger.debug( - f'Расчет пропорциональной цены: {monthly_price / 100}₽/мес × {months_to_charge} мес = {total_price / 100}₽' + 'Расчет пропорциональной цены: ₽/мес × мес ₽', + monthly_price=monthly_price / 100, + months_to_charge=months_to_charge, + total_price=total_price / 100, ) return total_price, months_to_charge @@ -65,11 +73,11 @@ def apply_percentage_discount(amount: int, percent: int) -> tuple[int, int]: discount_value = amount - discounted_amount logger.debug( - 'Применена скидка %s%%: %s → %s (скидка %s)', - clamped_percent, - amount, - discounted_amount, - discount_value, + 'Применена скидка %: → (скидка)', + clamped_percent=clamped_percent, + amount=amount, + discounted_amount=discounted_amount, + discount_value=discount_value, ) return discounted_amount, discount_value @@ -194,10 +202,7 @@ async def compute_simple_subscription_price( for squad_uuid in resolved_uuids: server = await get_server_squad_by_uuid(db, squad_uuid) if not server: - logger.warning( - 'SIMPLE_SUBSCRIPTION_PRICE_SERVER_NOT_FOUND | squad=%s', - squad_uuid, - ) + logger.warning('SIMPLE_SUBSCRIPTION_PRICE_SERVER_NOT_FOUND | squad', squad_uuid=squad_uuid) server_breakdown.append( { 'uuid': squad_uuid, @@ -212,10 +217,10 @@ async def compute_simple_subscription_price( if not server.is_available or server.is_full: logger.warning( - 'SIMPLE_SUBSCRIPTION_PRICE_SERVER_UNAVAILABLE | squad=%s | available=%s | full=%s', - squad_uuid, - server.is_available, - server.is_full, + 'SIMPLE_SUBSCRIPTION_PRICE_SERVER_UNAVAILABLE | squad= | available= | full', + squad_uuid=squad_uuid, + is_available=server.is_available, + is_full=server.is_full, ) server_breakdown.append( { @@ -327,10 +332,15 @@ def validate_pricing_calculation(base_price: int, monthly_additions: int, months if not is_valid: logger.warning( - f'Несоответствие в расчете цены: ожидалось {expected_total / 100}₽, получено {total_calculated / 100}₽' + 'Несоответствие в расчете цены: ожидалось ₽, получено ₽', + expected_total=expected_total / 100, + total_calculated=total_calculated / 100, ) logger.warning( - f'Детали: базовая цена {base_price / 100}₽ + месячные дополнения {monthly_additions / 100}₽ × {months} мес' + 'Детали: базовая цена ₽ + месячные дополнения ₽ × мес', + base_price=base_price / 100, + monthly_additions=monthly_additions / 100, + months=months, ) return is_valid diff --git a/app/utils/promo_rate_limiter.py b/app/utils/promo_rate_limiter.py index ff83e1ac..2c636bc3 100644 --- a/app/utils/promo_rate_limiter.py +++ b/app/utils/promo_rate_limiter.py @@ -1,9 +1,10 @@ -import logging import re import time +import structlog -logger = logging.getLogger(__name__) + +logger = structlog.get_logger(__name__) # Только буквы, цифры, дефис, подчёркивание PROMO_CODE_PATTERN = re.compile(r'^[A-Za-z0-9_-]+$') @@ -37,10 +38,10 @@ class PromoRateLimiter: if len(attempts) >= MAX_FAILED_ATTEMPTS: logger.warning( - 'Promo brute-force: user %s — %d failed attempts in %ds', - user_id, - len(attempts), - FAILED_WINDOW_SECONDS, + 'Promo brute-force: user — failed attempts in s', + user_id=user_id, + attempts_count=len(attempts), + FAILED_WINDOW_SECONDS=FAILED_WINDOW_SECONDS, ) def is_blocked(self, user_id: int) -> bool: diff --git a/app/utils/startup_timeline.py b/app/utils/startup_timeline.py index 8cb9191e..feb548e5 100644 --- a/app/utils/startup_timeline.py +++ b/app/utils/startup_timeline.py @@ -57,7 +57,7 @@ class StageHandle: self._explicit_status = True def log(self, message: str, icon: str = '•') -> None: - self.timeline.logger.info(f'┃ {icon} {message}') + self.timeline.logger.info('┃', icon=icon, message=message) class StartupTimeline: @@ -113,8 +113,8 @@ class StartupTimeline: status_label: str, message: str, ) -> None: - self.logger.info(f'┏ {icon} {title}') - self.logger.info(f'┗ {icon} {title} — {status_label}: {message}') + self.logger.info('┏', icon=icon, title=title) + self.logger.info('┗ —', icon=icon, title=title, status_label=status_label, message=message) self._record_step(title, icon, status_label, message, 0.0) @asynccontextmanager @@ -126,9 +126,9 @@ class StartupTimeline: success_message: str | None = 'Готово', ): if description: - self.logger.info(f'┏ {icon} {title} — {description}') + self.logger.info('┏ —', icon=icon, title=title, description=description) else: - self.logger.info(f'┏ {icon} {title}') + self.logger.info('┏', icon=icon, title=title) handle = StageHandle(self, title, icon, success_message) start_time = time.perf_counter() @@ -137,13 +137,19 @@ class StartupTimeline: except Exception as exc: message = str(exc) handle.failure(message) - self.logger.exception(f'┣ ❌ {title} — ошибка: {message}') + self.logger.exception('┣ ❌ — ошибка', title=title, message=message) raise finally: duration = time.perf_counter() - start_time if not handle._explicit_status: handle.success(handle.message or 'Готово') - self.logger.info(f'┗ {handle.status_icon} {title} — {handle.message} [{duration:.2f}s]') + self.logger.info( + '┗ — [s]', + status_icon=handle.status_icon, + title=title, + message=handle.message, + duration=round(duration, 2), + ) self._record_step( title=title, icon=handle.status_icon, diff --git a/app/utils/subscription_utils.py b/app/utils/subscription_utils.py index b679c960..1dcbcd62 100644 --- a/app/utils/subscription_utils.py +++ b/app/utils/subscription_utils.py @@ -1,7 +1,7 @@ -import logging from datetime import datetime from urllib.parse import quote, urlparse, urlunparse +import structlog from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -9,7 +9,7 @@ from app.config import settings from app.database.models import Subscription -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) async def ensure_single_subscription(db: AsyncSession, user_id: int) -> Subscription | None: @@ -25,17 +25,24 @@ async def ensure_single_subscription(db: AsyncSession, user_id: int) -> Subscrip old_subscriptions = subscriptions[1:] logger.warning( - f'🚨 Обнаружено {len(subscriptions)} подписок у пользователя {user_id}. Удаляем {len(old_subscriptions)} старых.' + '🚨 Обнаружено подписок у пользователя . Удаляем старых.', + subscriptions_count=len(subscriptions), + user_id=user_id, + old_subscriptions_count=len(old_subscriptions), ) for old_sub in old_subscriptions: await db.delete(old_sub) - logger.info(f'🗑️ Удалена подписка ID {old_sub.id} от {old_sub.created_at}') + logger.info('🗑️ Удалена подписка ID от', old_sub_id=old_sub.id, created_at=old_sub.created_at) await db.commit() await db.refresh(latest_subscription) - logger.info(f'✅ Оставлена подписка ID {latest_subscription.id} от {latest_subscription.created_at}') + logger.info( + '✅ Оставлена подписка ID от', + latest_subscription_id=latest_subscription.id, + created_at=latest_subscription.created_at, + ) return latest_subscription @@ -51,7 +58,7 @@ async def update_or_create_subscription(db: AsyncSession, user_id: int, **subscr await db.commit() await db.refresh(existing_subscription) - logger.info(f'🔄 Обновлена существующая подписка ID {existing_subscription.id}') + logger.info('🔄 Обновлена существующая подписка ID', existing_subscription_id=existing_subscription.id) return existing_subscription subscription_defaults = dict(subscription_data) @@ -71,7 +78,7 @@ async def update_or_create_subscription(db: AsyncSession, user_id: int, **subscr await db.commit() await db.refresh(new_subscription) - logger.info(f'🆕 Создана новая подписка ID {new_subscription.id}') + logger.info('🆕 Создана новая подписка ID', new_subscription_id=new_subscription.id) return new_subscription @@ -92,10 +99,14 @@ async def cleanup_duplicate_subscriptions(db: AsyncSession) -> int: for old_subscription in subscriptions[1:]: await db.delete(old_subscription) total_deleted += 1 - logger.info(f'🗑️ Удалена дублирующаяся подписка ID {old_subscription.id} пользователя {user_id}') + logger.info( + '🗑️ Удалена дублирующаяся подписка ID пользователя', + old_subscription_id=old_subscription.id, + user_id=user_id, + ) await db.commit() - logger.info(f'🧹 Очищено {total_deleted} дублирующихся подписок') + logger.info('🧹 Очищено дублирующихся подписок', total_deleted=total_deleted) return total_deleted diff --git a/app/utils/timezone.py b/app/utils/timezone.py index c3e720a9..26d1ddef 100644 --- a/app/utils/timezone.py +++ b/app/utils/timezone.py @@ -2,15 +2,16 @@ from __future__ import annotations -import logging from datetime import UTC, datetime from functools import lru_cache from zoneinfo import ZoneInfo +import structlog + from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) @lru_cache(maxsize=1) @@ -26,11 +27,7 @@ def get_local_timezone() -> ZoneInfo: try: return ZoneInfo(tz_name) except Exception as exc: # pragma: no cover - defensive branch - logger.warning( - "⚠️ Не удалось загрузить временную зону '%s': %s. Используем UTC.", - tz_name, - exc, - ) + logger.warning("⚠️ Не удалось загрузить временную зону '': . Используем UTC.", tz_name=tz_name, exc=exc) return ZoneInfo('UTC') @@ -67,28 +64,3 @@ def format_local_datetime( if localized is None: return na_placeholder return localized.strftime(fmt) - - -class TimezoneAwareFormatter(logging.Formatter): - """Logging formatter that renders timestamps in the configured timezone.""" - - def __init__(self, *args, timezone_name: str | None = None, **kwargs): - super().__init__(*args, **kwargs) - if timezone_name: - try: - self._timezone = ZoneInfo(timezone_name) - except Exception as exc: # pragma: no cover - defensive branch - logger.warning( - "⚠️ Не удалось загрузить временную зону '%s': %s. Используем UTC.", - timezone_name, - exc, - ) - self._timezone = ZoneInfo('UTC') - else: - self._timezone = get_local_timezone() - - def formatTime(self, record, datefmt=None): - dt = datetime.fromtimestamp(record.created, tz=self._timezone) - if datefmt: - return dt.strftime(datefmt) - return dt.strftime('%Y-%m-%d %H:%M:%S,%f')[:-3] diff --git a/app/utils/user_utils.py b/app/utils/user_utils.py index 5913d046..def4ad2d 100644 --- a/app/utils/user_utils.py +++ b/app/utils/user_utils.py @@ -1,8 +1,8 @@ -import logging import secrets import string from datetime import datetime, timedelta +import structlog from sqlalchemy import and_, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -11,7 +11,7 @@ from app.config import settings from app.database.models import ReferralEarning, Transaction, TransactionType, User -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def format_referrer_info(user: User) -> str: @@ -71,9 +71,7 @@ def get_effective_referral_commission_percent(user: User) -> int: getattr(user, 'telegram_id', None) or getattr(user, 'email', None) or f'#{getattr(user, "id", "unknown")}' ) logger.error( - '❌ Некорректный процент комиссии для пользователя %s: %s', - user_id_display, - percent, + '❌ Некорректный процент комиссии для пользователя', user_id_display=user_id_display, percent=percent ) return max(0, min(100, settings.REFERRAL_COMMISSION_PERCENT)) @@ -83,7 +81,7 @@ def get_effective_referral_commission_percent(user: User) -> int: async def mark_user_as_had_paid_subscription(db: AsyncSession, user: User) -> bool: try: if user.has_had_paid_subscription: - logger.debug(f'Пользователь {user.id} уже отмечен как имевший платную подписку') + logger.debug('Пользователь уже отмечен как имевший платную подписку', user_id=user.id) return True await db.execute( @@ -91,15 +89,15 @@ async def mark_user_as_had_paid_subscription(db: AsyncSession, user: User) -> bo ) await db.commit() - logger.info(f'✅ Пользователь {user.id} отмечен как имевший платную подписку') + logger.info('✅ Пользователь отмечен как имевший платную подписку', user_id=user.id) return True except Exception as e: - logger.error(f'Ошибка отметки пользователя {user.id} как имевшего платную подписку: {e}') + logger.error('Ошибка отметки пользователя как имевшего платную подписку', user_id=user.id, error=e) try: await db.rollback() except Exception as rollback_error: - logger.error(f'Ошибка отката транзакции: {rollback_error}') + logger.error('Ошибка отката транзакции', rollback_error=rollback_error) return False @@ -178,7 +176,7 @@ async def get_user_referral_summary(db: AsyncSession, user_id: int) -> dict: } except Exception as e: - logger.error(f'Ошибка получения статистики рефералов для пользователя {user_id}: {e}') + logger.error('Ошибка получения статистики рефералов для пользователя', user_id=user_id, error=e) return { 'invited_count': 0, 'paid_referrals_count': 0, @@ -259,7 +257,7 @@ async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int } except Exception as e: - logger.error(f'Ошибка получения списка рефералов для пользователя {user_id}: {e}') + logger.error('Ошибка получения списка рефералов для пользователя', user_id=user_id, error=e) return { 'referrals': [], 'total_count': 0, @@ -317,5 +315,5 @@ async def get_referral_analytics(db: AsyncSession, user_id: int) -> dict: return {'earnings_by_period': earnings_by_period, 'top_referrals': top_referrals} except Exception as e: - logger.error(f'Ошибка получения аналитики рефералов для пользователя {user_id}: {e}') + logger.error('Ошибка получения аналитики рефералов для пользователя', user_id=user_id, error=e) return {'earnings_by_period': {'today': 0, 'week': 0, 'month': 0, 'quarter': 0}, 'top_referrals': []} diff --git a/app/webapi/middleware.py b/app/webapi/middleware.py index 769608c5..1ed2d26e 100644 --- a/app/webapi/middleware.py +++ b/app/webapi/middleware.py @@ -1,45 +1,40 @@ from __future__ import annotations -import logging from time import monotonic +import structlog from sqlalchemy.exc import InterfaceError, OperationalError from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint from starlette.requests import Request from starlette.responses import JSONResponse, Response +from structlog.contextvars import bound_contextvars -logger = logging.getLogger('web_api') +logger = structlog.get_logger('web_api') class RequestLoggingMiddleware(BaseHTTPMiddleware): """Логирование входящих запросов в административный API.""" async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: - start = monotonic() - response: Response | None = None - try: - response = await call_next(request) - return response - except (TimeoutError, ConnectionRefusedError, OSError, OperationalError, InterfaceError) as e: - logger.error( - 'Database connection error on %s %s: %s', - request.method, - request.url.path, - str(e)[:200], - ) - response = JSONResponse( - status_code=503, - content={'detail': 'Service temporarily unavailable. Please try again later.'}, - ) - return response - finally: - duration_ms = (monotonic() - start) * 1000 - status = response.status_code if response else 'error' - logger.debug( - '%s %s -> %s (%.2f ms)', - request.method, - request.url.path, - status, - duration_ms, - ) + with bound_contextvars(http_method=request.method, http_path=request.url.path): + start = monotonic() + response: Response | None = None + try: + response = await call_next(request) + return response + except (TimeoutError, ConnectionRefusedError, OSError, OperationalError, InterfaceError) as e: + logger.error( + 'Database connection error on', method=request.method, path=request.url.path, e=str(e)[:200] + ) + response = JSONResponse( + status_code=503, + content={'detail': 'Service temporarily unavailable. Please try again later.'}, + ) + return response + finally: + duration_ms = (monotonic() - start) * 1000 + status = response.status_code if response else 'error' + logger.debug( + '-> (ms)', method=request.method, path=request.url.path, status=status, duration_ms=duration_ms + ) diff --git a/app/webapi/routes/ban_notifications.py b/app/webapi/routes/ban_notifications.py index bb43b977..0c35872f 100644 --- a/app/webapi/routes/ban_notifications.py +++ b/app/webapi/routes/ban_notifications.py @@ -4,8 +4,7 @@ API эндпоинты для приема уведомлений от ban си from __future__ import annotations -import logging - +import structlog from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession @@ -17,7 +16,7 @@ from app.webapi.schemas.ban_notifications import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter() @@ -46,9 +45,11 @@ async def send_ban_notification( Требует API ключ в заголовке X-API-Key или Authorization: Bearer """ logger.info( - f"Получен запрос на отправку уведомления типа '{request.notification_type}' " - f'для пользователя {request.username} ({request.user_identifier}), ' - f'node_name={request.node_name!r}' + 'Получен запрос на отправку уведомления типа для пользователя node_name', + notification_type=request.notification_type, + username=request.username, + user_identifier=request.user_identifier, + node_name=repr(request.node_name), ) try: @@ -132,7 +133,6 @@ async def send_ban_notification( except HTTPException: raise except Exception as e: - logger.exception(f'Ошибка при отправке уведомления: {e}') - raise HTTPException( + logger.exception('Ошибка при отправке уведомления', error=e)( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Внутренняя ошибка сервера: {e!s}' ) diff --git a/app/webapi/routes/logs.py b/app/webapi/routes/logs.py index 92d363cb..fca7887e 100644 --- a/app/webapi/routes/logs.py +++ b/app/webapi/routes/logs.py @@ -2,11 +2,11 @@ from __future__ import annotations -import logging from datetime import UTC, datetime from pathlib import Path from typing import Any +import structlog from fastapi import APIRouter, Depends, HTTPException, Query, Security from fastapi.concurrency import run_in_threadpool from fastapi.responses import FileResponse @@ -31,7 +31,7 @@ from ..schemas.logs import ( router = APIRouter() -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT = 4000 @@ -90,7 +90,7 @@ async def get_system_log_preview( try: content, size_bytes, mtime = await _read_system_log(log_path) except FileNotFoundError: - logger.warning('Лог-файл %s исчез во время чтения', log_path) + logger.warning('Лог-файл исчез во время чтения', log_path=log_path) return SystemLogPreviewResponse( path=str(log_path), exists=False, @@ -103,7 +103,7 @@ async def get_system_log_preview( download_url='/logs/system/download', ) except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения - logger.error('Ошибка чтения лог-файла %s: %s', log_path, error) + logger.error('Ошибка чтения лог-файла', log_path=log_path, error=error) raise HTTPException(status_code=500, detail='Не удалось прочитать лог-файл') from error preview_text = content[-preview_limit:] if preview_limit > 0 else '' @@ -140,7 +140,7 @@ async def download_system_log( filename=log_path.name, ) except Exception as error: # pragma: no cover - защита от неожиданных ошибок отдачи файла - logger.error('Ошибка отправки лог-файла %s: %s', log_path, error) + logger.error('Ошибка отправки лог-файла', log_path=log_path, error=error) raise HTTPException(status_code=500, detail='Не удалось отправить лог-файл') from error @@ -158,7 +158,7 @@ async def get_system_log_full( try: content, size_bytes, mtime = await _read_system_log(log_path) except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения - logger.error('Ошибка чтения лог-файла %s: %s', log_path, error) + logger.error('Ошибка чтения лог-файла', log_path=log_path, error=error) raise HTTPException(status_code=500, detail='Не удалось прочитать лог-файл') from error return SystemLogFullResponse( diff --git a/app/webapi/routes/media.py b/app/webapi/routes/media.py index 56f675c8..9e30d600 100644 --- a/app/webapi/routes/media.py +++ b/app/webapi/routes/media.py @@ -1,9 +1,9 @@ from __future__ import annotations -import logging import mimetypes from typing import Any +import structlog from aiogram import Bot from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode @@ -27,7 +27,7 @@ from ..schemas.media import MediaUploadResponse router = APIRouter() -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) ALLOWED_MEDIA_TYPES = {'photo', 'video', 'document'} @@ -110,7 +110,7 @@ async def upload_media( except HTTPException: raise except Exception as error: - logger.error('Failed to upload media: %s', error) + logger.error('Failed to upload media', error=error) raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Failed to upload media') from error finally: await bot.session.close() @@ -151,7 +151,7 @@ async def download_media( except HTTPException: raise except Exception as error: # pragma: no cover - неожиданные ошибки загрузки файла - logger.error('Failed to download media %s: %s', file_id, error) + logger.error('Failed to download media', file_id=file_id, error=error) raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Failed to download media') from error finally: await bot.session.close() diff --git a/app/webapi/routes/menu_layout.py b/app/webapi/routes/menu_layout.py index a71c935a..c7e8fe08 100644 --- a/app/webapi/routes/menu_layout.py +++ b/app/webapi/routes/menu_layout.py @@ -2,9 +2,9 @@ from __future__ import annotations -import logging from typing import Any +import structlog from fastapi import APIRouter, Depends, HTTPException, Response, Security, status from sqlalchemy.ext.asyncio import AsyncSession @@ -15,7 +15,7 @@ from app.services.menu_layout_service import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) from ..dependencies import get_db_session, require_api_token from ..schemas.menu_layout import ( @@ -828,7 +828,7 @@ async def get_stats_by_button_type( total_clicks=total_clicks, ) except Exception as e: - logger.error(f'Error getting stats by type: {e}', exc_info=True) + logger.error('Error getting stats by type', error=e, exc_info=True) raise HTTPException(status_code=500, detail=f'Internal server error: {e!s}') @@ -889,7 +889,7 @@ async def get_top_users( limit=limit, ) except Exception as e: - logger.error(f'Error getting top users: {e}', exc_info=True) + logger.error('Error getting top users', error=e, exc_info=True) raise HTTPException(status_code=500, detail=f'Internal server error: {e!s}') @@ -906,7 +906,11 @@ async def get_period_comparison( comparison = await MenuLayoutService.get_period_comparison(db, button_id, current_days, previous_days) logger.debug( - f'Period comparison: button_id={button_id}, current_days={current_days}, previous_days={previous_days}, trend={comparison.get("change", {}).get("trend")}' + 'Period comparison: button_id=, current_days=, previous_days=, trend', + button_id=button_id, + current_days=current_days, + previous_days=previous_days, + get=comparison.get('change', {}).get('trend'), ) return PeriodComparisonResponse( @@ -916,7 +920,7 @@ async def get_period_comparison( button_id=button_id, ) except Exception as e: - logger.error(f'Error getting period comparison: {e}', exc_info=True) + logger.error('Error getting period comparison', error=e, exc_info=True) raise HTTPException(status_code=500, detail=f'Internal server error: {e!s}') @@ -931,7 +935,12 @@ async def get_user_click_sequences( try: sequences = await MenuLayoutService.get_user_click_sequences(db, user_id, limit) - logger.debug(f'User sequences: user_id={user_id}, limit={limit}, found={len(sequences)} sequences') + logger.debug( + 'User sequences: user_id=, limit=, found= sequences', + user_id=user_id, + limit=limit, + sequences_count=len(sequences), + ) return UserClickSequencesResponse( user_id=user_id, @@ -946,5 +955,5 @@ async def get_user_click_sequences( total=len(sequences), ) except Exception as e: - logger.error(f'Error getting user sequences: user_id={user_id}, error={e}', exc_info=True) + logger.error('Error getting user sequences: user_id=, error', user_id=user_id, error=e, exc_info=True) raise HTTPException(status_code=500, detail=f'Internal server error: {e!s}') diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 60691403..b4204fb6 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import logging import math import re from collections.abc import Collection @@ -11,6 +10,7 @@ from pathlib import Path from typing import Any from uuid import uuid4 +import structlog from aiogram import Bot from fastapi import APIRouter, Depends, HTTPException, status from pydantic import ValidationError @@ -198,7 +198,7 @@ from ..schemas.miniapp import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter() @@ -281,7 +281,7 @@ def _load_app_config_data() -> dict[str, Any] | None: with path.open('r', encoding='utf-8') as file: data = json.load(file) except (OSError, json.JSONDecodeError) as error: - logger.warning('Failed to load app-config from %s: %s', path, error) + logger.warning('Failed to load app-config from', path=path, error=error) continue if isinstance(data, dict): @@ -701,7 +701,7 @@ def _build_mulenpay_iframe_config() -> MiniAppPaymentIframeConfig | None: try: return MiniAppPaymentIframeConfig(expected_origin=expected_origin) except ValidationError as error: # pragma: no cover - defensive logging - logger.error("Invalid MulenPay expected origin '%s': %s", expected_origin, error) + logger.error("Invalid MulenPay expected origin ''", expected_origin=expected_origin, error=error) return None @@ -986,7 +986,7 @@ async def create_payment_link( try: stars_amount, amount_kopeks = _normalize_stars_amount(amount_kopeks) except ValueError as exc: - logger.error('Failed to normalize Stars amount: %s', exc) + logger.error('Failed to normalize Stars amount', exc=exc) raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to prepare Stars payment', @@ -2800,7 +2800,7 @@ async def _resolve_connected_servers( except RemnaWaveConfigurationError: logger.debug('RemnaWave is not configured; skipping server name enrichment') except Exception as error: # pragma: no cover - defensive logging - logger.warning('Failed to resolve server names from RemnaWave: %s', error) + logger.warning('Failed to resolve server names from RemnaWave', error=error) connected_servers: list[MiniAppConnectedServer] = [] for squad_uuid in squad_uuids: @@ -2818,7 +2818,7 @@ async def _load_devices_info(user: User) -> tuple[int, list[MiniAppDevice]]: try: service = RemnaWaveService() except Exception as error: # pragma: no cover - defensive logging - logger.warning('Failed to initialise RemnaWave service: %s', error) + logger.warning('Failed to initialise RemnaWave service', error=error) return 0, [] if not service.is_configured: @@ -2831,7 +2831,7 @@ async def _load_devices_info(user: User) -> tuple[int, list[MiniAppDevice]]: logger.debug('RemnaWave configuration missing while loading devices') return 0, [] except Exception as error: # pragma: no cover - defensive logging - logger.warning('Failed to load devices from RemnaWave: %s', error) + logger.warning('Failed to load devices from RemnaWave', error=error) return 0, [] total_devices = int(response.get('total') or 0) @@ -2910,7 +2910,7 @@ async def _load_subscription_links( service = SubscriptionService() info = await service.get_subscription_info(subscription.remnawave_short_uuid) except Exception as error: # pragma: no cover - defensive logging - logger.warning('Failed to load subscription info from RemnaWave: %s', error) + logger.warning('Failed to load subscription info from RemnaWave', error=error) return {} if not info: @@ -3118,7 +3118,7 @@ async def get_subscription_details( except HTTPException: raise except Exception as e: - logger.warning(f'Failed to check channel subscription for user {telegram_id}: {e}') + logger.warning('Failed to check channel subscription for user', telegram_id=telegram_id, error=e) # Don't block user if check fails user = await get_user_by_telegram_id(db, telegram_id) @@ -3146,27 +3146,19 @@ async def get_subscription_details( usage_synced = await service.sync_subscription_usage(db, subscription) except Exception as error: # pragma: no cover - defensive logging logger.warning( - 'Failed to sync subscription usage for user %s: %s', - getattr(user, 'id', 'unknown'), - error, + 'Failed to sync subscription usage for user', getattr=getattr(user, 'id', 'unknown'), error=error ) if usage_synced: try: await db.refresh(subscription, attribute_names=['traffic_used_gb', 'updated_at']) except Exception as refresh_error: # pragma: no cover - defensive logging - logger.debug( - 'Failed to refresh subscription after usage sync: %s', - refresh_error, - ) + logger.debug('Failed to refresh subscription after usage sync', refresh_error=refresh_error) try: await db.refresh(user) except Exception as refresh_error: # pragma: no cover - defensive logging - logger.debug( - 'Failed to refresh user after usage sync: %s', - refresh_error, - ) + logger.debug('Failed to refresh user after usage sync', refresh_error=refresh_error) user = await get_user_by_telegram_id(db, telegram_id) subscription = getattr(user, 'subscription', subscription) @@ -3876,9 +3868,9 @@ async def activate_subscription_trial_endpoint( tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None) if tariff_trial_days: trial_duration = tariff_trial_days - logger.info(f'Miniapp: используем триальный тариф {trial_tariff.name}') + logger.info('Miniapp: используем триальный тариф', trial_tariff_name=trial_tariff.name) except Exception as e: - logger.error(f'Ошибка получения триального тарифа: {e}') + logger.error('Ошибка получения триального тарифа', error=e) try: subscription = await create_trial_subscription( @@ -3891,11 +3883,7 @@ async def activate_subscription_trial_endpoint( tariff_id=tariff_id_for_trial, ) except Exception as error: # pragma: no cover - defensive logging - logger.error( - 'Failed to activate trial subscription for user %s: %s', - user.id, - error, - ) + logger.error('Failed to activate trial subscription for user', user_id=user.id, error=error) raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, detail={ @@ -3919,11 +3907,7 @@ async def activate_subscription_trial_endpoint( }, ) from error - logger.error( - 'Balance check failed after trial creation for user %s: %s', - user.id, - error, - ) + logger.error('Balance check failed after trial creation for user', user_id=user.id, error=error) raise HTTPException( status.HTTP_402_PAYMENT_REQUIRED, detail={ @@ -3947,9 +3931,9 @@ async def activate_subscription_trial_endpoint( ) from error logger.error( - 'Failed to charge balance for trial activation after subscription %s creation: %s', - subscription.id, - error, + 'Failed to charge balance for trial activation after subscription creation', + subscription_id=subscription.id, + error=error, ) raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -3966,7 +3950,7 @@ async def activate_subscription_trial_endpoint( try: await subscription_service.create_remnawave_user(db, subscription) except RemnaWaveConfigurationError as error: # pragma: no cover - configuration issues - logger.error('RemnaWave update skipped due to configuration error: %s', error) + logger.error('RemnaWave update skipped due to configuration error', error=error) revert_result = await revert_trial_activation( db, user, @@ -4000,9 +3984,7 @@ async def activate_subscription_trial_endpoint( ) from error except Exception as error: # pragma: no cover - defensive logging logger.error( - 'Failed to create RemnaWave user for trial subscription %s: %s', - subscription.id, - error, + 'Failed to create RemnaWave user for trial subscription', subscription_id=subscription.id, error=error ) revert_result = await revert_trial_activation( db, @@ -4407,12 +4389,7 @@ async def remove_connected_device( detail={'code': 'service_unavailable', 'message': str(error)}, ) from error except Exception as error: # pragma: no cover - defensive - logger.warning( - 'Failed to remove device %s for user %s: %s', - hwid, - telegram_id, - error, - ) + logger.warning('Failed to remove device for user', hwid=hwid, telegram_id=telegram_id, error=error) raise HTTPException( status.HTTP_502_BAD_GATEWAY, detail={'code': 'remnawave_error', 'message': 'Failed to remove device'}, @@ -4696,10 +4673,10 @@ async def _prepare_subscription_renewal_options( pricing = pricing_model.to_payload() except Exception as error: # pragma: no cover - defensive logging logger.warning( - 'Failed to calculate renewal pricing for subscription %s (period %s): %s', - subscription.id, - period_days, - error, + 'Failed to calculate renewal pricing for subscription (period)', + subscription_id=subscription.id, + period_days=period_days, + error=error, ) continue @@ -5371,10 +5348,10 @@ async def submit_subscription_renewal_endpoint( raise except Exception as error: logger.error( - 'Failed to calculate renewal pricing for subscription %s (period %s): %s', - subscription.id, - period_days, - error, + 'Failed to calculate renewal pricing for subscription (period)', + subscription_id=subscription.id, + period_days=period_days, + error=error, ) raise HTTPException( status.HTTP_502_BAD_GATEWAY, @@ -5430,7 +5407,7 @@ async def submit_subscription_renewal_endpoint( reset_reason='subscription renewal (miniapp)', ) except Exception as e: - logger.error(f'Ошибка синхронизации с RemnaWave при продлении (miniapp): {e}') + logger.error('Ошибка синхронизации с RemnaWave при продлении (miniapp)', error=e) lang = getattr(user, 'language', settings.DEFAULT_LANGUAGE) if lang == 'ru': @@ -5447,11 +5424,7 @@ async def submit_subscription_renewal_endpoint( ) except Exception as error: await db.rollback() - logger.error( - 'Failed to renew tariff subscription %s: %s', - subscription.id, - error, - ) + logger.error('Failed to renew tariff subscription', subscription_id=subscription.id, error=error) raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, detail={'code': 'renewal_failed', 'message': 'Failed to renew subscription'}, @@ -5468,9 +5441,7 @@ async def submit_subscription_renewal_endpoint( ) except SubscriptionRenewalChargeError as error: logger.error( - 'Failed to charge balance for subscription renewal %s: %s', - subscription.id, - error, + 'Failed to charge balance for subscription renewal', subscription_id=subscription.id, error=error ) raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -5941,7 +5912,7 @@ async def update_subscription_servers_endpoint( remove_ids=removed_server_ids or None, ) except Exception as e: - logger.error('Ошибка обновления счётчика серверов: %s', e) + logger.error('Ошибка обновления счётчика серверов', e=e) ordered_selection = [] seen_selection = set() @@ -6734,9 +6705,11 @@ async def purchase_tariff_endpoint( } await user_cart_service.save_user_cart(user.id, cart_data) user_id_display = user.telegram_id or user.email or f'#{user.id}' - logger.info(f'Корзина тарифа сохранена для автопродления (miniapp) пользователя {user_id_display}') + logger.info( + 'Корзина тарифа сохранена для автопродления (miniapp) пользователя', user_id_display=user_id_display + ) except Exception as e: - logger.error(f'Ошибка сохранения корзины тарифа (miniapp): {e}') + logger.error('Ошибка сохранения корзины тарифа (miniapp)', error=e) await db.refresh(user) @@ -7067,7 +7040,7 @@ async def switch_tariff_endpoint( subscription.last_daily_charge_at = datetime.utcnow() # Для суточного тарифа end_date = сейчас + 1 день subscription.end_date = datetime.utcnow() + timedelta(days=1) - logger.info(f'🔄 Смена на суточный тариф: установлены daily поля, end_date={subscription.end_date}') + logger.info('🔄 Смена на суточный тариф: установлены daily поля, end_date', end_date=subscription.end_date) elif old_is_daily and not new_is_daily: # Переход с суточного на обычный тариф - очищаем daily поля subscription.is_daily_paused = False @@ -7076,7 +7049,9 @@ async def switch_tariff_endpoint( if new_period_days > 0: subscription.end_date = datetime.utcnow() + timedelta(days=new_period_days) logger.info( - f'🔄 Смена с суточного на периодный тариф: end_date={subscription.end_date} ({new_period_days} дней)' + '🔄 Смена с суточного на периодный тариф: end_date= ( дней)', + end_date=subscription.end_date, + new_period_days=new_period_days, ) else: logger.info('🔄 Смена с суточного на обычный тариф: очищены daily поля') @@ -7090,7 +7065,7 @@ async def switch_tariff_endpoint( service = SubscriptionService() await service.update_remnawave_user(db, subscription) except Exception as e: - logger.error(f'Ошибка синхронизации с RemnaWave при смене тарифа: {e}') + logger.error('Ошибка синхронизации с RemnaWave при смене тарифа', error=e) lang = getattr(user, 'language', settings.DEFAULT_LANGUAGE) if upgrade_cost > 0: @@ -7269,7 +7244,7 @@ async def purchase_traffic_topup_endpoint( service = SubscriptionService() await service.update_remnawave_user(db, subscription) except Exception as e: - logger.error(f'Ошибка синхронизации с RemnaWave при докупке трафика: {e}') + logger.error('Ошибка синхронизации с RemnaWave при докупке трафика', error=e) # Создаем транзакцию await create_transaction( @@ -7352,7 +7327,7 @@ async def toggle_daily_subscription_pause_endpoint( # Обновляем время последнего списания для корректного расчёта следующего subscription.last_daily_charge_at = datetime.utcnow() subscription.end_date = datetime.utcnow() + timedelta(days=1) - logger.info(f'✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE') + logger.info('✅ Суточная подписка восстановлена из DISABLED в ACTIVE', subscription_id=subscription.id) await db.commit() await db.refresh(subscription) @@ -7368,7 +7343,7 @@ async def toggle_daily_subscription_pause_endpoint( if user.remnawave_uuid: await service.enable_remnawave_user(user.remnawave_uuid) except Exception as e: - logger.error(f'Ошибка синхронизации с RemnaWave при возобновлении: {e}') + logger.error('Ошибка синхронизации с RemnaWave при возобновлении', error=e) lang = getattr(user, 'language', settings.DEFAULT_LANGUAGE) if new_paused_state: diff --git a/app/webapi/routes/partners.py b/app/webapi/routes/partners.py index 6e95cd8d..6e5344fa 100644 --- a/app/webapi/routes/partners.py +++ b/app/webapi/routes/partners.py @@ -1,8 +1,8 @@ from __future__ import annotations -import logging from typing import Any +import structlog from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -50,7 +50,7 @@ from ..schemas.partners import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter() diff --git a/app/webapi/routes/subscriptions.py b/app/webapi/routes/subscriptions.py index d22fffa7..e6d60c2d 100644 --- a/app/webapi/routes/subscriptions.py +++ b/app/webapi/routes/subscriptions.py @@ -1,8 +1,8 @@ from __future__ import annotations -import logging from typing import Any +import structlog from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -36,7 +36,7 @@ from ..schemas.subscriptions import ( ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter() @@ -75,13 +75,13 @@ async def _choose_trial_squads( try: squad_uuid = await get_random_trial_squad_uuid(db) except Exception as error: - logger.error('Failed to select trial squad: %s', error) + logger.error('Failed to select trial squad', error=error) squad_uuid = None if not squad_uuid: return [] - logger.debug('Selected trial squad %s for subscription replacement', squad_uuid) + logger.debug('Selected trial squad for subscription replacement', squad_uuid=squad_uuid) return [squad_uuid] @@ -222,7 +222,7 @@ async def create_subscription( try: await db.rollback() except Exception: - logger.exception('Rollback failed after error: %s', e) + logger.exception('Rollback failed after error', e=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Failed to sync with Remnawave: {e!s}' ) diff --git a/app/webapi/routes/tickets.py b/app/webapi/routes/tickets.py index 7ef0c1ff..edc598c0 100644 --- a/app/webapi/routes/tickets.py +++ b/app/webapi/routes/tickets.py @@ -1,9 +1,9 @@ from __future__ import annotations -import logging from datetime import datetime from typing import Any +import structlog from aiogram import Bot from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode @@ -28,7 +28,7 @@ from ..schemas.tickets import ( router = APIRouter() -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _serialize_message(message: TicketMessage) -> TicketMessageResponse: @@ -255,7 +255,7 @@ async def reply_to_ticket( db=db, ) except Exception as error: - logger.warning('Failed to emit ticket.message_added event: %s', error) + logger.warning('Failed to emit ticket.message_added event', error=error) return TicketReplyResponse( ticket=_serialize_ticket(ticket_with_messages, include_messages=True), @@ -295,7 +295,9 @@ async def get_ticket_message_media( if file.file_path: media_url = str(request.url_for('download_media', file_id=message.media_file_id)) except Exception as error: - logger.warning('Failed to resolve media URL for ticket %s message %s: %s', ticket_id, message_id, error) + logger.warning( + 'Failed to resolve media URL for ticket message', ticket_id=ticket_id, message_id=message_id, error=error + ) finally: await bot.session.close() diff --git a/app/webapi/routes/websocket.py b/app/webapi/routes/websocket.py index d94f4247..47f3ef09 100644 --- a/app/webapi/routes/websocket.py +++ b/app/webapi/routes/websocket.py @@ -1,8 +1,8 @@ from __future__ import annotations import json -import logging +import structlog from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi.security import APIKeyHeader @@ -11,7 +11,7 @@ from app.services.event_emitter import event_emitter from app.services.web_api_token_service import web_api_token_service -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter() @@ -43,7 +43,7 @@ async def verify_websocket_token( logger.warning('WebSocket token authentication failed: token not found or invalid') return webhook_token is not None except Exception as error: - logger.warning('WebSocket authentication error: %s', error, exc_info=True) + logger.warning('WebSocket authentication error', error=error, exc_info=True) return False @@ -51,20 +51,20 @@ async def verify_websocket_token( async def websocket_endpoint(websocket: WebSocket): """WebSocket endpoint для real-time обновлений.""" client_host = websocket.client.host if websocket.client else 'unknown' - logger.debug('WebSocket connection attempt from %s', client_host) + logger.debug('WebSocket connection attempt from', client_host=client_host) # Сначала проверяем авторизацию ДО принятия соединения token = websocket.query_params.get('token') or websocket.query_params.get('api_key') if not token: - logger.debug('WebSocket: No token provided from %s', client_host) + logger.debug('WebSocket: No token provided from', client_host=client_host) # Принимаем и сразу закрываем с кодом ошибки await websocket.accept() await websocket.close(code=1008, reason='Unauthorized: No token provided') return if not await verify_websocket_token(websocket, token): - logger.debug('WebSocket: Invalid token from %s', client_host) + logger.debug('WebSocket: Invalid token from', client_host=client_host) # Принимаем и сразу закрываем с кодом ошибки await websocket.accept() await websocket.close(code=1008, reason='Unauthorized: Invalid token') @@ -73,9 +73,9 @@ async def websocket_endpoint(websocket: WebSocket): # Только после успешной проверки принимаем соединение try: await websocket.accept() - logger.debug('WebSocket connection accepted from %s', client_host) + logger.debug('WebSocket connection accepted from', client_host=client_host) except Exception as e: - logger.error('WebSocket: Failed to accept connection from %s: %s', client_host, e) + logger.error('WebSocket: Failed to accept connection from', client_host=client_host, e=e) return # Регистрируем подключение @@ -107,12 +107,12 @@ async def websocket_endpoint(websocket: WebSocket): except WebSocketDisconnect: break except Exception as error: - logger.exception('Error processing WebSocket message: %s', error) + logger.exception('Error processing WebSocket message', error=error) except WebSocketDisconnect: logger.debug('WebSocket client disconnected') except Exception as error: - logger.exception('WebSocket error: %s', error) + logger.exception('WebSocket error', error=error) finally: # Отменяем регистрацию при отключении event_emitter.unregister_websocket(websocket) diff --git a/app/webapi/server.py b/app/webapi/server.py index db3f5305..f10690e1 100644 --- a/app/webapi/server.py +++ b/app/webapi/server.py @@ -1,8 +1,8 @@ from __future__ import annotations import asyncio -import logging +import structlog import uvicorn from app.config import settings @@ -10,7 +10,7 @@ from app.config import settings from .app import create_web_api_app -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class WebAPIServer: @@ -76,13 +76,11 @@ class WebAPIServer: try: await self._server.serve() except Exception as error: # pragma: no cover - логируем ошибки сервера - logger.exception('❌ Ошибка работы веб-API: %s', error) + logger.exception('❌ Ошибка работы веб-API', error=error) raise logger.info( - '🌐 Запуск административного API на %s:%s', - settings.WEB_API_HOST, - settings.WEB_API_PORT, + '🌐 Запуск административного API на', WEB_API_HOST=settings.WEB_API_HOST, WEB_API_PORT=settings.WEB_API_PORT ) self._task = asyncio.create_task(_serve(), name='web-api-server') diff --git a/app/webserver/payments.py b/app/webserver/payments.py index d00fbc00..8f0a7ee1 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -4,9 +4,9 @@ import base64 import hashlib import hmac import json -import logging from collections.abc import Iterable +import structlog from aiogram import Bot from fastapi import APIRouter, Request, Response, status from fastapi.responses import JSONResponse @@ -23,7 +23,7 @@ from app.services.payment_service import PaymentService from app.services.tribute_service import TributeService -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _create_cors_response() -> Response: @@ -50,7 +50,7 @@ def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool: display_name = settings.get_mulenpay_display_name() if not secret_key: - logger.warning('%s secret key is not configured', display_name) + logger.warning('secret key is not configured', display_name=display_name) return False signature = _extract_header( @@ -95,7 +95,7 @@ def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool: if hmac.compare_digest(normalized_no_padding, expected_urlsafe.rstrip('=')): return True - logger.warning('Неверная подпись %s webhook', display_name) + logger.warning('Неверная подпись webhook', display_name=display_name) return False authorization_header = request.headers.get('Authorization') @@ -107,7 +107,7 @@ def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool: if scheme_lower in {'bearer', 'token'}: if hmac.compare_digest(token, secret_key): return True - logger.warning('Неверный %s токен %s webhook', scheme, display_name) + logger.warning('Неверный токен webhook', scheme=scheme, display_name=display_name) return False if not value and hmac.compare_digest(token, secret_key): @@ -124,7 +124,7 @@ def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool: if fallback_token and hmac.compare_digest(fallback_token, secret_key): return True - logger.warning('Отсутствует подпись %s webhook', display_name) + logger.warning('Отсутствует подпись webhook', display_name=display_name) return False @@ -169,7 +169,7 @@ async def _parse_pal24_payload(request: Request) -> dict[str, str]: if isinstance(data, dict): return {str(k): str(v) for k, v in data.items()} except json.JSONDecodeError: - logger.debug('Pal24 webhook body не удалось распарсить как JSON: %s', raw_body) + logger.debug('Pal24 webhook body не удалось распарсить как JSON', raw_body=raw_body) return {} @@ -223,13 +223,13 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute return JSONResponse({'status': 'ok', 'result': result}) error = ValueError('Tribute webhook processing returned empty result') - logger.error('Tribute webhook processing failed: %s', error) + logger.error('Tribute webhook processing failed', error=error) return JSONResponse( {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_400_BAD_REQUEST, ) except Exception as e: - logger.exception('Tribute webhook processing error: %s', e) + logger.exception('Tribute webhook processing error', e=e) return JSONResponse( {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_400_BAD_REQUEST, @@ -274,13 +274,13 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute if success: return JSONResponse({'status': 'ok'}) - logger.error('MulenPay webhook processing failed: %s', payload) + logger.error('MulenPay webhook processing failed', payload=payload) return JSONResponse( {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_400_BAD_REQUEST, ) except Exception as e: - logger.exception('MulenPay webhook processing error: %s', e) + logger.exception('MulenPay webhook processing error', e=e) return JSONResponse( {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_400_BAD_REQUEST, @@ -338,15 +338,15 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute return JSONResponse({'status': 'ok'}) logger.error( - 'CryptoBot webhook processing failed: invoice_id=%s', - payload.get('payload', {}).get('invoice_id'), + 'CryptoBot webhook processing failed: invoice_id', + payload=payload.get('payload', {}).get('invoice_id'), ) return JSONResponse( {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_400_BAD_REQUEST, ) except Exception as e: - logger.exception('CryptoBot webhook processing error: %s', e) + logger.exception('CryptoBot webhook processing error', e=e) return JSONResponse( {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_400_BAD_REQUEST, @@ -420,7 +420,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature') if signature: - logger.info('ℹ️ Получена подпись YooKassa: %s', signature) + logger.info('ℹ️ Получена подпись YooKassa', signature=signature) try: webhook_data = json.loads(body) @@ -454,13 +454,13 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute return JSONResponse({'status': 'ok'}) payment_id = webhook_data.get('object', {}).get('id', 'unknown') - logger.error('YooKassa webhook processing failed: payment_id=%s', payment_id) + logger.error('YooKassa webhook processing failed: payment_id', payment_id=payment_id) return JSONResponse( {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) except Exception as e: - logger.exception('YooKassa webhook processing error: %s', e) + logger.exception('YooKassa webhook processing error', e=e) return JSONResponse( {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -525,13 +525,13 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute return JSONResponse({'status': 'ok'}) order_id = payload.get('orderId') or payload.get('order_id') or 'unknown' - logger.error('Wata webhook processing failed: order_id=%s, payload=%s', order_id, payload) + logger.error('Wata webhook processing failed: order_id payload', order_id=order_id, payload=payload) return JSONResponse( {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, ) except Exception as e: - logger.exception('Wata webhook processing error: %s', e) + logger.exception('Wata webhook processing error', e=e) return JSONResponse( {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, @@ -589,13 +589,13 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute return JSONResponse({'status': 'ok'}) uuid_val = payload.get('uuid', 'unknown') - logger.error('Heleket webhook processing failed: uuid=%s', uuid_val) + logger.error('Heleket webhook processing failed: uuid', uuid_val=uuid_val) return JSONResponse( {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, ) except Exception as e: - logger.exception('Heleket webhook processing error: %s', e) + logger.exception('Heleket webhook processing error', e=e) return JSONResponse( {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, @@ -660,13 +660,13 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute return JSONResponse({'status': 'ok'}) bill_id = parsed_payload.get('bill_id', 'unknown') - logger.error('Pal24 webhook processing failed: bill_id=%s', bill_id) + logger.error('Pal24 webhook processing failed: bill_id', bill_id=bill_id) return JSONResponse( {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, ) except Exception as e: - logger.exception('Pal24 webhook processing error: %s', e) + logger.exception('Pal24 webhook processing error', e=e) return JSONResponse( {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, @@ -714,13 +714,13 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute return JSONResponse({'status': 'ok'}) transaction_id = payload.get('transactionId', 'unknown') - logger.error('Platega webhook processing failed: transactionId=%s', transaction_id) + logger.error('Platega webhook processing failed: transactionId', transaction_id=transaction_id) return JSONResponse( {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, ) except Exception as e: - logger.exception('Platega webhook processing error: %s', e) + logger.exception('Platega webhook processing error', e=e) return JSONResponse( {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, @@ -763,9 +763,9 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute # Логируем для диагностики logger.info( - 'CloudPayments check webhook received, body_len=%d, all_headers=%s', - len(raw_body), - dict(request.headers), + 'CloudPayments check webhook received, body_len all_headers', + raw_body_count=len(raw_body), + headers=dict(request.headers), ) # Проверяем подпись только если она пришла и API_SECRET настроен @@ -776,8 +776,8 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET ): logger.warning( - 'CloudPayments check webhook: invalid signature, sig=%s...', - signature[:20] if signature else 'empty', + 'CloudPayments check webhook: invalid signature, sig=...', + signature=signature[:20] if signature else 'empty', ) return JSONResponse({'code': 13}) # Отклонить elif settings.CLOUDPAYMENTS_API_SECRET and not signature: @@ -788,7 +788,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute logger.info('CloudPayments check webhook: allowing payment, returning code=0') return JSONResponse({'code': 0}) except Exception as e: - logger.exception('CloudPayments check webhook error: %s', e) + logger.exception('CloudPayments check webhook error', e=e) # В случае ошибки всё равно разрешаем платёж return JSONResponse({'code': 0}) @@ -812,7 +812,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute form_data = await request.form() webhook_data = cloudpayments_service.parse_webhook_data(dict(form_data)) except Exception as error: - logger.error('CloudPayments pay webhook parse error: %s', error) + logger.error('CloudPayments pay webhook parse error', error=error) return JSONResponse({'code': 0}) # Возвращаем 0, чтобы не было повторов # Обрабатываем платёж @@ -844,7 +844,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute form_data = await request.form() webhook_data = cloudpayments_service.parse_webhook_data(dict(form_data)) except Exception as error: - logger.error('CloudPayments fail webhook parse error: %s', error) + logger.error('CloudPayments fail webhook parse error', error=error) return JSONResponse({'code': 0}) # Обрабатываем неуспешный платёж @@ -865,9 +865,9 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute # Логируем для диагностики logger.info( - 'CloudPayments universal webhook received, body_len=%d, headers=%s', - len(raw_body), - dict(request.headers), + 'CloudPayments universal webhook received, body_len headers', + raw_body_count=len(raw_body), + headers=dict(request.headers), ) # Проверяем подпись только если она пришла и API_SECRET настроен @@ -883,9 +883,9 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute try: form_data = await request.form() webhook_data = cloudpayments_service.parse_webhook_data(dict(form_data)) - logger.info('CloudPayments webhook parsed data: %s', webhook_data) + logger.info('CloudPayments webhook parsed data', webhook_data=webhook_data) except Exception as error: - logger.error('CloudPayments webhook parse error: %s', error) + logger.error('CloudPayments webhook parse error', error=error) # Может быть это Check уведомление - просто разрешаем return JSONResponse({'code': 0}) @@ -908,10 +908,10 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute elif status_value in ('Completed', 'Authorized') and is_pay_notification: # Успешная оплата (Pay notification) - есть Reason или AuthCode logger.info( - 'CloudPayments Pay notification: invoice=%s, reason=%s, auth_code=%s', - webhook_data.get('invoice_id'), - reason, - auth_code, + 'CloudPayments Pay notification: invoice reason auth_code', + webhook_data=webhook_data.get('invoice_id'), + reason=reason, + auth_code=auth_code, ) await _process_payment_service_callback( payment_service, @@ -922,16 +922,15 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute # Check notification или другой тип - просто разрешаем (code=0) # Check приходит ДО оплаты для валидации, не зачисляем баланс logger.info( - 'CloudPayments Check/other notification: status=%s, reason=%s, ' - 'auth_code=%s - allowing (code=0), NOT crediting balance', - status_value, - reason, - auth_code, + 'CloudPayments Check/other notification: status reason auth_code= - allowing (code=0), NOT crediting balance', + status_value=status_value, + reason=reason, + auth_code=auth_code, ) return JSONResponse({'code': 0}) except Exception as e: - logger.exception('CloudPayments universal webhook error: %s', e) + logger.exception('CloudPayments universal webhook error', e=e) return JSONResponse({'code': 0}) routes_registered = True @@ -976,7 +975,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute try: form_data = await request.form() except Exception as form_error: - logger.error('Freekassa webhook: не удалось прочитать данные формы: %s', form_error) + logger.error('Freekassa webhook: не удалось прочитать данные формы', form_error=form_error) return Response('Error reading form data', status_code=status.HTTP_400_BAD_REQUEST) # Извлекаем параметры @@ -1021,10 +1020,10 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute if success: return Response('YES', status_code=status.HTTP_200_OK) - logger.error('Freekassa webhook processing failed: order_id=%s, intid=%s', order_id, intid) + logger.error('Freekassa webhook processing failed: order_id intid', order_id=order_id, intid=intid) return Response('Error', status_code=status.HTTP_400_BAD_REQUEST) except Exception as e: - logger.exception('Freekassa webhook processing error: %s', e) + logger.exception('Freekassa webhook processing error', e=e) return Response('Error', status_code=status.HTTP_400_BAD_REQUEST) finally: try: @@ -1053,7 +1052,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute try: form_data = await request.form() except Exception as form_error: - logger.error('KassaAI webhook: не удалось прочитать данные формы: %s', form_error) + logger.error('KassaAI webhook: не удалось прочитать данные формы', form_error=form_error) return Response('Error reading form data', status_code=status.HTTP_400_BAD_REQUEST) # Извлекаем параметры (те же что и у Freekassa) @@ -1073,7 +1072,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute amount_float = float(amount) cur_id_int = int(cur_id) if cur_id else None except (ValueError, TypeError) as e: - logger.error('KassaAI webhook: некорректные параметры - %s', e) + logger.error('KassaAI webhook: некорректные параметры', e=e) return Response('Invalid parameters', status_code=status.HTTP_400_BAD_REQUEST) # Обрабатываем webhook @@ -1096,10 +1095,10 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute if success: return Response('YES', status_code=status.HTTP_200_OK) - logger.error('KassaAI webhook processing failed: order_id=%s, intid=%s', order_id, intid) + logger.error('KassaAI webhook processing failed: order_id intid', order_id=order_id, intid=intid) return Response('Error', status_code=status.HTTP_400_BAD_REQUEST) except Exception as e: - logger.exception('KassaAI webhook processing error: %s', e) + logger.exception('KassaAI webhook processing error', e=e) return Response('Error', status_code=status.HTTP_400_BAD_REQUEST) finally: try: diff --git a/app/webserver/remnawave_webhook.py b/app/webserver/remnawave_webhook.py index ed51f539..ce7be40a 100644 --- a/app/webserver/remnawave_webhook.py +++ b/app/webserver/remnawave_webhook.py @@ -10,8 +10,8 @@ from __future__ import annotations import hashlib import hmac import json -import logging +import structlog from aiogram import Bot from fastapi import APIRouter, Request, status from fastapi.responses import JSONResponse @@ -21,7 +21,7 @@ from app.database.database import AsyncSessionLocal from app.services.remnawave_webhook_service import RemnaWaveWebhookService -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # Max accepted webhook payload size (64 KB) to prevent memory exhaustion DoS _MAX_BODY_SIZE = 64 * 1024 @@ -58,7 +58,7 @@ def create_remnawave_webhook_router(bot: Bot) -> APIRouter: ) if len(raw_body) > _MAX_BODY_SIZE: - logger.warning('RemnaWave webhook: payload too large (%d bytes)', len(raw_body)) + logger.warning('RemnaWave webhook: payload too large (bytes)', raw_body_count=len(raw_body)) return JSONResponse( {'status': 'error', 'reason': 'payload_too_large'}, status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, @@ -115,7 +115,7 @@ def create_remnawave_webhook_router(bot: Bot) -> APIRouter: # RemnaWave sends event as full qualified name (e.g. "user.modified"), # so we use event directly instead of concatenating scope + event. event_name = event - logger.info('RemnaWave webhook received: scope=%s, event=%s', scope, event_name) + logger.info('RemnaWave webhook received: scope event', scope=scope, event_name=event_name) # Process event — return 200 to prevent retries for application-level errors. # Only return non-200 for infrastructure failures (DB unavailable). @@ -125,7 +125,7 @@ def create_remnawave_webhook_router(bot: Bot) -> APIRouter: processed = await webhook_service.process_event(None, event_name, data) return JSONResponse({'status': 'ok', 'processed': processed}) except Exception: - logger.exception('RemnaWave webhook processing error for event %s', event_name) + logger.exception('RemnaWave webhook processing error for event', event_name=event_name) return JSONResponse({'status': 'ok', 'processed': False}) # User events require a DB session @@ -137,7 +137,7 @@ def create_remnawave_webhook_router(bot: Bot) -> APIRouter: return JSONResponse({'status': 'ok', 'processed': processed}) except Exception: await db.rollback() - logger.exception('RemnaWave webhook processing error for event %s', event_name) + logger.exception('RemnaWave webhook processing error for event', event_name=event_name) return JSONResponse({'status': 'ok', 'processed': False}) except Exception: logger.error('RemnaWave webhook: failed to get database session') diff --git a/app/webserver/telegram.py b/app/webserver/telegram.py index 6b0f4795..d1f8f39e 100644 --- a/app/webserver/telegram.py +++ b/app/webserver/telegram.py @@ -1,9 +1,9 @@ from __future__ import annotations import asyncio -import logging from typing import Any +import structlog from aiogram import Bot, Dispatcher from aiogram.types import Update from fastapi import APIRouter, HTTPException, Request, status @@ -12,7 +12,7 @@ from fastapi.responses import JSONResponse from app.config import settings -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) class TelegramWebhookProcessorError(RuntimeError): @@ -74,9 +74,9 @@ class TelegramWebhookProcessor: if self._worker_count: logger.info( - '🚀 Telegram webhook processor запущен: %s воркеров, очередь %s', - self._worker_count, - self._queue_maxsize, + '🚀 Telegram webhook processor запущен: воркеров, очередь', + worker_count=self._worker_count, + queue_maxsize=self._queue_maxsize, ) else: logger.warning('Telegram webhook processor запущен без воркеров — обновления не будут обрабатываться') @@ -93,8 +93,8 @@ class TelegramWebhookProcessor: await asyncio.wait_for(self._queue.join(), timeout=self._shutdown_timeout) except TimeoutError: logger.warning( - '⏱️ Не удалось дождаться завершения очереди Telegram webhook за %s секунд', - self._shutdown_timeout, + '⏱️ Не удалось дождаться завершения очереди Telegram webhook за секунд', + shutdown_timeout=self._shutdown_timeout, ) else: drained = 0 @@ -108,8 +108,7 @@ class TelegramWebhookProcessor: self._queue.task_done() if drained: logger.warning( - 'Очередь Telegram webhook остановлена без воркеров, потеряно %s обновлений', - drained, + 'Очередь Telegram webhook остановлена без воркеров, потеряно обновлений', drained=drained ) for _ in range(len(self._workers)): @@ -152,7 +151,7 @@ class TelegramWebhookProcessor: try: item = await self._queue.get() except asyncio.CancelledError: # pragma: no cover - остановка приложения - logger.debug('Worker %s cancelled', worker_id) + logger.debug('Worker cancelled', worker_id=worker_id) raise if item is self._stop_sentinel: @@ -163,14 +162,14 @@ class TelegramWebhookProcessor: try: await self._dispatcher.feed_update(self._bot, update) # type: ignore[arg-type] except asyncio.CancelledError: # pragma: no cover - остановка приложения - logger.debug('Worker %s cancelled during processing', worker_id) + logger.debug('Worker cancelled during processing', worker_id=worker_id) raise except Exception as error: # pragma: no cover - логируем сбой обработчика - logger.exception('Ошибка обработки Telegram update в worker %s: %s', worker_id, error) + logger.exception('Ошибка обработки Telegram update в worker', worker_id=worker_id, error=error) finally: self._queue.task_done() finally: - logger.debug('Worker %s завершён', worker_id) + logger.debug('Worker завершён', worker_id=worker_id) async def _dispatch_update( @@ -184,10 +183,10 @@ async def _dispatch_update( try: await processor.enqueue(update) except TelegramWebhookOverloadedError as error: - logger.warning('Очередь Telegram webhook переполнена: %s', error) + logger.warning('Очередь Telegram webhook переполнена', error=error) raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='webhook_queue_full') from error except TelegramWebhookProcessorNotRunningError as error: - logger.error('Telegram webhook processor неактивен: %s', error) + logger.error('Telegram webhook processor неактивен', error=error) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='webhook_processor_unavailable' ) from error @@ -221,13 +220,13 @@ def create_telegram_router( try: payload: Any = await request.json() except Exception as error: # pragma: no cover - defensive logging - logger.error('Ошибка чтения Telegram webhook: %s', error) + logger.error('Ошибка чтения Telegram webhook', error=error) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='invalid_payload') from error try: update = Update.model_validate(payload) except Exception as error: # pragma: no cover - defensive logging - logger.error('Ошибка валидации Telegram update: %s', error) + logger.error('Ошибка валидации Telegram update', error=error) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='invalid_update') from error await _dispatch_update(update, dispatcher=dispatcher, bot=bot, processor=processor) diff --git a/app/webserver/unified_app.py b/app/webserver/unified_app.py index c1cc49f0..39c9e648 100644 --- a/app/webserver/unified_app.py +++ b/app/webserver/unified_app.py @@ -1,8 +1,8 @@ from __future__ import annotations -import logging from pathlib import Path +import structlog from aiogram import Bot, Dispatcher from fastapi import FastAPI, status from fastapi.responses import JSONResponse, RedirectResponse @@ -18,7 +18,7 @@ from app.webapi.docs import add_redoc_endpoint from . import payments, telegram -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) def _attach_docs_alias(app: FastAPI, docs_url: str | None) -> None: @@ -82,14 +82,14 @@ def _create_base_app() -> FastAPI: def _mount_miniapp_static(app: FastAPI) -> tuple[bool, Path]: static_path: Path = settings.get_miniapp_static_path() if not static_path.exists(): - logger.debug('Miniapp static path %s does not exist, skipping mount', static_path) + logger.debug('Miniapp static path does not exist, skipping mount', static_path=static_path) return False, static_path try: app.mount('/miniapp/static', StaticFiles(directory=static_path), name='miniapp-static') - logger.info('📦 Miniapp static files mounted at /miniapp/static from %s', static_path) + logger.info('📦 Miniapp static files mounted at /miniapp/static from', static_path=static_path) except RuntimeError as error: # pragma: no cover - defensive guard - logger.warning('Не удалось смонтировать статические файлы миниаппа: %s', error) + logger.warning('Не удалось смонтировать статические файлы миниаппа', error=error) return False, static_path return True, static_path @@ -119,7 +119,7 @@ def create_unified_app( remnawave_router = create_remnawave_webhook_router(bot) app.include_router(remnawave_router) - logger.info('RemnaWave webhook router mounted at %s', settings.REMNAWAVE_WEBHOOK_PATH) + logger.info('RemnaWave webhook router mounted at', REMNAWAVE_WEBHOOK_PATH=settings.REMNAWAVE_WEBHOOK_PATH) payment_providers_state = { 'tribute': settings.TRIBUTE_ENABLED, diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 61bb6c5f..05c845b9 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -52,6 +52,7 @@ services: env_file: - .env environment: + FORCE_COLOR: '1' DOCKER_ENV: 'true' DATABASE_MODE: 'auto' POSTGRES_HOST: 'postgres' diff --git a/docker-compose.yml b/docker-compose.yml index 1d015da6..ac385cf7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,7 @@ services: env_file: - .env environment: + FORCE_COLOR: '1' DOCKER_ENV: 'true' DATABASE_MODE: 'auto' POSTGRES_HOST: 'postgres' diff --git a/main.py b/main.py index d1309771..0d9cfdd3 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,8 @@ import signal import sys from pathlib import Path +import structlog + sys.path.append(str(Path(__file__).parent)) @@ -14,7 +16,7 @@ from app.database.database import init_db from app.database.models import PaymentMethod from app.database.universal_migration import run_universal_migration from app.localization.loader import ensure_locale_templates -from app.logging_handler import TelegramErrorHandler +from app.logging_config import setup_logging from app.services.backup_service import backup_service from app.services.ban_notification_service import ban_notification_service from app.services.broadcast_service import broadcast_service @@ -42,7 +44,6 @@ from app.services.version_service import version_service from app.utils.log_handlers import ExcludePaymentFilter, LevelFilterHandler from app.utils.payment_logger import configure_payment_logger from app.utils.startup_timeline import StartupTimeline -from app.utils.timezone import TimezoneAwareFormatter from app.webapi.server import WebAPIServer from app.webserver.unified_app import create_unified_app @@ -52,15 +53,12 @@ class GracefulExit: self.exit = False def exit_gracefully(self, signum, frame): - logging.getLogger(__name__).info(f'Получен сигнал {signum}. Корректное завершение работы...') + structlog.get_logger(__name__).info('Получен сигнал, корректное завершение работы', signum=signum) self.exit = True async def main(): - formatter = TimezoneAwareFormatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s', - timezone_name=settings.TIMEZONE, - ) + file_formatter, console_formatter, telegram_notifier = setup_logging() log_handlers = [] @@ -74,7 +72,7 @@ async def main(): # 1. Общий лог (bot.log) - все уровни, без платежей bot_handler = logging.FileHandler(log_dir / 'bot.log', encoding='utf-8') - bot_handler.setFormatter(formatter) + bot_handler.setFormatter(file_formatter) bot_handler.addFilter(ExcludePaymentFilter()) log_handlers.append(bot_handler) @@ -84,7 +82,7 @@ async def main(): min_level=logging.INFO, max_level=logging.INFO, ) - info_handler.setFormatter(formatter) + info_handler.setFormatter(file_formatter) info_handler.addFilter(ExcludePaymentFilter()) log_handlers.append(info_handler) @@ -93,7 +91,7 @@ async def main(): str(log_dir / settings.LOG_WARNING_FILE), min_level=logging.WARNING, ) - warning_handler.setFormatter(formatter) + warning_handler.setFormatter(file_formatter) warning_handler.addFilter(ExcludePaymentFilter()) log_handlers.append(warning_handler) @@ -102,7 +100,7 @@ async def main(): str(log_dir / settings.LOG_ERROR_FILE), min_level=logging.ERROR, ) - error_handler.setFormatter(formatter) + error_handler.setFormatter(file_formatter) error_handler.addFilter(ExcludePaymentFilter()) log_handlers.append(error_handler) @@ -111,11 +109,11 @@ async def main(): log_dir / settings.LOG_PAYMENTS_FILE, encoding='utf-8', ) - configure_payment_logger(payment_handler, formatter) + configure_payment_logger(payment_handler, file_formatter) # 6. Консольный вывод stream_handler = logging.StreamHandler(sys.stdout) - stream_handler.setFormatter(formatter) + stream_handler.setFormatter(console_formatter) log_handlers.append(stream_handler) logging.basicConfig( @@ -129,11 +127,11 @@ async def main(): else: # Старое поведение: один файл лога file_handler = logging.FileHandler(settings.LOG_FILE, encoding='utf-8') - file_handler.setFormatter(formatter) + file_handler.setFormatter(file_formatter) log_handlers.append(file_handler) stream_handler = logging.StreamHandler(sys.stdout) - stream_handler.setFormatter(formatter) + stream_handler.setFormatter(console_formatter) log_handlers.append(stream_handler) logging.basicConfig( @@ -141,25 +139,10 @@ async def main(): handlers=log_handlers, ) - # === TelegramErrorHandler: отправка ERROR/CRITICAL в админский чат === - telegram_error_handler = TelegramErrorHandler(level=logging.ERROR) - telegram_error_handler.setFormatter(formatter) - logging.getLogger().addHandler(telegram_error_handler) + # NOTE: TelegramNotifierProcessor and noisy logger suppression are + # handled inside setup_logging() / logging_config.py. - # Установим более высокий уровень логирования для "мусорных" логов - logging.getLogger('aiohttp.access').setLevel(logging.ERROR) - logging.getLogger('aiohttp.client').setLevel(logging.WARNING) - logging.getLogger('aiohttp.internal').setLevel(logging.WARNING) - logging.getLogger('app.external.remnawave_api').setLevel(logging.WARNING) - logging.getLogger('aiogram').setLevel(logging.WARNING) - logging.getLogger('uvicorn.access').setLevel(logging.ERROR) - logging.getLogger('uvicorn.error').setLevel(logging.WARNING) - # Скрываем спам от WebSocket подключений (connection open/closed) - logging.getLogger('uvicorn.protocols.websockets.websockets_impl').setLevel(logging.WARNING) - logging.getLogger('websockets.server').setLevel(logging.WARNING) - logging.getLogger('websockets').setLevel(logging.WARNING) - - logger = logging.getLogger(__name__) + logger = structlog.get_logger(__name__) timeline = StartupTimeline(logger, 'Bedolaga Remnawave Bot') timeline.log_banner( [ @@ -173,7 +156,7 @@ async def main(): ensure_locale_templates() except Exception as error: stage.warning(f'Не удалось подготовить шаблоны локализаций: {error}') - logger.warning('Failed to prepare locale templates: %s', error) + logger.warning('Failed to prepare locale templates', error=error) killer = GracefulExit() signal.signal(signal.SIGINT, killer.exit_gracefully) @@ -206,7 +189,13 @@ async def main(): success_message='Миграция завершена успешно', ) as stage: try: - migration_success = await run_universal_migration() + migration_log = logging.getLogger('app.database.universal_migration') + original_level = migration_log.level + migration_log.setLevel(logging.WARNING) + try: + migration_success = await run_universal_migration() + finally: + migration_log.setLevel(original_level) if migration_success: stage.success('Миграция завершена успешно') else: @@ -214,7 +203,7 @@ async def main(): logger.warning('⚠️ Миграция завершилась с предупреждениями, но продолжаем запуск') except Exception as migration_error: stage.warning(f'Ошибка выполнения миграции: {migration_error}') - logger.error(f'❌ Ошибка выполнения миграции: {migration_error}') + logger.error('❌ Ошибка выполнения миграции', migration_error=migration_error) logger.warning('⚠️ Продолжаем запуск без миграции') else: timeline.add_manual_step( @@ -237,7 +226,7 @@ async def main(): await ensure_tariffs_synced(db) except Exception as error: stage.warning(f'Не удалось синхронизировать тарифы: {error}') - logger.error(f'❌ Не удалось синхронизировать тарифы: {error}') + logger.error('❌ Не удалось синхронизировать тарифы', error=error) async with timeline.stage( 'Синхронизация серверов из RemnaWave', @@ -252,7 +241,7 @@ async def main(): await ensure_servers_synced(db) except Exception as error: stage.warning(f'Не удалось синхронизировать серверы: {error}') - logger.error(f'❌ Не удалось синхронизировать серверы: {error}') + logger.error('❌ Не удалось синхронизировать серверы', error=error) async with timeline.stage( 'Инициализация платёжных методов', @@ -267,7 +256,7 @@ async def main(): await ensure_payment_method_configs(db) except Exception as error: stage.warning(f'Не удалось инициализировать платёжные методы: {error}') - logger.error(f'❌ Не удалось инициализировать платёжные методы: {error}') + logger.error('❌ Не удалось инициализировать платёжные методы', error=error) async with timeline.stage( 'Загрузка конфигурации из БД', @@ -278,7 +267,7 @@ async def main(): await bot_configuration_service.initialize() except Exception as error: stage.warning(f'Не удалось загрузить конфигурацию: {error}') - logger.error(f'❌ Не удалось загрузить конфигурацию: {error}') + logger.error('❌ Не удалось загрузить конфигурацию', error=error) bot = None dp = None @@ -292,7 +281,7 @@ async def main(): ban_notification_service.set_bot(bot) traffic_monitoring_scheduler.set_bot(bot) daily_subscription_service.set_bot(bot) - telegram_error_handler.set_bot(bot) + telegram_notifier.set_bot(bot) # Initialize email broadcast service from app.cabinet.services.email_service import email_service @@ -334,7 +323,7 @@ async def main(): stage.success('Сервис бекапов инициализирован') except Exception as e: stage.warning(f'Ошибка инициализации сервиса бекапов: {e}') - logger.error(f'❌ Ошибка инициализации сервиса бекапов: {e}') + logger.error('❌ Ошибка инициализации сервиса бекапов', error=e) async with timeline.stage( 'Сервис отчетов', @@ -346,7 +335,7 @@ async def main(): await reporting_service.start() except Exception as e: stage.warning(f'Ошибка запуска сервиса отчетов: {e}') - logger.error(f'❌ Ошибка запуска сервиса отчетов: {e}') + logger.error('❌ Ошибка запуска сервиса отчетов', error=e) async with timeline.stage( 'Реферальные конкурсы', @@ -361,7 +350,7 @@ async def main(): stage.skip('Сервис конкурсов выключен настройками') except Exception as e: stage.warning(f'Ошибка запуска сервиса конкурсов: {e}') - logger.error(f'❌ Ошибка запуска сервиса конкурсов: {e}') + logger.error('❌ Ошибка запуска сервиса конкурсов', error=e) async with timeline.stage( 'Ротация игр', @@ -377,7 +366,7 @@ async def main(): stage.skip('Ротация игр выключена настройками') except Exception as e: stage.warning(f'Ошибка запуска ротации игр: {e}') - logger.error(f'❌ Ошибка запуска ротации игр: {e}') + logger.error('❌ Ошибка запуска ротации игр', error=e) if settings.is_log_rotation_enabled(): async with timeline.stage( @@ -400,7 +389,7 @@ async def main(): stage.log(f'Следующая ротация: {next_dt.strftime("%d.%m.%Y %H:%M")}') except Exception as e: stage.warning(f'Ошибка запуска сервиса ротации логов: {e}') - logger.error(f'❌ Ошибка запуска сервиса ротации логов: {e}') + logger.error('❌ Ошибка запуска сервиса ротации логов', error=e) async with timeline.stage( 'Автосинхронизация RemnaWave', @@ -421,7 +410,7 @@ async def main(): stage.log('Автосинхронизация отключена настройками') except Exception as e: stage.warning(f'Ошибка запуска автосинхронизации: {e}') - logger.error(f'❌ Ошибка запуска автосинхронизации RemnaWave: {e}') + logger.error('❌ Ошибка запуска автосинхронизации RemnaWave', error=e) payment_service = PaymentService(bot) auto_payment_verification_service.set_payment_service(payment_service) @@ -493,7 +482,7 @@ async def main(): stage.skip('Сервис не запущен') except Exception as e: stage.warning(f'Ошибка запуска очереди чеков: {e}') - logger.error(f'❌ Ошибка запуска очереди чеков NaloGO: {e}') + logger.error('❌ Ошибка запуска очереди чеков NaloGO', error=e) else: stage.skip('NaloGO отключен настройками') @@ -514,7 +503,7 @@ async def main(): stage.warning('Не удалось получить токен внешней админки') except Exception as error: # pragma: no cover - защитный блок stage.warning(f'Ошибка подготовки внешней админки: {error}') - logger.error('❌ Ошибка подготовки внешней админки: %s', error) + logger.error('❌ Ошибка подготовки внешней админки', error=error) bot_run_mode = settings.get_bot_run_mode() polling_enabled = bot_run_mode == 'polling' @@ -728,7 +717,7 @@ async def main(): await send_bot_startup_notification(bot) except Exception as startup_notify_error: - logger.warning(f'Не удалось отправить стартовое уведомление: {startup_notify_error}') + logger.warning('Не удалось отправить стартовое уведомление', startup_notify_error=startup_notify_error) try: while not killer.exit: @@ -737,19 +726,19 @@ async def main(): if monitoring_task.done(): exception = monitoring_task.exception() if exception: - logger.error(f'Служба мониторинга завершилась с ошибкой: {exception}') + logger.error('Служба мониторинга завершилась с ошибкой', error=exception) monitoring_task = asyncio.create_task(monitoring_service.start_monitoring()) if maintenance_task and maintenance_task.done(): exception = maintenance_task.exception() if exception: - logger.error(f'Служба техработ завершилась с ошибкой: {exception}') + logger.error('Служба техработ завершилась с ошибкой', error=exception) maintenance_task = asyncio.create_task(maintenance_service.start_monitoring()) if version_check_task and version_check_task.done(): exception = version_check_task.exception() if exception: - logger.error(f'Сервис проверки версий завершился с ошибкой: {exception}') + logger.error('Сервис проверки версий завершился с ошибкой', error=exception) if settings.is_version_check_enabled(): logger.info('🔄 Перезапуск сервиса проверки версий...') version_check_task = asyncio.create_task(version_service.start_periodic_check()) @@ -757,7 +746,7 @@ async def main(): if traffic_monitoring_task and traffic_monitoring_task.done(): exception = traffic_monitoring_task.exception() if exception: - logger.error(f'Мониторинг трафика завершился с ошибкой: {exception}') + logger.error('Мониторинг трафика завершился с ошибкой', error=exception) if traffic_monitoring_scheduler.is_enabled(): logger.info('🔄 Перезапуск мониторинга трафика...') traffic_monitoring_task = asyncio.create_task( @@ -767,7 +756,7 @@ async def main(): if daily_subscription_task and daily_subscription_task.done(): exception = daily_subscription_task.exception() if exception: - logger.error(f'Сервис суточных подписок завершился с ошибкой: {exception}') + logger.error('Сервис суточных подписок завершился с ошибкой', error=exception) if daily_subscription_service.is_enabled(): logger.info('🔄 Перезапуск сервиса суточных подписок...') daily_subscription_task = asyncio.create_task(daily_subscription_service.start_monitoring()) @@ -780,14 +769,14 @@ async def main(): if polling_task and polling_task.done(): exception = polling_task.exception() if exception: - logger.error(f'Polling завершился с ошибкой: {exception}') + logger.error('Polling завершился с ошибкой', error=exception) break except Exception as e: - logger.error(f'Ошибка в основном цикле: {e}') + logger.error('Ошибка в основном цикле', error=e) except Exception as e: - logger.error(f'❌ Критическая ошибка при запуске: {e}') + logger.error('❌ Критическая ошибка при запуске', error=e) raise finally: @@ -800,7 +789,7 @@ async def main(): try: await auto_payment_verification_service.stop() except Exception as error: - logger.error(f'Ошибка остановки сервиса автопроверки пополнений: {error}') + logger.error('Ошибка остановки сервиса автопроверки пополнений', error=error) if monitoring_task and not monitoring_task.done(): logger.info('ℹ️ Остановка службы мониторинга...') @@ -850,44 +839,44 @@ async def main(): try: await reporting_service.stop() except Exception as e: - logger.error(f'Ошибка остановки сервиса отчетов: {e}') + logger.error('Ошибка остановки сервиса отчетов', error=e) logger.info('ℹ️ Остановка сервиса конкурсов...') try: await referral_contest_service.stop() except Exception as e: - logger.error(f'Ошибка остановки сервиса конкурсов: {e}') + logger.error('Ошибка остановки сервиса конкурсов', error=e) logger.info('ℹ️ Остановка сервиса автосинхронизации RemnaWave...') try: await remnawave_sync_service.stop() except Exception as e: - logger.error(f'Ошибка остановки автосинхронизации RemnaWave: {e}') + logger.error('Ошибка остановки автосинхронизации RemnaWave', error=e) logger.info('ℹ️ Остановка ротации игр...') try: await contest_rotation_service.stop() except Exception as e: - logger.error(f'Ошибка остановки ротации игр: {e}') + logger.error('Ошибка остановки ротации игр', error=e) if settings.is_log_rotation_enabled(): logger.info('ℹ️ Остановка сервиса ротации логов...') try: await log_rotation_service.stop() except Exception as e: - logger.error(f'Ошибка остановки сервиса ротации логов: {e}') + logger.error('Ошибка остановки сервиса ротации логов', error=e) logger.info('ℹ️ Остановка очереди чеков NaloGO...') try: await nalogo_queue_service.stop() except Exception as e: - logger.error(f'Ошибка остановки очереди чеков NaloGO: {e}') + logger.error('Ошибка остановки очереди чеков NaloGO', error=e) logger.info('ℹ️ Остановка сервиса бекапов...') try: await backup_service.stop_auto_backup() except Exception as e: - logger.error(f'Ошибка остановки сервиса бекапов: {e}') + logger.error('Ошибка остановки сервиса бекапов', error=e) if polling_task and not polling_task.done(): logger.info('ℹ️ Остановка polling...') @@ -903,21 +892,21 @@ async def main(): await bot.delete_webhook(drop_pending_updates=False) logger.info('✅ Telegram webhook удалён') except Exception as error: - logger.error(f'Ошибка удаления Telegram webhook: {error}') + logger.error('Ошибка удаления Telegram webhook', error=error) if web_api_server: try: await web_api_server.stop() logger.info('✅ Административное веб-API остановлено') except Exception as error: - logger.error(f'Ошибка остановки веб-API: {error}') + logger.error('Ошибка остановки веб-API', error=error) if 'bot' in locals(): try: await bot.session.close() logger.info('✅ Сессия бота закрыта') except Exception as e: - logger.error(f'Ошибка закрытия сессии бота: {e}') + logger.error('Ошибка закрытия сессии бота', error=e) logger.info('✅ Завершение работы бота завершено') diff --git a/pyproject.toml b/pyproject.toml index 9e7b817c..a54cdfdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,8 @@ dependencies = [ 'bcrypt>=5.0.0', 'pyjwt>=2.11.0', 'pyzipper>=0.3.6', + 'structlog>=25.1.0,<26', + 'rich>=14.0', ] [dependency-groups] diff --git a/requirements.txt b/requirements.txt index 7f1d7538..3065a0d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,8 @@ yookassa==3.10.0 httpx # зависимость для nalogo # Логирование и мониторинг -structlog==23.2.0 +structlog>=25.1.0,<26 +rich>=14.0 # Планировщик задач для техработ APScheduler==3.11.0 diff --git a/uv.lock b/uv.lock index 801d2ddd..eb0a1d01 100644 --- a/uv.lock +++ b/uv.lock @@ -1132,7 +1132,9 @@ dependencies = [ { name = "pyzipper" }, { name = "qrcode", extra = ["pil"] }, { name = "redis" }, + { name = "rich" }, { name = "sqlalchemy" }, + { name = "structlog" }, { name = "yookassa" }, ] @@ -1160,7 +1162,9 @@ requires-dist = [ { name = "pyzipper", specifier = ">=0.3.6" }, { name = "qrcode", extras = ["pil"], specifier = ">=8.0" }, { name = "redis", specifier = ">=7.1.1" }, + { name = "rich", specifier = ">=14.0" }, { name = "sqlalchemy", specifier = ">=2.0.46" }, + { name = "structlog", specifier = ">=25.1.0,<26" }, { name = "yookassa", specifier = ">=3.10.0" }, ] @@ -1330,6 +1334,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, ] +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + [[package]] name = "typer" version = "0.21.1"