1f0fef114b
- Add ContextVarsMiddleware for automatic user_id/chat_id/username binding via structlog contextvars (aiogram) and http_method/http_path (FastAPI) - Use bound_contextvars() context manager instead of clear_contextvars() to safely restore previous state instead of wiping all context - Register ContextVarsMiddleware as outermost middleware (before GlobalError) so all error logs include user context - Replace structlog.get_logger() with structlog.get_logger(__name__) across 270 calls in 265 files for meaningful logger names - Switch wrapper_class from BoundLogger to make_filtering_bound_logger() for pre-processor level filtering (performance optimization) - Migrate 1411 %-style positional arg logger calls to structlog kwargs style across 161 files via AST script - Migrate log_rotation_service.py from stdlib logging to structlog - Add payment module prefixes to TelegramNotifierProcessor.IGNORED_LOGGER_PREFIXES and ExcludePaymentFilter.PAYMENT_MODULES to prevent payment data leaking to Telegram notifications and general log files - Fix LoggingMiddleware: add from_user null-safety for channel posts, switch time.time() to time.monotonic() for duration measurement - Remove duplicate logger assignments in purchase.py, config.py, inline.py, and admin/payments.py
590 lines
19 KiB
Python
590 lines
19 KiB
Python
"""WebSocket endpoint for cabinet real-time notifications."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
import structlog
|
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
|
|
from app.cabinet.auth.jwt_handler import get_token_payload
|
|
from app.config import settings
|
|
from app.database.crud.user import get_user_by_id
|
|
from app.database.database import AsyncSessionLocal
|
|
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class CabinetConnectionManager:
|
|
"""Менеджер WebSocket подключений для кабинета."""
|
|
|
|
def __init__(self):
|
|
# user_id -> set of websocket connections
|
|
self._user_connections: dict[int, set[WebSocket]] = {}
|
|
# admin user_ids -> set of websocket connections
|
|
self._admin_connections: dict[int, set[WebSocket]] = {}
|
|
self._lock = asyncio.Lock()
|
|
|
|
async def connect(self, websocket: WebSocket, user_id: int, is_admin: bool) -> None:
|
|
"""Зарегистрировать подключение."""
|
|
async with self._lock:
|
|
if user_id not in self._user_connections:
|
|
self._user_connections[user_id] = set()
|
|
self._user_connections[user_id].add(websocket)
|
|
|
|
if is_admin:
|
|
if user_id not in self._admin_connections:
|
|
self._admin_connections[user_id] = set()
|
|
self._admin_connections[user_id].add(websocket)
|
|
|
|
logger.debug(
|
|
'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:
|
|
"""Отменить регистрацию подключения."""
|
|
async with self._lock:
|
|
if user_id in self._user_connections:
|
|
self._user_connections[user_id].discard(websocket)
|
|
if not self._user_connections[user_id]:
|
|
del self._user_connections[user_id]
|
|
|
|
if user_id in self._admin_connections:
|
|
self._admin_connections[user_id].discard(websocket)
|
|
if not self._admin_connections[user_id]:
|
|
del self._admin_connections[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:
|
|
"""Отправить сообщение конкретному пользователю."""
|
|
# Snapshot connections under the lock to avoid mutation during iteration
|
|
async with self._lock:
|
|
connections = list(self._user_connections.get(user_id, set()))
|
|
|
|
if not connections:
|
|
return
|
|
|
|
disconnected = set()
|
|
data = json.dumps(message, default=str, ensure_ascii=False)
|
|
|
|
for ws in connections:
|
|
try:
|
|
await ws.send_text(data)
|
|
except Exception as e:
|
|
logger.warning('Failed to send to user', user_id=user_id, e=e)
|
|
disconnected.add(ws)
|
|
|
|
# Cleanup disconnected
|
|
if disconnected:
|
|
async with self._lock:
|
|
for ws in disconnected:
|
|
self._user_connections.get(user_id, set()).discard(ws)
|
|
|
|
async def send_to_admins(self, message: dict) -> None:
|
|
"""Отправить сообщение всем админам."""
|
|
# Snapshot connections under the lock to avoid mutation during iteration
|
|
async with self._lock:
|
|
if not self._admin_connections:
|
|
return
|
|
# Create a snapshot: list of (user_id, list of websockets)
|
|
admin_snapshot = [(user_id, list(connections)) for user_id, connections in self._admin_connections.items()]
|
|
|
|
data = json.dumps(message, default=str, ensure_ascii=False)
|
|
disconnected_by_user: dict[int, set[WebSocket]] = {}
|
|
|
|
for user_id, connections in admin_snapshot:
|
|
for ws in connections:
|
|
try:
|
|
await ws.send_text(data)
|
|
except Exception as 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)
|
|
|
|
# Cleanup disconnected
|
|
if disconnected_by_user:
|
|
async with self._lock:
|
|
for user_id, ws_set in disconnected_by_user.items():
|
|
for ws in ws_set:
|
|
self._admin_connections.get(user_id, set()).discard(ws)
|
|
|
|
|
|
# Глобальный менеджер подключений
|
|
cabinet_ws_manager = CabinetConnectionManager()
|
|
|
|
|
|
async def verify_cabinet_ws_token(token: str) -> tuple[int | None, bool]:
|
|
"""
|
|
Проверить JWT токен для WebSocket.
|
|
|
|
Returns:
|
|
tuple[user_id, is_admin] или (None, False) если токен невалидный
|
|
"""
|
|
if not token:
|
|
return None, False
|
|
|
|
payload = get_token_payload(token, expected_type='access')
|
|
if not payload:
|
|
return None, False
|
|
|
|
try:
|
|
user_id = int(payload.get('sub'))
|
|
except (TypeError, ValueError):
|
|
return None, False
|
|
|
|
try:
|
|
async with AsyncSessionLocal() as db:
|
|
user = await get_user_by_id(db, user_id)
|
|
if not user or user.status != 'active':
|
|
return None, False
|
|
|
|
is_admin = settings.is_admin(
|
|
telegram_id=user.telegram_id, email=user.email if user.email_verified else None
|
|
)
|
|
return user_id, is_admin
|
|
except (TimeoutError, OSError, ConnectionRefusedError) as e:
|
|
logger.error('Database connection error in WS token verification', e=str(e)[:200])
|
|
return None, False
|
|
|
|
|
|
@router.websocket('/ws')
|
|
async def cabinet_websocket_endpoint(websocket: WebSocket):
|
|
"""WebSocket endpoint для real-time уведомлений кабинета."""
|
|
client_host = websocket.client.host if websocket.client else 'unknown'
|
|
|
|
# Получаем токен из query params
|
|
token = websocket.query_params.get('token')
|
|
|
|
if not token:
|
|
logger.debug('Cabinet WS: No token from', client_host=client_host)
|
|
# Принимаем и сразу закрываем с кодом ошибки
|
|
await websocket.accept()
|
|
await websocket.close(code=1008, reason='Unauthorized: No token')
|
|
return
|
|
|
|
# Верифицируем токен
|
|
user_id, is_admin = await verify_cabinet_ws_token(token)
|
|
|
|
if not user_id:
|
|
logger.debug('Cabinet WS: Invalid token from', client_host=client_host)
|
|
# Принимаем и сразу закрываем с кодом ошибки
|
|
await websocket.accept()
|
|
await websocket.close(code=1008, reason='Unauthorized: Invalid token')
|
|
return
|
|
|
|
# Принимаем соединение
|
|
try:
|
|
await websocket.accept()
|
|
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', client_host=client_host, e=e)
|
|
return
|
|
|
|
# Регистрируем подключение
|
|
await cabinet_ws_manager.connect(websocket, user_id, is_admin)
|
|
|
|
try:
|
|
# Приветственное сообщение
|
|
await websocket.send_json(
|
|
{
|
|
'type': 'connected',
|
|
'user_id': user_id,
|
|
'is_admin': is_admin,
|
|
}
|
|
)
|
|
|
|
# Обрабатываем входящие сообщения
|
|
while True:
|
|
try:
|
|
data = await websocket.receive_text()
|
|
message = json.loads(data)
|
|
|
|
# Ping/pong для keepalive
|
|
if message.get('type') == 'ping':
|
|
await websocket.send_json({'type': 'pong'})
|
|
|
|
except json.JSONDecodeError:
|
|
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', user_id=user_id, e=e)
|
|
break
|
|
|
|
except WebSocketDisconnect:
|
|
logger.debug('Cabinet WS disconnected: user_id', user_id=user_id)
|
|
except Exception as e:
|
|
logger.exception('Cabinet WS error', e=e)
|
|
finally:
|
|
await cabinet_ws_manager.disconnect(websocket, user_id)
|
|
|
|
|
|
# Функции для отправки уведомлений (используются из других модулей)
|
|
async def notify_user_ticket_reply(user_id: int, ticket_id: int, message: str) -> None:
|
|
"""Уведомить пользователя об ответе в тикете."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'ticket.admin_reply',
|
|
'ticket_id': ticket_id,
|
|
'message': message,
|
|
},
|
|
)
|
|
|
|
|
|
async def notify_admins_new_ticket(ticket_id: int, title: str, user_id: int) -> None:
|
|
"""Уведомить админов о новом тикете."""
|
|
await cabinet_ws_manager.send_to_admins(
|
|
{
|
|
'type': 'ticket.new',
|
|
'ticket_id': ticket_id,
|
|
'title': title,
|
|
'user_id': user_id,
|
|
}
|
|
)
|
|
|
|
|
|
async def notify_admins_ticket_reply(ticket_id: int, message: str, user_id: int) -> None:
|
|
"""Уведомить админов об ответе пользователя."""
|
|
await cabinet_ws_manager.send_to_admins(
|
|
{
|
|
'type': 'ticket.user_reply',
|
|
'ticket_id': ticket_id,
|
|
'message': message,
|
|
'user_id': user_id,
|
|
}
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Уведомления о балансе
|
|
# ============================================================================
|
|
|
|
|
|
async def notify_user_balance_topup(
|
|
user_id: int,
|
|
amount_kopeks: int,
|
|
new_balance_kopeks: int,
|
|
description: str = '',
|
|
) -> None:
|
|
"""Уведомить пользователя о пополнении баланса."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'balance.topup',
|
|
'amount_kopeks': amount_kopeks,
|
|
'amount_rubles': amount_kopeks / 100,
|
|
'new_balance_kopeks': new_balance_kopeks,
|
|
'new_balance_rubles': new_balance_kopeks / 100,
|
|
'description': description,
|
|
},
|
|
)
|
|
|
|
|
|
async def notify_user_balance_change(
|
|
user_id: int,
|
|
amount_kopeks: int,
|
|
new_balance_kopeks: int,
|
|
description: str = '',
|
|
) -> None:
|
|
"""Уведомить пользователя об изменении баланса."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'balance.change',
|
|
'amount_kopeks': amount_kopeks,
|
|
'amount_rubles': amount_kopeks / 100,
|
|
'new_balance_kopeks': new_balance_kopeks,
|
|
'new_balance_rubles': new_balance_kopeks / 100,
|
|
'description': description,
|
|
},
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Уведомления о подписке
|
|
# ============================================================================
|
|
|
|
|
|
async def notify_user_subscription_activated(
|
|
user_id: int,
|
|
expires_at: str,
|
|
tariff_name: str = '',
|
|
) -> None:
|
|
"""Уведомить пользователя об активации подписки."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'subscription.activated',
|
|
'expires_at': expires_at,
|
|
'tariff_name': tariff_name,
|
|
},
|
|
)
|
|
|
|
|
|
async def notify_user_subscription_expiring(
|
|
user_id: int,
|
|
days_left: int,
|
|
expires_at: str,
|
|
) -> None:
|
|
"""Уведомить пользователя о скором истечении подписки."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'subscription.expiring',
|
|
'days_left': days_left,
|
|
'expires_at': expires_at,
|
|
},
|
|
)
|
|
|
|
|
|
async def notify_user_subscription_expired(user_id: int) -> None:
|
|
"""Уведомить пользователя об истечении подписки."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'subscription.expired',
|
|
},
|
|
)
|
|
|
|
|
|
async def notify_user_subscription_renewed(
|
|
user_id: int,
|
|
new_expires_at: str,
|
|
amount_kopeks: int = 0,
|
|
) -> None:
|
|
"""Уведомить пользователя о продлении подписки."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'subscription.renewed',
|
|
'new_expires_at': new_expires_at,
|
|
'amount_kopeks': amount_kopeks,
|
|
'amount_rubles': amount_kopeks / 100,
|
|
},
|
|
)
|
|
|
|
|
|
async def notify_user_devices_purchased(
|
|
user_id: int,
|
|
devices_added: int,
|
|
new_device_limit: int,
|
|
amount_kopeks: int,
|
|
) -> None:
|
|
"""Уведомить пользователя о покупке устройств."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'subscription.devices_purchased',
|
|
'devices_added': devices_added,
|
|
'new_device_limit': new_device_limit,
|
|
'amount_kopeks': amount_kopeks,
|
|
'amount_rubles': amount_kopeks / 100,
|
|
},
|
|
)
|
|
|
|
|
|
async def notify_user_traffic_purchased(
|
|
user_id: int,
|
|
traffic_gb_added: int,
|
|
new_traffic_limit_gb: int,
|
|
amount_kopeks: int,
|
|
) -> None:
|
|
"""Уведомить пользователя о покупке трафика."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'subscription.traffic_purchased',
|
|
'traffic_gb_added': traffic_gb_added,
|
|
'new_traffic_limit_gb': new_traffic_limit_gb,
|
|
'amount_kopeks': amount_kopeks,
|
|
'amount_rubles': amount_kopeks / 100,
|
|
},
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Уведомления об автопродлении
|
|
# ============================================================================
|
|
|
|
|
|
async def notify_user_autopay_success(
|
|
user_id: int,
|
|
amount_kopeks: int,
|
|
new_expires_at: str,
|
|
) -> None:
|
|
"""Уведомить пользователя об успешном автопродлении."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'autopay.success',
|
|
'amount_kopeks': amount_kopeks,
|
|
'amount_rubles': amount_kopeks / 100,
|
|
'new_expires_at': new_expires_at,
|
|
},
|
|
)
|
|
|
|
|
|
async def notify_user_autopay_failed(
|
|
user_id: int,
|
|
reason: str = '',
|
|
) -> None:
|
|
"""Уведомить пользователя о неудачном автопродлении."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'autopay.failed',
|
|
'reason': reason,
|
|
},
|
|
)
|
|
|
|
|
|
async def notify_user_autopay_insufficient_funds(
|
|
user_id: int,
|
|
required_kopeks: int,
|
|
balance_kopeks: int,
|
|
) -> None:
|
|
"""Уведомить о недостатке средств для автопродления."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'autopay.insufficient_funds',
|
|
'required_kopeks': required_kopeks,
|
|
'required_rubles': required_kopeks / 100,
|
|
'balance_kopeks': balance_kopeks,
|
|
'balance_rubles': balance_kopeks / 100,
|
|
},
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Уведомления о бане/разбане
|
|
# ============================================================================
|
|
|
|
|
|
async def notify_user_ban(user_id: int, reason: str = '') -> None:
|
|
"""Уведомить пользователя о блокировке."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'account.banned',
|
|
'reason': reason,
|
|
},
|
|
)
|
|
|
|
|
|
async def notify_user_unban(user_id: int) -> None:
|
|
"""Уведомить пользователя о разблокировке."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'account.unbanned',
|
|
},
|
|
)
|
|
|
|
|
|
async def notify_user_warning(user_id: int, message: str) -> None:
|
|
"""Уведомить пользователя о предупреждении."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'account.warning',
|
|
'message': message,
|
|
},
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Уведомления о рефералах
|
|
# ============================================================================
|
|
|
|
|
|
async def notify_user_referral_bonus(
|
|
user_id: int,
|
|
bonus_kopeks: int,
|
|
referral_name: str = '',
|
|
) -> None:
|
|
"""Уведомить пользователя о реферальном бонусе."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'referral.bonus',
|
|
'bonus_kopeks': bonus_kopeks,
|
|
'bonus_rubles': bonus_kopeks / 100,
|
|
'referral_name': referral_name,
|
|
},
|
|
)
|
|
|
|
|
|
async def notify_user_referral_registered(
|
|
user_id: int,
|
|
referral_name: str = '',
|
|
) -> None:
|
|
"""Уведомить пользователя о регистрации нового реферала."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'referral.registered',
|
|
'referral_name': referral_name,
|
|
},
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Прочие уведомления
|
|
# ============================================================================
|
|
|
|
|
|
async def notify_user_daily_debit(
|
|
user_id: int,
|
|
amount_kopeks: int,
|
|
new_balance_kopeks: int,
|
|
) -> None:
|
|
"""Уведомить о ежедневном списании."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'subscription.daily_debit',
|
|
'amount_kopeks': amount_kopeks,
|
|
'amount_rubles': amount_kopeks / 100,
|
|
'new_balance_kopeks': new_balance_kopeks,
|
|
'new_balance_rubles': new_balance_kopeks / 100,
|
|
},
|
|
)
|
|
|
|
|
|
async def notify_user_traffic_reset(user_id: int) -> None:
|
|
"""Уведомить о сбросе трафика."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'subscription.traffic_reset',
|
|
},
|
|
)
|
|
|
|
|
|
async def notify_user_payment_received(
|
|
user_id: int,
|
|
amount_kopeks: int,
|
|
payment_method: str = '',
|
|
) -> None:
|
|
"""Уведомить о полученном платеже."""
|
|
await cabinet_ws_manager.send_to_user(
|
|
user_id,
|
|
{
|
|
'type': 'payment.received',
|
|
'amount_kopeks': amount_kopeks,
|
|
'amount_rubles': amount_kopeks / 100,
|
|
'payment_method': payment_method,
|
|
},
|
|
)
|