diff --git a/.env.example b/.env.example index 7171a0aa..c4a18d99 100644 --- a/.env.example +++ b/.env.example @@ -66,11 +66,36 @@ ADMIN_REPORTS_CHAT_ID= # Опционально: чат ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета -# Мониторинг трафика -TRAFFIC_MONITORING_ENABLED=false # Включить мониторинг трафика пользователей -TRAFFIC_THRESHOLD_GB_PER_DAY=10.0 # Порог трафика в ГБ за сутки (превышение вызывает уведомление) -TRAFFIC_MONITORING_INTERVAL_HOURS=24 # Интервал проверки трафика в часах (например: 1, 6, 12, 24) -SUSPICIOUS_NOTIFICATIONS_TOPIC_ID=14 # ID топика для уведомлений о подозрительной активности (0 для отправки в основной чат) +# ===== МОНИТОРИНГ ТРАФИКА ===== +# Логика: при запуске бота создаётся snapshot трафика всех пользователей. +# Через указанный интервал проверяется дельта (разница) трафика. +# Если дельта превышает порог — отправляется уведомление админам. + +# Быстрая проверка (дельта трафика за интервал) +TRAFFIC_FAST_CHECK_ENABLED=false # Включить быструю проверку +TRAFFIC_FAST_CHECK_INTERVAL_MINUTES=10 # Интервал проверки в минутах +TRAFFIC_FAST_CHECK_THRESHOLD_GB=5.0 # Порог дельты в ГБ (сколько потрачено за интервал) + +# Суточная проверка (трафик за 24 часа через bandwidth API) +TRAFFIC_DAILY_CHECK_ENABLED=false # Включить суточную проверку +TRAFFIC_DAILY_CHECK_TIME=00:00 # Время суточной проверки (HH:MM по UTC) +TRAFFIC_DAILY_THRESHOLD_GB=50.0 # Порог суточного трафика в ГБ + +# Куда отправлять уведомления +SUSPICIOUS_NOTIFICATIONS_TOPIC_ID=14 # ID топика для уведомлений о подозрительной активности + +# Фильтрация по серверам (UUID нод через запятую) +TRAFFIC_MONITORED_NODES= # Только эти ноды (пусто = все) +TRAFFIC_IGNORED_NODES= # Исключить эти ноды + +# Исключить пользователей (UUID через запятую) +TRAFFIC_EXCLUDED_USER_UUIDS= # Служебные/тунельные пользователи + +# Производительность +TRAFFIC_CHECK_BATCH_SIZE=1000 # Размер батча для получения пользователей +TRAFFIC_CHECK_CONCURRENCY=10 # Параллельных запросов к API +TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES=60 # Кулдаун уведомлений на пользователя (минуты) +TRAFFIC_SNAPSHOT_TTL_HOURS=24 # TTL snapshot трафика в Redis (часы, сохраняется при рестарте) # Черный список BLACKLIST_CHECK_ENABLED=false # Включить проверку пользователей по черному списку @@ -705,6 +730,17 @@ APP_CONFIG_PATH=app-config.json ENABLE_DEEP_LINKS=true APP_CONFIG_CACHE_TTL=3600 +# ===== BAN SYSTEM INTEGRATION (BedolagaBan) ===== +# Интеграция с системой мониторинга банов BedolagaBan +# Включить интеграцию с Ban системой +BAN_SYSTEM_ENABLED=false +# URL API сервера Ban системы (например: http://ban-server:8000) +BAN_SYSTEM_API_URL= +# API токен для авторизации в Ban системе +BAN_SYSTEM_API_TOKEN= +# Таймаут запросов к API (секунды) +BAN_SYSTEM_REQUEST_TIMEOUT=30 + # ===== СИСТЕМА БЕКАПОВ ===== BACKUP_AUTO_ENABLED=true BACKUP_INTERVAL_HOURS=24 diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index 6f99721a..1d6eb9b0 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -22,6 +22,7 @@ from .admin_wheel import router as admin_wheel_router from .admin_tariffs import router as admin_tariffs_router from .admin_servers import router as admin_servers_router from .admin_stats import router as admin_stats_router +from .admin_ban_system import router as admin_ban_system_router from .media import router as media_router # Main cabinet router @@ -53,5 +54,6 @@ router.include_router(admin_wheel_router) router.include_router(admin_tariffs_router) router.include_router(admin_servers_router) router.include_router(admin_stats_router) +router.include_router(admin_ban_system_router) __all__ = ["router"] diff --git a/app/cabinet/routes/admin_ban_system.py b/app/cabinet/routes/admin_ban_system.py new file mode 100644 index 00000000..ae40416d --- /dev/null +++ b/app/cabinet/routes/admin_ban_system.py @@ -0,0 +1,975 @@ +"""Admin routes for Ban System monitoring in cabinet.""" + +import logging +from typing import Optional, List, Any + +from fastapi import APIRouter, Depends, HTTPException, status, Query + +from app.config import settings +from app.database.models import User +from app.external.ban_system_api import BanSystemAPI, BanSystemAPIError + +from ..dependencies import get_current_admin_user +from ..schemas.ban_system import ( + BanSystemStatusResponse, + BanSystemStatsResponse, + BanUsersListResponse, + BanUserListItem, + BanUserDetailResponse, + BanUserIPInfo, + BanUserRequestLog, + BanPunishmentsListResponse, + BanPunishmentItem, + BanHistoryResponse, + BanUserRequest, + UnbanResponse, + BanNodesListResponse, + BanNodeItem, + BanAgentsListResponse, + BanAgentItem, + BanAgentsSummary, + BanTrafficViolationsResponse, + BanTrafficViolationItem, + BanTrafficResponse, + BanTrafficTopItem, + BanSettingsResponse, + BanSettingDefinition, + BanWhitelistRequest, + BanReportResponse, + BanReportTopViolator, + BanHealthResponse, + BanHealthComponent, + BanHealthDetailedResponse, + BanAgentHistoryResponse, + BanAgentHistoryItem, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/ban-system", tags=["Cabinet Admin Ban System"]) + + +def _get_ban_api() -> BanSystemAPI: + """Get Ban System API instance.""" + logger.info(f"Ban System check - enabled: {settings.is_ban_system_enabled()}, configured: {settings.is_ban_system_configured()}") + logger.info(f"Ban System URL: {settings.get_ban_system_api_url()}") + + if not settings.is_ban_system_enabled(): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Ban System integration is disabled", + ) + + if not settings.is_ban_system_configured(): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Ban System is not configured", + ) + + return BanSystemAPI( + base_url=settings.get_ban_system_api_url(), + api_token=settings.get_ban_system_api_token(), + timeout=settings.get_ban_system_request_timeout(), + ) + + +async def _api_request(api: BanSystemAPI, method: str, *args, **kwargs) -> Any: + """Execute API request with error handling.""" + try: + async with api: + func = getattr(api, method) + return await func(*args, **kwargs) + except BanSystemAPIError as e: + logger.error(f"Ban System API 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}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Internal error: {str(e)}", + ) + + +# === Status === + +@router.get("/status", response_model=BanSystemStatusResponse) +async def get_ban_system_status( + admin: User = Depends(get_current_admin_user), +) -> BanSystemStatusResponse: + """Get Ban System integration status.""" + return BanSystemStatusResponse( + enabled=settings.is_ban_system_enabled(), + configured=settings.is_ban_system_configured(), + ) + + +# === Stats === + +@router.get("/stats/raw") +async def get_stats_raw( + admin: User = Depends(get_current_admin_user), +) -> dict: + """Get raw stats from Ban System API for debugging.""" + api = _get_ban_api() + data = await _api_request(api, "get_stats") + return {"raw_response": data} + + +@router.get("/stats", response_model=BanSystemStatsResponse) +async def get_stats( + admin: User = Depends(get_current_admin_user), +) -> BanSystemStatsResponse: + """Get overall Ban System statistics.""" + from datetime import datetime + + api = _get_ban_api() + data = await _api_request(api, "get_stats") + + logger.info(f"Ban System raw stats: {data}") + + # Extract punishment stats + punishment_stats = data.get("punishment_stats") or {} + + # Extract connected nodes info + connected_nodes = data.get("connected_nodes", []) + + # Count online nodes/agents + nodes_online = sum(1 for n in connected_nodes if n.get("is_online", False)) + + # Extract tcp_metrics for uptime + tcp_metrics = data.get("tcp_metrics") or {} + uptime_seconds = None + intake_started = tcp_metrics.get("intake_started_at") + if intake_started: + try: + start_time = datetime.fromisoformat(intake_started.replace("Z", "+00:00")) + uptime_seconds = int((datetime.now(start_time.tzinfo) - start_time).total_seconds()) + except Exception: + pass + + return BanSystemStatsResponse( + total_users=data.get("total_users", 0), + active_users=data.get("users_with_limit", 0), + users_over_limit=data.get("users_over_limit", 0), + total_requests=data.get("total_requests", 0), + total_punishments=punishment_stats.get("total_punishments", 0), + active_punishments=punishment_stats.get("active_punishments", 0), + nodes_online=nodes_online, + nodes_total=len(connected_nodes), + agents_online=nodes_online, # Agents = connected nodes with stats + agents_total=len(connected_nodes), + panel_connected=data.get("panel_loaded", False), + uptime_seconds=uptime_seconds, + ) + + +# === Users === + +@router.get("/users", response_model=BanUsersListResponse) +async def get_users( + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + status: Optional[str] = Query(None, description="Filter: over_limit, with_limit, unlimited"), + admin: User = Depends(get_current_admin_user), +) -> BanUsersListResponse: + """Get list of users from Ban System.""" + api = _get_ban_api() + data = await _api_request(api, "get_users", offset=offset, limit=limit, status=status) + + users = [] + for user_data in data.get("users", []): + users.append(BanUserListItem( + email=user_data.get("email", ""), + unique_ip_count=user_data.get("unique_ip_count", 0), + total_requests=user_data.get("total_requests", 0), + limit=user_data.get("limit"), + is_over_limit=user_data.get("is_over_limit", False), + blocked_count=user_data.get("blocked_count", 0), + )) + + return BanUsersListResponse( + users=users, + total=data.get("total", len(users)), + offset=offset, + limit=limit, + ) + + +@router.get("/users/over-limit", response_model=BanUsersListResponse) +async def get_users_over_limit( + limit: int = Query(50, ge=1, le=100), + admin: User = Depends(get_current_admin_user), +) -> BanUsersListResponse: + """Get users who exceeded their device limit.""" + api = _get_ban_api() + data = await _api_request(api, "get_users_over_limit", limit=limit) + + users = [] + for user_data in data.get("users", []): + users.append(BanUserListItem( + email=user_data.get("email", ""), + unique_ip_count=user_data.get("unique_ip_count", 0), + total_requests=user_data.get("total_requests", 0), + limit=user_data.get("limit"), + is_over_limit=True, + blocked_count=user_data.get("blocked_count", 0), + )) + + return BanUsersListResponse( + users=users, + total=len(users), + offset=0, + limit=limit, + ) + + +@router.get("/users/search/{query}") +async def search_users( + query: str, + admin: User = Depends(get_current_admin_user), +) -> BanUsersListResponse: + """Search for users.""" + api = _get_ban_api() + data = await _api_request(api, "search_users", query=query) + + users = [] + users_data = data.get("users", []) if isinstance(data, dict) else data + for user_data in users_data: + users.append(BanUserListItem( + email=user_data.get("email", ""), + unique_ip_count=user_data.get("unique_ip_count", 0), + total_requests=user_data.get("total_requests", 0), + limit=user_data.get("limit"), + is_over_limit=user_data.get("is_over_limit", False), + blocked_count=user_data.get("blocked_count", 0), + )) + + return BanUsersListResponse( + users=users, + total=len(users), + offset=0, + limit=100, + ) + + +@router.get("/users/{email}", response_model=BanUserDetailResponse) +async def get_user_detail( + email: str, + admin: User = Depends(get_current_admin_user), +) -> BanUserDetailResponse: + """Get detailed user information.""" + api = _get_ban_api() + data = await _api_request(api, "get_user", email=email) + + ips = [] + for ip_data in data.get("ips", {}).values() if isinstance(data.get("ips"), dict) else data.get("ips", []): + ips.append(BanUserIPInfo( + ip=ip_data.get("ip", ""), + first_seen=ip_data.get("first_seen"), + last_seen=ip_data.get("last_seen"), + node=ip_data.get("node"), + request_count=ip_data.get("request_count", 0), + country_code=ip_data.get("country_code"), + country_name=ip_data.get("country_name"), + city=ip_data.get("city"), + )) + + recent_requests = [] + for req_data in data.get("recent_requests", []): + recent_requests.append(BanUserRequestLog( + timestamp=req_data.get("timestamp"), + source_ip=req_data.get("source_ip", ""), + destination=req_data.get("destination"), + dest_port=req_data.get("dest_port"), + protocol=req_data.get("protocol"), + action=req_data.get("action"), + node=req_data.get("node"), + )) + + return BanUserDetailResponse( + email=data.get("email", email), + unique_ip_count=data.get("unique_ip_count", 0), + total_requests=data.get("total_requests", 0), + limit=data.get("limit"), + is_over_limit=data.get("is_over_limit", False), + blocked_count=data.get("blocked_count", 0), + ips=ips, + recent_requests=recent_requests, + network_type=data.get("network_type"), + ) + + +# === Punishments === + +@router.get("/punishments", response_model=BanPunishmentsListResponse) +async def get_punishments( + admin: User = Depends(get_current_admin_user), +) -> BanPunishmentsListResponse: + """Get list of active punishments (bans).""" + api = _get_ban_api() + data = await _api_request(api, "get_punishments") + + punishments = [] + punishments_data = data if isinstance(data, list) else data.get("punishments", []) + for p in punishments_data: + punishments.append(BanPunishmentItem( + id=p.get("id"), + user_id=p.get("user_id", ""), + uuid=p.get("uuid"), + username=p.get("username", ""), + reason=p.get("reason"), + punished_at=p.get("punished_at"), + enable_at=p.get("enable_at"), + ip_count=p.get("ip_count", 0), + limit=p.get("limit", 0), + enabled=p.get("enabled", False), + enabled_at=p.get("enabled_at"), + node_name=p.get("node_name"), + )) + + return BanPunishmentsListResponse( + punishments=punishments, + total=len(punishments), + ) + + +@router.post("/punishments/{user_id}/unban", response_model=UnbanResponse) +async def unban_user( + user_id: str, + admin: User = Depends(get_current_admin_user), +) -> UnbanResponse: + """Unban (enable) a 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") + return UnbanResponse(success=True, message="User unbanned successfully") + except HTTPException: + raise + except Exception as e: + return UnbanResponse(success=False, message=str(e)) + + +@router.post("/ban", response_model=UnbanResponse) +async def ban_user( + request: BanUserRequest, + admin: User = Depends(get_current_admin_user), +) -> UnbanResponse: + """Manually ban a user.""" + api = _get_ban_api() + try: + await _api_request( + api, + "ban_user", + username=request.username, + minutes=request.minutes, + reason=request.reason, + ) + logger.info(f"Admin {admin.id} banned user {request.username}: {request.reason}") + return UnbanResponse(success=True, message="User banned successfully") + except HTTPException: + raise + except Exception as e: + return UnbanResponse(success=False, message=str(e)) + + +@router.get("/history/{query}", response_model=BanHistoryResponse) +async def get_punishment_history( + query: str, + limit: int = Query(20, ge=1, le=100), + admin: User = Depends(get_current_admin_user), +) -> BanHistoryResponse: + """Get punishment history for a user.""" + api = _get_ban_api() + data = await _api_request(api, "get_punishment_history", query=query, limit=limit) + + items = [] + history_data = data if isinstance(data, list) else data.get("items", []) + for p in history_data: + items.append(BanPunishmentItem( + id=p.get("id"), + user_id=p.get("user_id", ""), + uuid=p.get("uuid"), + username=p.get("username", ""), + reason=p.get("reason"), + punished_at=p.get("punished_at"), + enable_at=p.get("enable_at"), + ip_count=p.get("ip_count", 0), + limit=p.get("limit", 0), + enabled=p.get("enabled", False), + enabled_at=p.get("enabled_at"), + node_name=p.get("node_name"), + )) + + return BanHistoryResponse( + items=items, + total=len(items), + ) + + +# === Nodes === + +@router.get("/nodes", response_model=BanNodesListResponse) +async def get_nodes( + admin: User = Depends(get_current_admin_user), +) -> BanNodesListResponse: + """Get list of connected nodes.""" + api = _get_ban_api() + data = await _api_request(api, "get_nodes") + + nodes = [] + nodes_data = data if isinstance(data, list) else data.get("nodes", []) + online_count = 0 + for n in nodes_data: + # API returns is_online, not is_connected + is_connected = n.get("is_online", n.get("is_connected", False)) + if is_connected: + online_count += 1 + nodes.append(BanNodeItem( + name=n.get("name", ""), + address=n.get("address"), + is_connected=is_connected, + # API returns last_heartbeat, not last_seen + last_seen=n.get("last_heartbeat", n.get("last_seen")), + # API returns unique_users, not users_count + users_count=n.get("unique_users", n.get("users_count", 0)), + agent_stats=n.get("agent_stats"), + )) + + return BanNodesListResponse( + nodes=nodes, + total=len(nodes), + online=online_count, + ) + + +# === Agents === + +@router.get("/agents", response_model=BanAgentsListResponse) +async def get_agents( + search: Optional[str] = Query(None), + health: Optional[str] = Query(None, description="healthy, warning, critical"), + agent_status: Optional[str] = Query(None, alias="status", description="online, offline"), + admin: User = Depends(get_current_admin_user), +) -> BanAgentsListResponse: + """Get list of monitoring agents.""" + api = _get_ban_api() + data = await _api_request( + api, + "get_agents", + search=search, + health=health, + status=agent_status, + ) + + agents = [] + agents_data = data.get("agents", {}) if isinstance(data, dict) else data + online_count = 0 + + # API returns agents as dict: {"node_name": {stats...}, ...} + if isinstance(agents_data, dict): + for node_name, agent_info in agents_data.items(): + # Extract metrics from nested structure + stats = agent_info.get("stats", {}) or {} + metrics = stats.get("metrics", {}) or {} + sent_info = metrics.get("sent", {}) or {} + queue_info = metrics.get("queue", {}) or {} + conn_info = metrics.get("connection", {}) or {} + + is_online = agent_info.get("is_online", False) + if is_online: + online_count += 1 + + agents.append(BanAgentItem( + node_name=node_name, + sent_total=sent_info.get("total", 0), + dropped_total=sent_info.get("dropped", 0), + batches_total=sent_info.get("batches", 0), + reconnects=conn_info.get("reconnects", 0), + failures=conn_info.get("failures", sent_info.get("failed", 0)), + queue_size=queue_info.get("current", 0), + queue_max=queue_info.get("high_watermark", 0), + dedup_checked=0, + dedup_skipped=0, + filter_checked=0, + filter_filtered=0, + health=agent_info.get("health", "unknown"), + is_online=is_online, + last_report=agent_info.get("updated_at"), + )) + else: + # Fallback for list format + for a in agents_data: + is_online = a.get("is_online", False) + if is_online: + online_count += 1 + agents.append(BanAgentItem( + node_name=a.get("node_name", ""), + sent_total=a.get("sent_total", 0), + dropped_total=a.get("dropped_total", 0), + batches_total=a.get("batches_total", 0), + reconnects=a.get("reconnects", 0), + failures=a.get("failures", 0), + queue_size=a.get("queue_size", 0), + queue_max=a.get("queue_max", 0), + dedup_checked=a.get("dedup_checked", 0), + dedup_skipped=a.get("dedup_skipped", 0), + filter_checked=a.get("filter_checked", 0), + filter_filtered=a.get("filter_filtered", 0), + health=a.get("health", "unknown"), + is_online=is_online, + last_report=a.get("last_report"), + )) + + summary = None + if isinstance(data, dict) and "summary" in data: + s = data["summary"] + summary = BanAgentsSummary( + total_agents=s.get("total_agents", len(agents)), + online_agents=s.get("online_agents", online_count), + total_sent=s.get("total_sent", 0), + total_dropped=s.get("total_dropped", 0), + avg_queue_size=s.get("avg_queue_size", 0.0), + healthy_count=s.get("healthy_count", 0), + warning_count=s.get("warning_count", 0), + critical_count=s.get("critical_count", 0), + ) + + return BanAgentsListResponse( + agents=agents, + summary=summary, + total=len(agents), + online=online_count, + ) + + +@router.get("/agents/summary", response_model=BanAgentsSummary) +async def get_agents_summary( + admin: User = Depends(get_current_admin_user), +) -> BanAgentsSummary: + """Get agents summary statistics.""" + api = _get_ban_api() + data = await _api_request(api, "get_agents_summary") + + return BanAgentsSummary( + total_agents=data.get("total_agents", 0), + online_agents=data.get("online_agents", 0), + total_sent=data.get("total_sent", 0), + total_dropped=data.get("total_dropped", 0), + avg_queue_size=data.get("avg_queue_size", 0.0), + healthy_count=data.get("healthy_count", 0), + warning_count=data.get("warning_count", 0), + critical_count=data.get("critical_count", 0), + ) + + +# === Traffic Violations === + +@router.get("/traffic/violations", response_model=BanTrafficViolationsResponse) +async def get_traffic_violations( + limit: int = Query(50, ge=1, le=100), + admin: User = Depends(get_current_admin_user), +) -> BanTrafficViolationsResponse: + """Get list of traffic limit violations.""" + api = _get_ban_api() + data = await _api_request(api, "get_traffic_violations", limit=limit) + + violations = [] + violations_data = data if isinstance(data, list) else data.get("violations", []) + for v in violations_data: + violations.append(BanTrafficViolationItem( + id=v.get("id"), + username=v.get("username", ""), + email=v.get("email"), + violation_type=v.get("violation_type", v.get("type", "")), + description=v.get("description"), + bytes_used=v.get("bytes_used", 0), + bytes_limit=v.get("bytes_limit", 0), + detected_at=v.get("detected_at"), + resolved=v.get("resolved", False), + )) + + return BanTrafficViolationsResponse( + violations=violations, + total=len(violations), + ) + + +# === Full Traffic Stats === + +@router.get("/traffic", response_model=BanTrafficResponse) +async def get_traffic( + admin: User = Depends(get_current_admin_user), +) -> BanTrafficResponse: + """Get full traffic statistics including top users.""" + api = _get_ban_api() + data = await _api_request(api, "get_traffic") + + top_users = [] + for u in data.get("top_users", []): + top_users.append(BanTrafficTopItem( + username=u.get("username", ""), + bytes_total=u.get("bytes_total", u.get("total_bytes", 0)), + bytes_limit=u.get("bytes_limit"), + over_limit=u.get("over_limit", False), + )) + + violations = [] + for v in data.get("recent_violations", []): + violations.append(BanTrafficViolationItem( + id=v.get("id"), + username=v.get("username", ""), + email=v.get("email"), + violation_type=v.get("violation_type", v.get("type", "")), + description=v.get("description"), + bytes_used=v.get("bytes_used", 0), + bytes_limit=v.get("bytes_limit", 0), + detected_at=v.get("detected_at"), + resolved=v.get("resolved", False), + )) + + return BanTrafficResponse( + enabled=data.get("enabled", False), + stats=data.get("stats"), + top_users=top_users, + recent_violations=violations, + ) + + +@router.get("/traffic/top") +async def get_traffic_top( + limit: int = Query(20, ge=1, le=100), + admin: User = Depends(get_current_admin_user), +) -> List[BanTrafficTopItem]: + """Get top users by traffic.""" + api = _get_ban_api() + data = await _api_request(api, "get_traffic_top", limit=limit) + + top_users = [] + users_data = data if isinstance(data, list) else data.get("users", []) + for u in users_data: + top_users.append(BanTrafficTopItem( + username=u.get("username", ""), + bytes_total=u.get("bytes_total", u.get("total_bytes", 0)), + bytes_limit=u.get("bytes_limit"), + over_limit=u.get("over_limit", False), + )) + + return top_users + + +# === Settings === + +def _parse_setting_response(key: str, data: Any, default_type: str = "str") -> BanSettingDefinition: + """Parse setting response from API.""" + if isinstance(data, dict) and "value" in data: + return BanSettingDefinition( + key=key, + value=data.get("value"), + type=data.get("type", default_type), + min_value=data.get("min"), + max_value=data.get("max"), + editable=data.get("editable", True), + description=data.get("description"), + category=data.get("category"), + ) + else: + # Простое значение или dict без "value" + value = data.get("value", data) if isinstance(data, dict) else data + value_type = default_type + if isinstance(value, bool): + value_type = "bool" + elif isinstance(value, int): + value_type = "int" + elif isinstance(value, float): + value_type = "float" + elif isinstance(value, list): + value_type = "list" + + return BanSettingDefinition( + key=key, + value=value, + type=value_type, + min_value=None, + max_value=None, + editable=True, + description=None, + category=None, + ) + + +@router.get("/settings", response_model=BanSettingsResponse) +async def get_settings( + admin: User = Depends(get_current_admin_user), +) -> BanSettingsResponse: + """Get all Ban System settings.""" + api = _get_ban_api() + data = await _api_request(api, "get_settings") + + settings_list = [] + settings_data = data.get("settings", {}) if isinstance(data, dict) else {} + + for key, info in settings_data.items(): + # API может возвращать настройки в двух форматах: + # 1. {"key": {"value": ..., "type": ...}} - с метаданными + # 2. {"key": value} - просто значение + if isinstance(info, dict) and "value" in info: + # Формат с метаданными + settings_list.append(BanSettingDefinition( + key=key, + value=info.get("value"), + type=info.get("type", "str"), + min_value=info.get("min"), + max_value=info.get("max"), + editable=info.get("editable", True), + description=info.get("description"), + category=info.get("category"), + )) + else: + # Простой формат - определяем тип по значению + value_type = "str" + if isinstance(info, bool): + value_type = "bool" + elif isinstance(info, int): + value_type = "int" + elif isinstance(info, float): + value_type = "float" + elif isinstance(info, list): + value_type = "list" + + settings_list.append(BanSettingDefinition( + key=key, + value=info, + type=value_type, + min_value=None, + max_value=None, + editable=True, + description=None, + category=None, + )) + + return BanSettingsResponse(settings=settings_list) + + +@router.get("/settings/{key}") +async def get_setting( + key: str, + admin: User = Depends(get_current_admin_user), +) -> BanSettingDefinition: + """Get a specific setting.""" + api = _get_ban_api() + data = await _api_request(api, "get_setting", key=key) + + return _parse_setting_response(key, data) + + +@router.post("/settings/{key}") +async def set_setting( + key: str, + value: str = Query(...), + admin: User = Depends(get_current_admin_user), +) -> BanSettingDefinition: + """Set a setting value.""" + 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}") + + return _parse_setting_response(key, data) + + +@router.post("/settings/{key}/toggle") +async def toggle_setting( + key: str, + admin: User = Depends(get_current_admin_user), +) -> BanSettingDefinition: + """Toggle a boolean 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}") + + return _parse_setting_response(key, data, default_type="bool") + + +# === Whitelist === + +@router.post("/settings/whitelist/add", response_model=UnbanResponse) +async def whitelist_add( + request: BanWhitelistRequest, + admin: User = Depends(get_current_admin_user), +) -> UnbanResponse: + """Add user to whitelist.""" + 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") + return UnbanResponse(success=True, message=f"User {request.username} added to whitelist") + except HTTPException: + raise + except Exception as e: + return UnbanResponse(success=False, message=str(e)) + + +@router.post("/settings/whitelist/remove", response_model=UnbanResponse) +async def whitelist_remove( + request: BanWhitelistRequest, + admin: User = Depends(get_current_admin_user), +) -> UnbanResponse: + """Remove user from whitelist.""" + 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") + return UnbanResponse(success=True, message=f"User {request.username} removed from whitelist") + except HTTPException: + raise + except Exception as e: + return UnbanResponse(success=False, message=str(e)) + + +# === Reports === + +@router.get("/report", response_model=BanReportResponse) +async def get_report( + hours: int = Query(24, ge=1, le=168), + admin: User = Depends(get_current_admin_user), +) -> BanReportResponse: + """Get period report.""" + api = _get_ban_api() + data = await _api_request(api, "get_stats_period", hours=hours) + + top_violators = [] + punishment_stats = data.get("punishment_stats", {}) or {} + for v in punishment_stats.get("top_violators", []): + top_violators.append(BanReportTopViolator( + username=v.get("username", ""), + count=v.get("count", 0), + )) + + return BanReportResponse( + period_hours=hours, + current_users=data.get("current_users", 0), + current_ips=data.get("current_ips", 0), + punishment_stats=punishment_stats, + top_violators=top_violators, + ) + + +# === Health === + +@router.get("/health", response_model=BanHealthResponse) +async def get_health( + admin: User = Depends(get_current_admin_user), +) -> BanHealthResponse: + """Get Ban System health status.""" + api = _get_ban_api() + data = await _api_request(api, "health_check") + + components = [] + for name, info in data.get("components", {}).items(): + if isinstance(info, dict): + components.append(BanHealthComponent( + name=name, + status=info.get("status", "unknown"), + message=info.get("message"), + details=info.get("details"), + )) + else: + components.append(BanHealthComponent( + name=name, + status=str(info) if info else "unknown", + )) + + return BanHealthResponse( + status=data.get("status", "unknown"), + uptime=data.get("uptime"), + components=components, + ) + + +@router.get("/health/detailed", response_model=BanHealthDetailedResponse) +async def get_health_detailed( + admin: User = Depends(get_current_admin_user), +) -> BanHealthDetailedResponse: + """Get detailed health information.""" + api = _get_ban_api() + data = await _api_request(api, "health_detailed") + + return BanHealthDetailedResponse( + status=data.get("status", "unknown"), + uptime=data.get("uptime"), + components=data.get("components", {}), + ) + + +# === Agent History === + +@router.get("/agents/{node_name}/history", response_model=BanAgentHistoryResponse) +async def get_agent_history( + node_name: str, + hours: int = Query(24, ge=1, le=168), + admin: User = Depends(get_current_admin_user), +) -> BanAgentHistoryResponse: + """Get agent statistics history.""" + api = _get_ban_api() + data = await _api_request(api, "get_agent_history", node_name=node_name, hours=hours) + + history = [] + for item in data.get("history", []): + history.append(BanAgentHistoryItem( + timestamp=item.get("timestamp"), + sent_total=item.get("sent_total", 0), + dropped_total=item.get("dropped_total", 0), + queue_size=item.get("queue_size", 0), + batches_total=item.get("batches_total", 0), + )) + + return BanAgentHistoryResponse( + node=data.get("node", node_name), + hours=data.get("hours", hours), + records=data.get("records", len(history)), + delta=data.get("delta"), + first=data.get("first"), + last=data.get("last"), + history=history, + ) + + +# === User Punishment History === + +@router.get("/users/{email}/history", response_model=BanHistoryResponse) +async def get_user_punishment_history( + email: str, + limit: int = Query(20, ge=1, le=100), + admin: User = Depends(get_current_admin_user), +) -> BanHistoryResponse: + """Get punishment history for a specific user.""" + api = _get_ban_api() + data = await _api_request(api, "get_punishment_history", query=email, limit=limit) + + items = [] + history_data = data if isinstance(data, list) else data.get("items", []) + for p in history_data: + items.append(BanPunishmentItem( + id=p.get("id"), + user_id=p.get("user_id", ""), + uuid=p.get("uuid"), + username=p.get("username", ""), + reason=p.get("reason"), + punished_at=p.get("punished_at"), + enable_at=p.get("enable_at"), + ip_count=p.get("ip_count", 0), + limit=p.get("limit", 0), + enabled=p.get("enabled", False), + enabled_at=p.get("enabled_at"), + node_name=p.get("node_name"), + )) + + return BanHistoryResponse( + items=items, + total=len(items), + ) diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index 41acebbe..8aa79807 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -119,15 +119,19 @@ async def get_payment_methods(): """Get available payment methods.""" methods = [] - # YooKassa + # YooKassa - with card and SBP options if settings.is_yookassa_enabled(): methods.append(PaymentMethodResponse( id="yookassa", - name="YooKassa (Bank Card)", - description="Pay with bank card via YooKassa", + name="YooKassa", + description="Pay via YooKassa", min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS, max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS, is_available=True, + options=[ + {"id": "card", "name": "💳 Карта", "description": "Банковская карта"}, + {"id": "sbp", "name": "🏦 СБП", "description": "Система быстрых платежей (QR)"}, + ], )) # CryptoBot @@ -378,19 +382,34 @@ async def create_topup( try: if request.payment_method == "yookassa": yookassa_service = YooKassaService() - result = await yookassa_service.create_payment( - amount=amount_rubles, - currency="RUB", - description=f"Пополнение баланса на {amount_rubles:.2f} ₽", - metadata={ - "user_id": str(user.id), - "user_telegram_id": str(user.telegram_id) if user.telegram_id else "", - "user_username": user.username or "", - "amount_kopeks": str(request.amount_kopeks), - "type": "balance_topup", - "source": "cabinet", - }, - ) + yookassa_metadata = { + "user_id": str(user.id), + "user_telegram_id": str(user.telegram_id) if user.telegram_id else "", + "user_username": user.username or "", + "amount_kopeks": str(request.amount_kopeks), + "type": "balance_topup", + "source": "cabinet", + } + + # Use payment_option to select card or sbp (default: card) + option = (request.payment_option or "").strip().lower() + if option == "sbp": + # Create SBP payment with QR code + result = await yookassa_service.create_sbp_payment( + amount=amount_rubles, + currency="RUB", + description=f"Пополнение баланса на {amount_rubles:.2f} ₽", + metadata=yookassa_metadata, + ) + else: + # Default: card payment + result = await yookassa_service.create_payment( + amount=amount_rubles, + currency="RUB", + description=f"Пополнение баланса на {amount_rubles:.2f} ₽", + metadata=yookassa_metadata, + ) + if result and not result.get("error"): payment_url = result.get("confirmation_url") payment_id = result.get("id") diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index 3a102fc1..aa069715 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -1661,18 +1661,48 @@ def _convert_remnawave_block_to_step(block: Dict[str, Any], url_scheme: str = "" # Known app URL schemes (fallback if RemnaWave doesn't provide urlScheme) KNOWN_APP_URL_SCHEMES = { + # iOS "happ": "happ://add/", "streisand": "streisand://import/", "shadowrocket": "sub://", - "v2rayn": "v2rayng://install-config?url=", - "v2rayng": "v2rayng://install-config?url=", + "shadow rocket": "sub://", + "karing": "karing://install-config?url=", + "foxray": "foxray://yiguo.dev/sub/add/?url=", + "fox ray": "foxray://yiguo.dev/sub/add/?url=", + "v2box": "v2box://install-sub?url=", + "sing-box": "sing-box://import-remote-profile?url=", + "singbox": "sing-box://import-remote-profile?url=", + "quantumult x": "quantumult-x://add-resource?remote-resource=", + "quantumultx": "quantumult-x://add-resource?remote-resource=", + "quantumult": "quantumult-x://add-resource?remote-resource=", + "surge": "surge3://install-config?url=", + "loon": "loon://import?sub=", + "stash": "stash://install-config?url=", + # Android + "v2rayn": "v2rayng://install-sub?url=", + "v2rayng": "v2rayng://install-sub?url=", + "v2ray ng": "v2rayng://install-sub?url=", + "nekoray": "sn://subscription?url=", + "nekobox": "sn://subscription?url=", + "neko ray": "sn://subscription?url=", + "neko box": "sn://subscription?url=", + "surfboard": "surfboard://install-config?url=", + # PC (Windows/macOS/Linux) "clash": "clash://install-config?url=", "clash meta": "clash://install-config?url=", "clash verge": "clash://install-config?url=", - "hiddify": "hiddify://import/", - "nekoray": "sn://subscription?url=", - "nekobox": "sn://subscription?url=", - "karing": "karing://add/", + "clash verge rev": "clash://install-config?url=", + "clashx": "clashx://install-config?url=", + "clashx meta": "clash://install-config?url=", + "clashx pro": "clash://install-config?url=", + "flclash": "clash://install-config?url=", + "flclashx": "clash://install-config?url=", + "koala clash": "clash://install-config?url=", + "koalaclash": "clash://install-config?url=", + "hiddify": "hiddify://install-config/?url=", + "hiddify next": "hiddify://install-config/?url=", + "mihomo party": "clash://install-config?url=", + "mihomo": "clash://install-config?url=", } @@ -1719,7 +1749,7 @@ def _convert_remnawave_app_to_cabinet(app: Dict[str, Any]) -> Dict[str, Any]: "id": app.get("name", "").lower().replace(" ", "-"), "name": app.get("name", ""), "isFeatured": app.get("featured", False), - "urlScheme": app.get("urlScheme", ""), + "urlScheme": url_scheme, # Use resolved url_scheme (with fallback from app name) "isNeedBase64Encoding": app.get("isNeedBase64Encoding", False), "installationStep": installation_step, "addSubscriptionStep": subscription_step, diff --git a/app/cabinet/schemas/ban_system.py b/app/cabinet/schemas/ban_system.py new file mode 100644 index 00000000..defdb1f9 --- /dev/null +++ b/app/cabinet/schemas/ban_system.py @@ -0,0 +1,340 @@ +"""Schemas for Ban System integration in cabinet.""" + +from datetime import datetime +from typing import List, Optional, Dict, Any +from pydantic import BaseModel, Field + + +# === Status === + +class BanSystemStatusResponse(BaseModel): + """Ban System integration status.""" + enabled: bool + configured: bool + + +# === Stats === + +class BanSystemStatsResponse(BaseModel): + """Overall Ban System statistics.""" + total_users: int = 0 + active_users: int = 0 + users_over_limit: int = 0 + total_requests: int = 0 + total_punishments: int = 0 + active_punishments: int = 0 + nodes_online: int = 0 + nodes_total: int = 0 + agents_online: int = 0 + agents_total: int = 0 + panel_connected: bool = False + uptime_seconds: Optional[int] = None + + +# === Users === + +class BanUserIPInfo(BaseModel): + """User IP address information.""" + ip: str + first_seen: Optional[datetime] = None + last_seen: Optional[datetime] = None + node: Optional[str] = None + request_count: int = 0 + country_code: Optional[str] = None + country_name: Optional[str] = None + city: Optional[str] = None + + +class BanUserRequestLog(BaseModel): + """User request log entry.""" + timestamp: datetime + source_ip: str + destination: Optional[str] = None + dest_port: Optional[int] = None + protocol: Optional[str] = None + action: Optional[str] = None + node: Optional[str] = None + + +class BanUserListItem(BaseModel): + """User in the list.""" + email: str + unique_ip_count: int = 0 + total_requests: int = 0 + limit: Optional[int] = None + is_over_limit: bool = False + blocked_count: int = 0 + last_seen: Optional[datetime] = None + + +class BanUsersListResponse(BaseModel): + """Paginated list of users.""" + users: List[BanUserListItem] = [] + total: int = 0 + offset: int = 0 + limit: int = 50 + + +class BanUserDetailResponse(BaseModel): + """Detailed user information.""" + email: str + unique_ip_count: int = 0 + total_requests: int = 0 + limit: Optional[int] = None + is_over_limit: bool = False + blocked_count: int = 0 + ips: List[BanUserIPInfo] = [] + recent_requests: List[BanUserRequestLog] = [] + network_type: Optional[str] = None # wifi, mobile, mixed + + +# === Punishments (Bans) === + +class BanPunishmentItem(BaseModel): + """Punishment/ban entry.""" + id: Optional[int] = None + user_id: str + uuid: Optional[str] = None + username: str + reason: Optional[str] = None + punished_at: datetime + enable_at: Optional[datetime] = None + ip_count: int = 0 + limit: int = 0 + enabled: bool = False + enabled_at: Optional[datetime] = None + node_name: Optional[str] = None + + +class BanPunishmentsListResponse(BaseModel): + """List of active punishments.""" + punishments: List[BanPunishmentItem] = [] + total: int = 0 + + +class BanHistoryResponse(BaseModel): + """Punishment history.""" + items: List[BanPunishmentItem] = [] + total: int = 0 + + +class BanUserRequest(BaseModel): + """Request to ban a user.""" + username: str = Field(..., min_length=1) + minutes: int = Field(default=30, ge=1) + reason: Optional[str] = Field(None, max_length=500) + + +class UnbanResponse(BaseModel): + """Unban response.""" + success: bool + message: str + + +# === Nodes === + +class BanNodeItem(BaseModel): + """Node information.""" + name: str + address: Optional[str] = None + is_connected: bool = False + last_seen: Optional[datetime] = None + users_count: int = 0 + agent_stats: Optional[Dict[str, Any]] = None + + +class BanNodesListResponse(BaseModel): + """List of nodes.""" + nodes: List[BanNodeItem] = [] + total: int = 0 + online: int = 0 + + +# === Agents === + +class BanAgentItem(BaseModel): + """Monitoring agent information.""" + node_name: str + sent_total: int = 0 + dropped_total: int = 0 + batches_total: int = 0 + reconnects: int = 0 + failures: int = 0 + queue_size: int = 0 + queue_max: int = 0 + dedup_checked: int = 0 + dedup_skipped: int = 0 + filter_checked: int = 0 + filter_filtered: int = 0 + health: str = "unknown" # healthy, warning, critical + is_online: bool = False + last_report: Optional[datetime] = None + + +class BanAgentsSummary(BaseModel): + """Agents summary statistics.""" + total_agents: int = 0 + online_agents: int = 0 + total_sent: int = 0 + total_dropped: int = 0 + avg_queue_size: float = 0.0 + healthy_count: int = 0 + warning_count: int = 0 + critical_count: int = 0 + + +class BanAgentsListResponse(BaseModel): + """List of agents.""" + agents: List[BanAgentItem] = [] + summary: Optional[BanAgentsSummary] = None + total: int = 0 + online: int = 0 + + +# === Traffic === + +class BanTrafficStats(BaseModel): + """Traffic statistics.""" + total_bytes: int = 0 + upload_bytes: int = 0 + download_bytes: int = 0 + total_users: int = 0 + violators_count: int = 0 + + +class BanTrafficUserItem(BaseModel): + """User traffic information.""" + username: str + email: Optional[str] = None + total_bytes: int = 0 + upload_bytes: int = 0 + download_bytes: int = 0 + limit_bytes: Optional[int] = None + is_over_limit: bool = False + + +class BanTrafficViolationItem(BaseModel): + """Traffic limit violation entry.""" + id: Optional[int] = None + username: str + email: Optional[str] = None + violation_type: str + description: Optional[str] = None + bytes_used: int = 0 + bytes_limit: int = 0 + detected_at: datetime + resolved: bool = False + + +class BanTrafficViolationsResponse(BaseModel): + """List of traffic violations.""" + violations: List[BanTrafficViolationItem] = [] + total: int = 0 + + +class BanTrafficTopItem(BaseModel): + """Top user by traffic.""" + username: str + bytes_total: int = 0 + bytes_limit: Optional[int] = None + over_limit: bool = False + + +class BanTrafficResponse(BaseModel): + """Full traffic statistics response.""" + enabled: bool = False + stats: Optional[Dict[str, Any]] = None + top_users: List[BanTrafficTopItem] = [] + recent_violations: List[BanTrafficViolationItem] = [] + + +# === Settings === + +class BanSettingDefinition(BaseModel): + """Setting definition with value.""" + key: str + value: Any + type: str # bool, int, str, list + min_value: Optional[int] = None + max_value: Optional[int] = None + editable: bool = True + description: Optional[str] = None + category: Optional[str] = None + + +class BanSettingsResponse(BaseModel): + """All settings response.""" + settings: List[BanSettingDefinition] = [] + + +class BanSettingUpdateRequest(BaseModel): + """Request to update a setting.""" + value: Any + + +class BanWhitelistRequest(BaseModel): + """Request to add/remove from whitelist.""" + username: str = Field(..., min_length=1) + + +# === Reports === + +class BanReportTopViolator(BaseModel): + """Top violator in report.""" + username: str + count: int = 0 + + +class BanReportResponse(BaseModel): + """Period report response.""" + period_hours: int = 24 + current_users: int = 0 + current_ips: int = 0 + punishment_stats: Optional[Dict[str, Any]] = None + top_violators: List[BanReportTopViolator] = [] + + +# === Health === + +class BanHealthComponent(BaseModel): + """Health component status.""" + name: str + status: str # healthy, degraded, unhealthy + message: Optional[str] = None + details: Optional[Dict[str, Any]] = None + + +class BanHealthResponse(BaseModel): + """Health status response.""" + status: str # healthy, degraded, unhealthy + uptime: Optional[int] = None + components: List[BanHealthComponent] = [] + + +class BanHealthDetailedResponse(BaseModel): + """Detailed health response.""" + status: str + uptime: Optional[int] = None + components: Dict[str, Any] = {} + + +# === Agent History === + +class BanAgentHistoryItem(BaseModel): + """Agent history item.""" + timestamp: datetime + sent_total: int = 0 + dropped_total: int = 0 + queue_size: int = 0 + batches_total: int = 0 + + +class BanAgentHistoryResponse(BaseModel): + """Agent history response.""" + node: str + hours: int = 24 + records: int = 0 + delta: Optional[Dict[str, Any]] = None + first: Optional[Dict[str, Any]] = None + last: Optional[Dict[str, Any]] = None + history: List[BanAgentHistoryItem] = [] diff --git a/app/config.py b/app/config.py index 598c4b22..9cc227a3 100644 --- a/app/config.py +++ b/app/config.py @@ -243,11 +243,32 @@ class Settings(BaseSettings): MENU_LAYOUT_ENABLED: bool = False # Включить управление меню через API # Настройки мониторинга трафика - TRAFFIC_MONITORING_ENABLED: bool = False - TRAFFIC_THRESHOLD_GB_PER_DAY: float = 10.0 # Порог трафика в ГБ за сутки - TRAFFIC_MONITORING_INTERVAL_HOURS: int = 24 # Интервал проверки в часах (по умолчанию - раз в сутки) + TRAFFIC_MONITORING_ENABLED: bool = False # Глобальный переключатель (для обратной совместимости) + TRAFFIC_THRESHOLD_GB_PER_DAY: float = 10.0 # Порог трафика в ГБ за сутки (для обратной совместимости) + TRAFFIC_MONITORING_INTERVAL_HOURS: int = 24 # Интервал проверки в часах (для обратной совместимости) SUSPICIOUS_NOTIFICATIONS_TOPIC_ID: Optional[int] = None + # Новый мониторинг трафика v2 + # Быстрая проверка (текущий использованный трафик) + TRAFFIC_FAST_CHECK_ENABLED: bool = False + TRAFFIC_FAST_CHECK_INTERVAL_MINUTES: int = 10 # Интервал проверки в минутах + TRAFFIC_FAST_CHECK_THRESHOLD_GB: float = 5.0 # Порог в ГБ для быстрой проверки + + # Суточная проверка (трафик за 24 часа) + TRAFFIC_DAILY_CHECK_ENABLED: bool = False + TRAFFIC_DAILY_CHECK_TIME: str = "00:00" # Время суточной проверки (HH:MM) + TRAFFIC_DAILY_THRESHOLD_GB: float = 50.0 # Порог суточного трафика в ГБ + + # Фильтрация по серверам (UUID нод через запятую) + TRAFFIC_MONITORED_NODES: str = "" # Только эти ноды (пусто = все) + TRAFFIC_IGNORED_NODES: str = "" # Исключить эти ноды + TRAFFIC_EXCLUDED_USER_UUIDS: str = "" # Исключить пользователей (UUID через запятую) + + # Параллельность и кулдаун + TRAFFIC_CHECK_BATCH_SIZE: int = 1000 # Размер батча для получения пользователей + TRAFFIC_CHECK_CONCURRENCY: int = 10 # Параллельных запросов + TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES: int = 60 # Кулдаун уведомлений (минуты) + TRAFFIC_SNAPSHOT_TTL_HOURS: int = 24 # TTL для snapshot трафика в Redis (часы) # Настройки суточных подписок DAILY_SUBSCRIPTIONS_ENABLED: bool = True # Включить автоматическое списание для суточных тарифов DAILY_SUBSCRIPTIONS_CHECK_INTERVAL_MINUTES: int = 30 # Интервал проверки в минутах @@ -656,6 +677,12 @@ class Settings(BaseSettings): SMTP_FROM_NAME: str = "VPN Service" SMTP_USE_TLS: bool = True + # Ban System Integration (BedolagaBan monitoring) + BAN_SYSTEM_ENABLED: bool = False + BAN_SYSTEM_API_URL: Optional[str] = None # e.g., http://ban-server:8000 + BAN_SYSTEM_API_TOKEN: Optional[str] = None + BAN_SYSTEM_REQUEST_TIMEOUT: int = 30 + @field_validator('MAIN_MENU_MODE', mode='before') @classmethod def normalize_main_menu_mode(cls, value: Optional[str]) -> str: @@ -923,6 +950,41 @@ class Settings(BaseSettings): def get_remnawave_auto_sync_times(self) -> List[time]: return self.parse_daily_time_list(self.REMNAWAVE_AUTO_SYNC_TIMES) + def get_traffic_monitored_nodes(self) -> List[str]: + """Возвращает список UUID нод для мониторинга (пусто = все)""" + if not self.TRAFFIC_MONITORED_NODES: + return [] + # Убираем комментарии (все после #) + value = self.TRAFFIC_MONITORED_NODES.split("#")[0].strip() + if not value: + return [] + return [n.strip() for n in value.split(",") if n.strip()] + + def get_traffic_ignored_nodes(self) -> List[str]: + """Возвращает список UUID нод для исключения из мониторинга""" + if not self.TRAFFIC_IGNORED_NODES: + return [] + # Убираем комментарии (все после #) + value = self.TRAFFIC_IGNORED_NODES.split("#")[0].strip() + if not value: + return [] + return [n.strip() for n in value.split(",") if n.strip()] + + def get_traffic_excluded_user_uuids(self) -> List[str]: + """Возвращает список UUID пользователей для исключения из мониторинга (например, тунельные/служебные)""" + if not self.TRAFFIC_EXCLUDED_USER_UUIDS: + return [] + # Убираем комментарии (все после #) + value = self.TRAFFIC_EXCLUDED_USER_UUIDS.split("#")[0].strip() + if not value: + return [] + return [uuid.strip().lower() for uuid in value.split(",") if uuid.strip()] + + def get_traffic_daily_check_time(self) -> Optional[time]: + """Возвращает время суточной проверки трафика""" + times = self.parse_daily_time_list(self.TRAFFIC_DAILY_CHECK_TIME) + return times[0] if times else None + def get_display_name_banned_keywords(self) -> List[str]: raw_value = self.DISPLAY_NAME_BANNED_KEYWORDS if raw_value is None: @@ -2334,6 +2396,24 @@ class Settings(BaseSettings): return self.SMTP_FROM_EMAIL return self.SMTP_USER + # Ban System helpers + def is_ban_system_enabled(self) -> bool: + return bool(self.BAN_SYSTEM_ENABLED) + + def is_ban_system_configured(self) -> bool: + return bool(self.BAN_SYSTEM_API_URL and self.BAN_SYSTEM_API_TOKEN) + + def get_ban_system_api_url(self) -> Optional[str]: + if self.BAN_SYSTEM_API_URL: + return self.BAN_SYSTEM_API_URL.rstrip('/') + return None + + def get_ban_system_api_token(self) -> Optional[str]: + return self.BAN_SYSTEM_API_TOKEN + + def get_ban_system_request_timeout(self) -> int: + return max(1, self.BAN_SYSTEM_REQUEST_TIMEOUT) + model_config = { "env_file": ".env", "env_file_encoding": "utf-8", diff --git a/app/external/ban_system_api.py b/app/external/ban_system_api.py new file mode 100644 index 00000000..c8789ab6 --- /dev/null +++ b/app/external/ban_system_api.py @@ -0,0 +1,415 @@ +""" +Ban System API Client. + +Client for interacting with the BedolagaBan monitoring system. +""" +import asyncio +import logging +from datetime import datetime +from typing import Any, Dict, List, Optional + +import aiohttp + +logger = logging.getLogger(__name__) + + +class BanSystemAPIError(Exception): + """Ban System API error.""" + + def __init__(self, message: str, status_code: Optional[int] = None, response_data: Optional[dict] = None): + self.message = message + self.status_code = status_code + self.response_data = response_data + super().__init__(self.message) + + +class BanSystemAPI: + """HTTP client for Ban System API.""" + + def __init__(self, base_url: str, api_token: str, timeout: int = 30): + self.base_url = base_url.rstrip('/') + self.api_token = api_token + self.timeout = aiohttp.ClientTimeout(total=timeout) + self.session: Optional[aiohttp.ClientSession] = None + + def _get_headers(self) -> Dict[str, str]: + """Get request headers with authorization.""" + return { + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + async def __aenter__(self): + """Async context manager entry.""" + self.session = aiohttp.ClientSession( + timeout=self.timeout, + headers=self._get_headers() + ) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit.""" + if self.session: + await self.session.close() + self.session = None + + async def _ensure_session(self): + """Ensure session is created.""" + if self.session is None: + self.session = aiohttp.ClientSession( + timeout=self.timeout, + headers=self._get_headers() + ) + + async def _request( + self, + method: str, + endpoint: str, + params: Optional[Dict] = None, + json_data: Optional[Dict] = None, + ) -> Any: + """Execute HTTP request.""" + await self._ensure_session() + + url = f"{self.base_url}{endpoint}" + + try: + async with self.session.request( + method=method, + url=url, + params=params, + json=json_data, + ) as response: + response_text = await response.text() + + if response.status >= 400: + logger.error(f"Ban System API error: {response.status} - {response_text}") + raise BanSystemAPIError( + message=f"API error {response.status}: {response_text}", + status_code=response.status, + response_data={"error": response_text} + ) + + if response_text: + try: + return await response.json() + except Exception: + return {"raw": response_text} + return {} + + except aiohttp.ClientError as e: + logger.error(f"Ban System API connection error: {e}") + raise BanSystemAPIError( + message=f"Connection error: {str(e)}", + status_code=None, + response_data=None + ) + except asyncio.TimeoutError: + logger.error("Ban System API request timeout") + raise BanSystemAPIError( + message="Request timeout", + status_code=None, + response_data=None + ) + + async def close(self): + """Close the session.""" + if self.session: + await self.session.close() + self.session = None + + # === Stats === + + async def get_stats(self) -> Dict[str, Any]: + """ + Get overall system statistics. + + GET /api/stats + """ + return await self._request("GET", "/api/stats") + + async def get_stats_period(self, hours: int = 24) -> Dict[str, Any]: + """ + Get statistics for a specific period. + + GET /api/stats/period?hours={hours} + """ + return await self._request("GET", "/api/stats/period", params={"hours": hours}) + + # === Users === + + async def get_users( + self, + offset: int = 0, + limit: int = 50, + status: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Get list of users with pagination. + + GET /api/users + + Args: + offset: Pagination offset + limit: Number of users per page (max 100) + status: Filter by status (over_limit, with_limit, unlimited) + """ + params = {"offset": offset, "limit": min(limit, 100)} + if status: + params["status"] = status + return await self._request("GET", "/api/users", params=params) + + async def get_users_over_limit(self, limit: int = 50, window: bool = True) -> Dict[str, Any]: + """ + Get users who exceeded their device limit. + + GET /api/users/over-limit + """ + return await self._request( + "GET", + "/api/users/over-limit", + params={"limit": limit, "window": str(window).lower()} + ) + + async def search_users(self, query: str) -> Dict[str, Any]: + """ + Search for a user. + + GET /api/users/search/{query} + """ + return await self._request("GET", f"/api/users/search/{query}") + + async def get_user(self, email: str) -> Dict[str, Any]: + """ + Get detailed user information. + + GET /api/users/{email} + """ + return await self._request("GET", f"/api/users/{email}") + + async def get_user_network(self, email: str) -> Dict[str, Any]: + """ + Get user network information (WiFi/Mobile detection). + + GET /api/users/{email}/network + """ + return await self._request("GET", f"/api/users/{email}/network") + + # === Punishments (Bans) === + + async def get_punishments(self) -> List[Dict[str, Any]]: + """ + Get list of active punishments (bans). + + GET /api/punishments + """ + return await self._request("GET", "/api/punishments") + + async def enable_user(self, user_id: str) -> Dict[str, Any]: + """ + Enable (unban) a user. + + POST /api/punishments/{user_id}/enable + """ + return await self._request("POST", f"/api/punishments/{user_id}/enable") + + async def ban_user( + self, + username: str, + minutes: int = 30, + reason: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Manually ban a user. + + POST /api/ban + """ + params = {"username": username, "minutes": minutes} + if reason: + params["reason"] = reason + return await self._request("POST", "/api/ban", params=params) + + async def get_punishment_history(self, query: str, limit: int = 20) -> List[Dict[str, Any]]: + """ + Get punishment history for a user. + + GET /api/history/{query} + """ + return await self._request( + "GET", + f"/api/history/{query}", + params={"limit": limit} + ) + + # === Nodes === + + async def get_nodes(self, include_agent_stats: bool = True) -> List[Dict[str, Any]]: + """ + Get list of connected nodes. + + GET /api/nodes + """ + return await self._request( + "GET", + "/api/nodes", + params={"include_agent_stats": str(include_agent_stats).lower()} + ) + + # === Agents === + + async def get_agents( + self, + search: Optional[str] = None, + health: Optional[str] = None, + status: Optional[str] = None, + sort_by: str = "name", + sort_order: str = "asc", + ) -> Dict[str, Any]: + """ + Get list of monitoring agents. + + GET /api/agents + + Args: + search: Search query + health: Filter by health (healthy, warning, critical) + status: Filter by status (online, offline) + sort_by: Sort by field (name, sent, dropped, health) + sort_order: Sort order (asc, desc) + """ + params = {"sort_by": sort_by, "sort_order": sort_order} + if search: + params["search"] = search + if health: + params["health"] = health + if status: + params["status"] = status + return await self._request("GET", "/api/agents", params=params) + + async def get_agents_summary(self) -> Dict[str, Any]: + """ + Get summary statistics for all agents. + + GET /api/agents/summary + """ + return await self._request("GET", "/api/agents/summary") + + async def get_agent_history( + self, + node_name: str, + hours: int = 24, + limit: int = 50, + ) -> List[Dict[str, Any]]: + """ + Get agent statistics history. + + GET /api/agents/{node_name}/history + """ + return await self._request( + "GET", + f"/api/agents/{node_name}/history", + params={"hours": hours, "limit": limit} + ) + + # === Traffic === + + async def get_traffic(self) -> Dict[str, Any]: + """ + Get overall traffic statistics. + + GET /api/traffic + """ + return await self._request("GET", "/api/traffic") + + async def get_traffic_top(self, limit: int = 20) -> List[Dict[str, Any]]: + """ + Get top users by traffic. + + GET /api/traffic/top + """ + return await self._request("GET", "/api/traffic/top", params={"limit": limit}) + + async def get_user_traffic(self, username: str) -> Dict[str, Any]: + """ + Get traffic information for a specific user. + + GET /api/traffic/user/{username} + """ + return await self._request("GET", f"/api/traffic/user/{username}") + + async def get_traffic_violations(self, limit: int = 50) -> List[Dict[str, Any]]: + """ + Get list of traffic limit violations. + + GET /api/traffic/violations + """ + return await self._request("GET", "/api/traffic/violations", params={"limit": limit}) + + # === Health === + + async def health_check(self) -> Dict[str, Any]: + """ + Check API health. + + GET /health + """ + return await self._request("GET", "/health") + + async def health_detailed(self) -> Dict[str, Any]: + """ + Get detailed health information. + + GET /health/detailed + """ + return await self._request("GET", "/health/detailed") + + # === Settings === + + async def get_settings(self) -> Dict[str, Any]: + """ + Get all settings with their definitions. + + GET /api/settings + """ + return await self._request("GET", "/api/settings") + + async def get_setting(self, key: str) -> Dict[str, Any]: + """ + Get a specific setting value. + + GET /api/settings/{key} + """ + return await self._request("GET", f"/api/settings/{key}") + + async def set_setting(self, key: str, value: Any) -> Dict[str, Any]: + """ + Set a setting value. + + POST /api/settings/{key}?value={value} + """ + return await self._request("POST", f"/api/settings/{key}", params={"value": value}) + + async def toggle_setting(self, key: str) -> Dict[str, Any]: + """ + Toggle a boolean setting. + + POST /api/settings/{key}/toggle + """ + return await self._request("POST", f"/api/settings/{key}/toggle") + + async def whitelist_add(self, username: str) -> Dict[str, Any]: + """ + Add user to whitelist. + + POST /api/settings/whitelist/add?username={username} + """ + return await self._request("POST", "/api/settings/whitelist/add", params={"username": username}) + + async def whitelist_remove(self, username: str) -> Dict[str, Any]: + """ + Remove user from whitelist. + + POST /api/settings/whitelist/remove?username={username} + """ + return await self._request("POST", "/api/settings/whitelist/remove", params={"username": username}) diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index fff926ae..178d3e0c 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -774,81 +774,53 @@ async def force_check_callback(callback: CallbackQuery): @router.callback_query(F.data == "admin_mon_traffic_check") @admin_required async def traffic_check_callback(callback: CallbackQuery): - """Ручная проверка трафика всех пользователей.""" + """Ручная проверка трафика — использует snapshot и дельту.""" try: # Проверяем, включен ли мониторинг трафика if not traffic_monitoring_scheduler.is_enabled(): await callback.answer( "⚠️ Мониторинг трафика отключен в настройках\n" - "Включите TRAFFIC_MONITORING_ENABLED=true в .env", + "Включите TRAFFIC_FAST_CHECK_ENABLED=true в .env", show_alert=True ) return - await callback.answer("⏳ Запускаем проверку трафика...") + await callback.answer("⏳ Запускаем проверку трафика (дельта)...") + + # Используем run_fast_check — он сравнивает с snapshot и отправляет уведомления + from app.services.traffic_monitoring_service import traffic_monitoring_scheduler_v2 # Устанавливаем бота, если не установлен - if not traffic_monitoring_scheduler.bot: - traffic_monitoring_scheduler.set_bot(callback.bot) + if not traffic_monitoring_scheduler_v2.bot: + traffic_monitoring_scheduler_v2.set_bot(callback.bot) - checked_count = 0 - exceeded_count = 0 - exceeded_users = [] + violations = await traffic_monitoring_scheduler_v2.run_fast_check_now() - async for db in get_db(): - from app.database.crud.user import get_users_with_active_subscriptions - - users = await get_users_with_active_subscriptions(db) - - for user in users: - if user.remnawave_uuid: - is_exceeded, traffic_info = await traffic_monitoring_service.check_user_traffic_threshold( - db, - user.remnawave_uuid, - user.telegram_id - ) - checked_count += 1 - - if is_exceeded: - exceeded_count += 1 - total_gb = traffic_info.get('total_gb', 0) - exceeded_users.append({ - 'telegram_id': user.telegram_id, - 'name': user.full_name or str(user.telegram_id), - 'traffic_gb': total_gb - }) - - # Отправляем уведомление админам - if traffic_monitoring_scheduler._should_send_notification(user.remnawave_uuid): - await traffic_monitoring_service.process_suspicious_traffic( - db, - user.remnawave_uuid, - traffic_info, - callback.bot - ) - traffic_monitoring_scheduler._record_notification(user.remnawave_uuid) - - break - - threshold_gb = settings.TRAFFIC_THRESHOLD_GB_PER_DAY + # Получаем информацию о snapshot + snapshot_age = await traffic_monitoring_scheduler_v2.service.get_snapshot_age_minutes() + threshold_gb = traffic_monitoring_scheduler_v2.service.get_fast_check_threshold_gb() text = f""" 📊 Проверка трафика завершена -🔍 Результаты: -• Проверено пользователей: {checked_count} -• Превышений порога: {exceeded_count} -• Порог: {threshold_gb} ГБ/сутки +🔍 Результаты (дельта): +• Превышений за интервал: {len(violations)} +• Порог дельты: {threshold_gb} ГБ +• Возраст snapshot: {snapshot_age:.1f} мин 🕐 Время проверки: {datetime.now().strftime('%H:%M:%S')} """ - if exceeded_users: - text += "\n⚠️ Пользователи с превышением:\n" - for u in exceeded_users[:10]: - text += f"• {u['name']}: {u['traffic_gb']:.1f} ГБ\n" - if len(exceeded_users) > 10: - text += f"... и ещё {len(exceeded_users) - 10}\n" + if violations: + text += "\n⚠️ Превышения дельты:\n" + for v in violations[:10]: + name = v.full_name or v.user_uuid[:8] + text += f"• {name}: +{v.used_traffic_gb:.1f} ГБ\n" + if len(violations) > 10: + text += f"... и ещё {len(violations) - 10}\n" + text += "\n📨 Уведомления отправлены (с учётом кулдауна)" + else: + text += "\n✅ Превышений не обнаружено" from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton keyboard = InlineKeyboardMarkup(inline_keyboard=[ diff --git a/app/middlewares/display_name_restriction.py b/app/middlewares/display_name_restriction.py index 04bd1e58..3826f244 100644 --- a/app/middlewares/display_name_restriction.py +++ b/app/middlewares/display_name_restriction.py @@ -131,12 +131,36 @@ class DisplayNameRestrictionMiddleware(BaseMiddleware): cleaned = ZERO_WIDTH_PATTERN.sub("", value) lower_value = cleaned.lower() - # Убраны жёсткие проверки на @ и паттерны ссылок - слишком много ложных срабатываний - # Теперь проверяем только по настраиваемым ключевым словам из DISPLAY_NAME_BANNED_KEYWORDS + if "@" in cleaned or "@" in cleaned: + return True + + if any(pattern.search(lower_value) for pattern in LINK_PATTERNS): + return True + + # Проверяем обфусцированные ссылки типа "t . m e" или "т м е" + # Но НЕ блокируем если это часть обычного слова/имени + domain_match = DOMAIN_OBFUSCATION_PATTERN.search(lower_value) + if domain_match: + # Проверяем контекст: если "tme" внутри слова (с буквами с обеих сторон) - пропускаем + start_pos = domain_match.start() + end_pos = domain_match.end() + + # Проверяем символ ДО и ПОСЛЕ совпадения + has_letter_before = start_pos > 0 and lower_value[start_pos - 1].isalpha() + has_letter_after = end_pos < len(lower_value) and lower_value[end_pos].isalpha() + + # Если с ОБЕИХ сторон буквы - скорее всего это просто имя/фамилия + if not (has_letter_before and has_letter_after): + return True normalized = self._normalize_text(lower_value) collapsed = COLLAPSE_PATTERN.sub("", normalized) + # Проверяем "tme" с контекстом (ловим t.me ссылки, но не случайные совпадения в именах) + # Ищем tme в начале, конце, или с пробелами/спецсимволами вокруг + if re.search(r"(?:^|[^a-zа-яё])tme(?:[^a-zа-яё]|$)", collapsed, re.IGNORECASE): + return True + banned_keywords = settings.get_display_name_banned_keywords() # Если список пустой - не блокируем никого diff --git a/app/services/payment/cloudpayments.py b/app/services/payment/cloudpayments.py index e0d9bca0..dfad134f 100644 --- a/app/services/payment/cloudpayments.py +++ b/app/services/payment/cloudpayments.py @@ -267,7 +267,10 @@ class CloudPaymentsPaymentMixin: # Умная автоактивация если автопокупка не сработала if not auto_purchase_success: try: - await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None)) + # Игнорируем notification_sent т.к. здесь нет дополнительных уведомлений + await auto_activate_subscription_after_topup( + db, user, bot=getattr(self, "bot", None), topup_amount=amount_kopeks + ) except Exception as error: logger.exception("Ошибка умной автоактивации после CloudPayments: %s", error) diff --git a/app/services/payment/common.py b/app/services/payment/common.py index 61422fe7..82831681 100644 --- a/app/services/payment/common.py +++ b/app/services/payment/common.py @@ -141,19 +141,75 @@ class PaymentCommonMixin: ) try: - keyboard = await self.build_topup_success_keyboard(user_snapshot) - payment_method = payment_method_title or "Банковская карта (YooKassa)" - message = ( - "✅ Платеж успешно завершен!\n\n" - f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" - f"💳 Способ: {payment_method}\n\n" - "Средства зачислены на ваш баланс!\n\n" - "⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " - "Обязательно активируйте подписку отдельно!\n\n" - f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " - f"подписка будет приобретена автоматически после пополнения баланса." - ) + + # Проверяем, нужно ли показывать яркое предупреждение об активации + if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: + # Определяем статус подписки для выбора правильной кнопки + has_active_subscription = False + if user_snapshot: + try: + subscription = user_snapshot.subscription + has_active_subscription = bool( + subscription + and not getattr(subscription, "is_trial", False) + and getattr(subscription, "is_active", False) + ) + except Exception: + pass + + # Яркое сообщение с восклицательными знаками + message = ( + "✅ Платеж успешно завершен!\n\n" + f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" + f"💳 Способ: {payment_method}\n\n" + "💎 Средства зачислены на ваш баланс!\n\n" + "‼️ ВНИМАНИЕ! ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ! ‼️\n\n" + "⚠️ Пополнение баланса НЕ АКТИВИРУЕТ подписку автоматически!\n\n" + "👇 НАЖМИТЕ КНОПКУ НИЖЕ ДЛЯ АКТИВАЦИИ 👇" + ) + + # Формируем клавиатуру с кнопками действий + keyboard_rows: list[list[InlineKeyboardButton]] = [] + + # Кнопка активации или продления в зависимости от статуса + if has_active_subscription: + # Активная платная подписка - показываем продление и изменение устройств + keyboard_rows.append([ + build_miniapp_or_callback_button( + text="🔄 ПРОДЛИТЬ ПОДПИСКУ", + callback_data="subscription_extend", + ) + ]) + keyboard_rows.append([ + build_miniapp_or_callback_button( + text="📱 Изменить количество устройств", + callback_data="subscription_change_devices", + ) + ]) + else: + # Нет подписки или истекла - показываем только активацию + keyboard_rows.append([ + build_miniapp_or_callback_button( + text="🔥 АКТИВИРОВАТЬ ПОДПИСКУ", + callback_data="menu_buy", + ) + ]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + else: + # Стандартное сообщение с полной клавиатурой + keyboard = await self.build_topup_success_keyboard(user_snapshot) + message = ( + "✅ Платеж успешно завершен!\n\n" + f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" + f"💳 Способ: {payment_method}\n\n" + "Средства зачислены на ваш баланс!\n\n" + "⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " + "Обязательно активируйте подписку отдельно!\n\n" + f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " + f"подписка будет приобретена автоматически после пополнения баланса." + ) await self.bot.send_message( chat_id=telegram_id, diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py index 95855c56..2252b6a5 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -377,12 +377,14 @@ class CryptoBotPaymentMixin: has_saved_cart = False # Умная автоактивация если автопокупка не сработала + activation_notification_sent = False if not auto_purchase_success: try: - await auto_activate_subscription_after_topup( + _, activation_notification_sent = await auto_activate_subscription_after_topup( db, user, bot=bot_instance, + topup_amount=amount_kopeks, ) except Exception as auto_activate_error: logger.error( @@ -392,7 +394,8 @@ class CryptoBotPaymentMixin: exc_info=True, ) - if has_saved_cart and bot_instance: + # Отправляем уведомление только если его ещё не отправили + if has_saved_cart and bot_instance and not activation_notification_sent: from app.localization.texts import get_texts texts = get_texts(user.language) diff --git a/app/services/payment/freekassa.py b/app/services/payment/freekassa.py index b6e13fb2..1dafabbf 100644 --- a/app/services/payment/freekassa.py +++ b/app/services/payment/freekassa.py @@ -411,9 +411,12 @@ class FreekassaPaymentMixin: has_saved_cart = False # Умная автоактивация если автопокупка не сработала + activation_notification_sent = False if not auto_purchase_success: try: - await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None)) + _, activation_notification_sent = await auto_activate_subscription_after_topup( + db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks + ) except Exception as auto_activate_error: logger.error( "Ошибка умной автоактивации для пользователя %s: %s", @@ -422,7 +425,8 @@ class FreekassaPaymentMixin: exc_info=True, ) - if has_saved_cart and getattr(self, "bot", None): + # Отправляем уведомление только если его ещё не отправили + if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent: from app.localization.texts import get_texts texts = get_texts(user.language) diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index 3d1705d6..9dd3b8c7 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -396,9 +396,12 @@ class MulenPayPaymentMixin: has_saved_cart = False # Умная автоактивация если автопокупка не сработала + activation_notification_sent = False if not auto_purchase_success: try: - await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None)) + _, activation_notification_sent = await auto_activate_subscription_after_topup( + db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks + ) except Exception as auto_activate_error: logger.error( "Ошибка умной автоактивации для пользователя %s: %s", @@ -407,7 +410,8 @@ class MulenPayPaymentMixin: exc_info=True, ) - if has_saved_cart and getattr(self, "bot", None): + # Отправляем уведомление только если его ещё не отправили + if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent: # Если у пользователя есть сохраненная корзина, # отправляем ему уведомление с кнопкой вернуться к оформлению from app.localization.texts import get_texts diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 1086c63b..3000155c 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -499,9 +499,12 @@ class Pal24PaymentMixin: has_saved_cart = False # Умная автоактивация если автопокупка не сработала + activation_notification_sent = False if not auto_purchase_success: try: - await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None)) + _, activation_notification_sent = await auto_activate_subscription_after_topup( + db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks + ) except Exception as auto_activate_error: logger.error( "Ошибка умной автоактивации для пользователя %s: %s", @@ -510,7 +513,8 @@ class Pal24PaymentMixin: exc_info=True, ) - if has_saved_cart and getattr(self, "bot", None): + # Отправляем уведомление только если его ещё не отправили + if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent: from app.localization.texts import get_texts texts = get_texts(user.language) diff --git a/app/services/payment/platega.py b/app/services/payment/platega.py index 3149d0bb..eb6d5501 100644 --- a/app/services/payment/platega.py +++ b/app/services/payment/platega.py @@ -485,9 +485,12 @@ class PlategaPaymentMixin: has_saved_cart = False # Умная автоактивация если автопокупка не сработала + activation_notification_sent = False if not auto_purchase_success: try: - await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None)) + _, activation_notification_sent = await auto_activate_subscription_after_topup( + db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks + ) except Exception as auto_activate_error: logger.error( "Ошибка умной автоактивации для пользователя %s: %s", @@ -496,7 +499,8 @@ class PlategaPaymentMixin: exc_info=True, ) - if has_saved_cart and getattr(self, "bot", None): + # Отправляем уведомление только если его ещё не отправили + if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent: from app.localization.texts import get_texts texts = get_texts(user.language) diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index a5cff2e7..5cf2841e 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -534,12 +534,14 @@ class TelegramStarsMixin: has_saved_cart = False # Умная автоактивация если автопокупка не сработала + activation_notification_sent = False if not auto_purchase_success: try: - await auto_activate_subscription_after_topup( + _, activation_notification_sent = await auto_activate_subscription_after_topup( db, user, bot=getattr(self, "bot", None), + topup_amount=amount_kopeks, ) except Exception as auto_activate_error: logger.error( @@ -549,7 +551,8 @@ class TelegramStarsMixin: exc_info=True, ) - if has_saved_cart and getattr(self, "bot", None): + # Отправляем уведомление только если его ещё не отправили + if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent: texts = get_texts(user.language) cart_message = texts.t( "BALANCE_TOPUP_CART_REMINDER_DETAILED", diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py index 44d03369..ebaf2db8 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -569,9 +569,12 @@ class WataPaymentMixin: has_saved_cart = False # Умная автоактивация если автопокупка не сработала + activation_notification_sent = False if not auto_purchase_success: try: - await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None)) + _, activation_notification_sent = await auto_activate_subscription_after_topup( + db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks + ) except Exception as auto_activate_error: logger.error( "Ошибка умной автоактивации для пользователя %s: %s", @@ -580,7 +583,8 @@ class WataPaymentMixin: exc_info=True, ) - if has_saved_cart and getattr(self, "bot", None): + # Отправляем уведомление только если его ещё не отправили + if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent: from app.localization.texts import get_texts texts = get_texts(user.language) diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index f996bf61..34204e53 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -848,58 +848,61 @@ class YooKassaPaymentMixin: exc_info=True, ) - if has_saved_cart and getattr(self, "bot", None): - # Если у пользователя есть сохраненная корзина, - # отправляем ему уведомление с кнопкой вернуться к оформлению - from app.localization.texts import get_texts - from aiogram import types + # Если включен яркий промпт активации, пропускаем старое уведомление + # т.к. оно будет отправлено через _send_payment_success_notification + if not settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: + if has_saved_cart and getattr(self, "bot", None): + # Если у пользователя есть сохраненная корзина, + # отправляем ему уведомление с кнопкой вернуться к оформлению + from app.localization.texts import get_texts + from aiogram import types - texts = get_texts(user.language) - cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format( - total_amount=settings.format_price(payment.amount_kopeks) - ) + texts = get_texts(user.language) + cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format( + total_amount=settings.format_price(payment.amount_kopeks) + ) - # Создаем клавиатуру с кнопками - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="return_to_saved_cart", - ) - ], - [ - types.InlineKeyboardButton( - text="💰 Мой баланс", - callback_data="menu_balance", - ) - ], - [ - types.InlineKeyboardButton( - text="🏠 Главное меню", - callback_data="back_to_menu", - ) - ], - ] - ) + # Создаем клавиатуру с кнопками + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data="return_to_saved_cart", + ) + ], + [ + types.InlineKeyboardButton( + text="💰 Мой баланс", + callback_data="menu_balance", + ) + ], + [ + types.InlineKeyboardButton( + text="🏠 Главное меню", + callback_data="back_to_menu", + ) + ], + ] + ) - await self.bot.send_message( - chat_id=user.telegram_id, - text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n" - f"⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " - f"Обязательно активируйте подписку отдельно!\n\n" - f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " - f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}", - reply_markup=keyboard, - ) - logger.info( - f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}" - ) - else: - logger.info( - "У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена", - user.id, - ) + await self.bot.send_message( + chat_id=user.telegram_id, + text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n" + f"⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " + f"Обязательно активируйте подписку отдельно!\n\n" + f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " + f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}", + reply_markup=keyboard, + ) + logger.info( + f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}" + ) + else: + logger.info( + "У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена", + user.id, + ) except Exception as e: logger.error( f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 0f4bf295..57ae9316 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -717,7 +717,8 @@ async def auto_activate_subscription_after_topup( user: User, *, bot: Optional[Bot] = None, -) -> bool: + topup_amount: Optional[int] = None, +) -> tuple[bool, bool]: """ Умная автоактивация после пополнения баланса. @@ -727,6 +728,14 @@ async def auto_activate_subscription_after_topup( - Если подписки нет — создаёт новую с дефолтными параметрами Выбирает максимальный период, который можно оплатить из баланса. + + Args: + topup_amount: Сумма пополнения в копейках (для отображения в уведомлении) + + Returns: + tuple[bool, bool]: (success, notification_sent) + - success: True если подписка активирована + - notification_sent: True если уведомление отправлено пользователю """ from datetime import datetime from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription @@ -739,70 +748,25 @@ async def auto_activate_subscription_after_topup( from app.services.admin_notification_service import AdminNotificationService if not user or not getattr(user, "id", None): - return False + return (False, False) subscription = await get_subscription_by_user_id(db, user.id) - # Если автоактивация отключена - только отправляем предупреждение + # Если автоактивация отключена - уведомление отправится из _send_payment_success_notification if not settings.is_auto_activate_after_topup_enabled(): - # Отправляем предупреждение если включен режим и нет активной подписки - if ( - settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP - and bot - and (not subscription or subscription.status not in ("active", "ACTIVE")) - ): - try: - texts = get_texts(getattr(user, "language", "ru")) - warning_message = ( - f"✅ Баланс пополнен!\n\n" - f"💳 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n" - f"{'─' * 25}\n\n" - f"⚠️ ВАЖНО! ⚠️\n\n" - f"🔴 ПОДПИСКА НЕ АКТИВНА!\n\n" - f"Пополнение баланса НЕ активирует подписку автоматически!\n\n" - f"👇 Выберите действие:" - ) - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton( - text="🚀 АКТИВИРОВАТЬ ПОДПИСКУ", - callback_data="subscription_buy", - )], - [InlineKeyboardButton( - text="💎 ПРОДЛИТЬ ПОДПИСКУ", - callback_data="subscription_extend", - )], - [InlineKeyboardButton( - text="📱 ДОБАВИТЬ УСТРОЙСТВА", - callback_data="subscription_add_devices", - )], - ] - ) - await bot.send_message( - chat_id=user.telegram_id, - text=warning_message, - reply_markup=keyboard, - parse_mode="HTML", - ) - logger.info( - "⚠️ Отправлено предупреждение об активации подписки пользователю %s (автоактивация выключена)", - user.telegram_id, - ) - except Exception as notify_error: - logger.warning( - "⚠️ Не удалось отправить предупреждение пользователю %s: %s", - user.telegram_id, - notify_error, - ) - return False + logger.info( + "⚠️ Автоактивация отключена для пользователя %s, уведомление будет отправлено из payment service", + user.telegram_id, + ) + return (False, False) - # Если подписка активна — ничего не делаем + # Если подписка активна — ничего не делаем (автоактивация включена, но подписка уже есть) if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow(): logger.info( "🔁 Автоактивация: у пользователя %s уже активная подписка, пропускаем", user.telegram_id, ) - return False + return (False, False) # Определяем параметры подписки if subscription: @@ -839,7 +803,7 @@ async def auto_activate_subscription_after_topup( if not available_periods: logger.warning("🔁 Автоактивация: нет доступных периодов подписки") - return False + return (False, False) subscription_service = SubscriptionService() @@ -875,56 +839,12 @@ async def auto_activate_subscription_after_topup( user.telegram_id, balance, ) - # Отправляем предупреждение пользователю если включен режим и подписки нет - if ( - settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP - and bot - and (not subscription or subscription.status not in ("active", "ACTIVE")) - ): - try: - texts = get_texts(getattr(user, "language", "ru")) - warning_message = ( - f"✅ Баланс пополнен!\n\n" - f"💳 Текущий баланс: {settings.format_price(balance)}\n\n" - f"{'─' * 25}\n\n" - f"⚠️ ВАЖНО! ⚠️\n\n" - f"🔴 ПОДПИСКА НЕ АКТИВНА!\n\n" - f"Пополнение баланса НЕ активирует подписку автоматически!\n\n" - f"👇 Выберите действие:" - ) - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton( - text="🚀 АКТИВИРОВАТЬ ПОДПИСКУ", - callback_data="subscription_buy", - )], - [InlineKeyboardButton( - text="💎 ПРОДЛИТЬ ПОДПИСКУ", - callback_data="subscription_extend", - )], - [InlineKeyboardButton( - text="📱 ДОБАВИТЬ УСТРОЙСТВА", - callback_data="subscription_add_devices", - )], - ] - ) - await bot.send_message( - chat_id=user.telegram_id, - text=warning_message, - reply_markup=keyboard, - parse_mode="HTML", - ) - logger.info( - "⚠️ Отправлено предупреждение об активации подписки пользователю %s", - user.telegram_id, - ) - except Exception as notify_error: - logger.warning( - "⚠️ Не удалось отправить предупреждение пользователю %s: %s", - user.telegram_id, - notify_error, - ) - return False + # Уведомление отправится из _send_payment_success_notification + logger.info( + "⚠️ Недостаточно средств для автоактивации пользователя %s, уведомление будет отправлено из payment service", + user.telegram_id, + ) + return (False, False) texts = get_texts(getattr(user, "language", "ru")) @@ -1085,7 +1005,7 @@ async def auto_activate_subscription_after_topup( notify_error, ) - return True + return (True, True) # success=True, notification_sent=True (об активации) except Exception as e: logger.error( @@ -1094,6 +1014,7 @@ async def auto_activate_subscription_after_topup( e, exc_info=True, ) + return (False, False) await db.rollback() return False diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 31d39da3..1f1187b0 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -259,6 +259,7 @@ class BotConfigurationService: "PAYMENT_BALANCE_TEMPLATE": "PAYMENT", "PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT", "AUTO_PURCHASE_AFTER_TOPUP_ENABLED": "PAYMENT", + "SHOW_ACTIVATION_PROMPT_AFTER_TOPUP": "PAYMENT", "SIMPLE_SUBSCRIPTION_ENABLED": "SIMPLE_SUBSCRIPTION", "SIMPLE_SUBSCRIPTION_PERIOD_DAYS": "SIMPLE_SUBSCRIPTION", "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT": "SIMPLE_SUBSCRIPTION", @@ -271,6 +272,10 @@ class BotConfigurationService: "NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS", "MONITORING_LOGS_RETENTION_DAYS": "MONITORING", "MONITORING_INTERVAL": "MONITORING", + "TRAFFIC_MONITORING_ENABLED": "MONITORING", + "TRAFFIC_MONITORING_INTERVAL_HOURS": "MONITORING", + "TRAFFIC_MONITORED_NODES": "MONITORING", + "TRAFFIC_SNAPSHOT_TTL_HOURS": "MONITORING", "ENABLE_LOGO_MODE": "INTERFACE_BRANDING", "LOGO_FILE": "INTERFACE_BRANDING", "HIDE_SUBSCRIPTION_LINK": "INTERFACE_SUBSCRIPTION", @@ -570,6 +575,19 @@ class BotConfigurationService: "Используйте с осторожностью: средства будут списаны мгновенно, если корзина найдена." ), }, + "SHOW_ACTIVATION_PROMPT_AFTER_TOPUP": { + "description": ( + "Включает режим яркого промпта активации подписки после пополнения баланса. " + "Вместо обычного уведомления пользователь получит яркое сообщение с восклицательными знаками " + "и кнопками для активации/продления подписки или изменения количества устройств." + ), + "format": "Булево значение.", + "example": "true", + "warning": ( + "При включении пользователи будут получать только яркое уведомление без кнопок баланса и главного меню. " + "Эти кнопки появятся после выполнения действия (активация/продление/изменение устройств)." + ), + }, "SUPPORT_TICKET_SLA_MINUTES": { "description": "Лимит времени для ответа модераторов на тикет в минутах.", "format": "Целое число от 1 до 1440.", @@ -710,6 +728,58 @@ class BotConfigurationService: "warning": "Убедитесь, что конфигурация существует в панели и содержит нужные приложения.", "dependencies": "Настроенное подключение к RemnaWave API", }, + "TRAFFIC_MONITORING_ENABLED": { + "description": ( + "Включает автоматический мониторинг трафика пользователей. " + "Система отслеживает изменения трафика (дельту) и сохраняет snapshot в Redis. " + "При превышении порогов отправляются уведомления пользователям и админам." + ), + "format": "Булево значение.", + "example": "true", + "warning": ( + "Требует настроенного подключения к Redis. " + "При включении будет запущен фоновый мониторинг трафика по расписанию." + ), + "dependencies": "Redis, TRAFFIC_MONITORING_INTERVAL_HOURS, TRAFFIC_SNAPSHOT_TTL_HOURS", + }, + "TRAFFIC_MONITORING_INTERVAL_HOURS": { + "description": ( + "Интервал проверки трафика в часах. " + "Каждые N часов система проверяет трафик всех активных пользователей и сравнивает с предыдущим snapshot." + ), + "format": "Целое число часов (минимум 1).", + "example": "24", + "warning": ( + "Слишком маленький интервал может создать большую нагрузку на RemnaWave API. " + "Рекомендуется 24 часа для ежедневного мониторинга." + ), + "dependencies": "TRAFFIC_MONITORING_ENABLED", + }, + "TRAFFIC_MONITORED_NODES": { + "description": ( + "Список UUID нод для мониторинга трафика через запятую. " + "Если пусто - мониторятся все ноды. " + "Позволяет ограничить мониторинг только определенными серверами." + ), + "format": "UUID через запятую или пусто для всех нод.", + "example": "d4aa2b8c-9a36-4f31-93a2-6f07dad05fba, a1b2c3d4-5678-90ab-cdef-1234567890ab", + "warning": "UUID должны существовать в RemnaWave, иначе мониторинг не будет работать.", + "dependencies": "TRAFFIC_MONITORING_ENABLED", + }, + "TRAFFIC_SNAPSHOT_TTL_HOURS": { + "description": ( + "Время жизни (TTL) snapshot трафика в Redis в часах. " + "Snapshot используется для вычисления дельты (изменения трафика) между проверками. " + "После истечения TTL snapshot удаляется и создается новый." + ), + "format": "Целое число часов (минимум 1).", + "example": "24", + "warning": ( + "TTL должен быть >= интервала мониторинга. " + "Если TTL меньше интервала, snapshot будет удален до следующей проверки." + ), + "dependencies": "TRAFFIC_MONITORING_ENABLED, Redis", + }, } @classmethod diff --git a/app/services/traffic_monitoring_service.py b/app/services/traffic_monitoring_service.py index e3fc2412..d2b5ff25 100644 --- a/app/services/traffic_monitoring_service.py +++ b/app/services/traffic_monitoring_service.py @@ -1,432 +1,969 @@ """ -Сервис для мониторинга трафика пользователей -Проверяет, не превышает ли пользователь заданный порог трафика за сутки +Сервис для мониторинга трафика пользователей v2 +Быстрая проверка текущего трафика + суточная проверка """ -import logging import asyncio -from datetime import datetime, timedelta -from typing import Dict, List, Optional, Tuple, Set +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta, time +from typing import Dict, List, Optional, Set from app.config import settings from app.services.admin_notification_service import AdminNotificationService from app.services.remnawave_service import RemnaWaveService from app.database.crud.user import get_user_by_remnawave_uuid -from app.database.database import get_db -from app.database.models import User +from app.database.database import AsyncSessionLocal +from app.utils.cache import cache, cache_key from sqlalchemy.ext.asyncio import AsyncSession logger = logging.getLogger(__name__) +# Ключи для хранения snapshot в Redis +TRAFFIC_SNAPSHOT_KEY = "traffic:snapshot" +TRAFFIC_SNAPSHOT_TIME_KEY = "traffic:snapshot:time" +TRAFFIC_NOTIFICATION_CACHE_KEY = "traffic:notifications" -class TrafficMonitoringService: + +@dataclass +class TrafficViolation: + """Информация о превышении трафика""" + user_uuid: str + telegram_id: Optional[int] + full_name: Optional[str] + username: Optional[str] + used_traffic_gb: float + threshold_gb: float + last_node_uuid: Optional[str] + last_node_name: Optional[str] + check_type: str # "fast" или "daily" + + +class TrafficMonitoringServiceV2: """ - Сервис для мониторинга трафика пользователей + Улучшенный сервис мониторинга трафика + - Батчевая загрузка пользователей + - Параллельная обработка + - Быстрая проверка (каждые N минут) с дельтой + - Суточная проверка + - Фильтрация по нодам + - Хранение snapshot в Redis (персистентность при перезапуске) """ - + def __init__(self): self.remnawave_service = RemnaWaveService() - self.lock = asyncio.Lock() # Блокировка для предотвращения одновременных проверок + self._nodes_cache: Dict[str, str] = {} # {node_uuid: node_name} + # Fallback на память если Redis недоступен + self._memory_snapshot: Dict[str, float] = {} + self._memory_snapshot_time: Optional[datetime] = None + self._memory_notification_cache: Dict[str, datetime] = {} - def is_traffic_monitoring_enabled(self) -> bool: - """Проверяет, включен ли мониторинг трафика""" - return getattr(settings, 'TRAFFIC_MONITORING_ENABLED', False) + # ============== Настройки ============== - def get_traffic_threshold_gb(self) -> float: - """Получает порог трафика в ГБ за сутки""" - return getattr(settings, 'TRAFFIC_THRESHOLD_GB_PER_DAY', 10.0) + def is_fast_check_enabled(self) -> bool: + # Поддержка старого параметра TRAFFIC_MONITORING_ENABLED + return settings.TRAFFIC_FAST_CHECK_ENABLED or settings.TRAFFIC_MONITORING_ENABLED - def get_monitoring_interval_hours(self) -> int: - """Получает интервал мониторинга в часах""" - return getattr(settings, 'TRAFFIC_MONITORING_INTERVAL_HOURS', 24) + def is_daily_check_enabled(self) -> bool: + return settings.TRAFFIC_DAILY_CHECK_ENABLED - def get_suspicious_notifications_topic_id(self) -> Optional[int]: - """Получает ID топика для уведомлений о подозрительной активности""" - return getattr(settings, 'SUSPICIOUS_NOTIFICATIONS_TOPIC_ID', None) + def get_fast_check_interval_seconds(self) -> int: + # Если используется старый параметр — конвертируем часы в секунды + if settings.TRAFFIC_MONITORING_ENABLED and not settings.TRAFFIC_FAST_CHECK_ENABLED: + return settings.TRAFFIC_MONITORING_INTERVAL_HOURS * 3600 + return settings.TRAFFIC_FAST_CHECK_INTERVAL_MINUTES * 60 - async def get_user_daily_traffic(self, user_uuid: str) -> Dict: - """ - Получает статистику трафика пользователя за последние 24 часа + def get_fast_check_threshold_gb(self) -> float: + # Если используется старый параметр — используем старый порог + if settings.TRAFFIC_MONITORING_ENABLED and not settings.TRAFFIC_FAST_CHECK_ENABLED: + return settings.TRAFFIC_THRESHOLD_GB_PER_DAY + return settings.TRAFFIC_FAST_CHECK_THRESHOLD_GB - Args: - user_uuid: UUID пользователя в Remnawave + def get_daily_threshold_gb(self) -> float: + return settings.TRAFFIC_DAILY_THRESHOLD_GB - Returns: - Словарь с информацией о трафике - """ + def get_batch_size(self) -> int: + return settings.TRAFFIC_CHECK_BATCH_SIZE + + def get_concurrency(self) -> int: + return settings.TRAFFIC_CHECK_CONCURRENCY + + def get_notification_cooldown_seconds(self) -> int: + return settings.TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES * 60 + + def get_monitored_nodes(self) -> List[str]: + return settings.get_traffic_monitored_nodes() + + def get_ignored_nodes(self) -> List[str]: + return settings.get_traffic_ignored_nodes() + + def get_excluded_user_uuids(self) -> List[str]: + return settings.get_traffic_excluded_user_uuids() + + def get_daily_check_time(self) -> Optional[time]: + return settings.get_traffic_daily_check_time() + + def get_snapshot_ttl_seconds(self) -> int: + """TTL для snapshot в Redis (по умолчанию 24 часа)""" + return getattr(settings, 'TRAFFIC_SNAPSHOT_TTL_HOURS', 24) * 3600 + + # ============== Redis операции для snapshot ============== + + async def _save_snapshot_to_redis(self, snapshot: Dict[str, float]) -> bool: + """Сохраняет snapshot трафика в Redis""" try: - # Получаем время начала и конца суток (сегодня) - now = datetime.utcnow() - start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0) - end_of_day = now.replace(hour=23, minute=59, second=59, microsecond=999999) + # Сохраняем snapshot как JSON + snapshot_data = {uuid: bytes_val for uuid, bytes_val in snapshot.items()} + ttl = self.get_snapshot_ttl_seconds() - # Форматируем даты в ISO формат - start_date = start_of_day.strftime("%Y-%m-%dT%H:%M:%S.000Z") - end_date = end_of_day.strftime("%Y-%m-%dT%H:%M:%S.999Z") - - # Получаем API клиент и вызываем метод получения статистики - async with self.remnawave_service.get_api_client() as api: - traffic_data = await api.get_user_stats_usage(user_uuid, start_date, end_date) - - # Обрабатываем ответ API - if traffic_data and 'response' in traffic_data: - response = traffic_data['response'] - - # Вычисляем общий трафик - total_gb = 0 - nodes_info = [] - - if isinstance(response, list): - for item in response: - node_name = item.get('nodeName', 'Unknown') - total_bytes = item.get('total', 0) - total_gb_item = round(total_bytes / (1024**3), 2) # Конвертируем в ГБ - total_gb += total_gb_item - - nodes_info.append({ - 'node': node_name, - 'gb': total_gb_item - }) - else: - # Если response - это уже результат обработки (как в примере) - total_gb = response.get('total_gb', 0) - nodes_info = response.get('nodes', []) - - return { - 'total_gb': total_gb, - 'nodes': nodes_info, - 'date_range': { - 'start': start_date, - 'end': end_date - } - } + success = await cache.set(TRAFFIC_SNAPSHOT_KEY, snapshot_data, expire=ttl) + 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}ч") else: - logger.warning(f"Нет данных о трафике для пользователя {user_uuid}") - return { - 'total_gb': 0, - 'nodes': [], - 'date_range': { - 'start': start_date, - 'end': end_date - } - } + logger.warning(f"⚠️ Не удалось сохранить snapshot в Redis") + return success + except Exception as e: + logger.error(f"❌ Ошибка сохранения snapshot в Redis: {e}") + return False + + async def _load_snapshot_from_redis(self) -> Optional[Dict[str, float]]: + """Загружает snapshot трафика из Redis""" + try: + snapshot_data = await cache.get(TRAFFIC_SNAPSHOT_KEY) + # ВАЖНО: пустой словарь {} - это валидный snapshot! + 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)} пользователей") + return result + return None + except Exception as e: + logger.error(f"❌ Ошибка загрузки snapshot из Redis: {e}") + return None + + async def _get_snapshot_time_from_redis(self) -> Optional[datetime]: + """Получает время создания snapshot из Redis""" + try: + time_str = await cache.get(TRAFFIC_SNAPSHOT_TIME_KEY) + if time_str: + return datetime.fromisoformat(time_str) + return None + except Exception as e: + logger.error(f"❌ Ошибка получения времени snapshot: {e}") + return None + + async def _save_notification_to_redis(self, user_uuid: str) -> bool: + """Сохраняет время уведомления в Redis""" + try: + key = cache_key(TRAFFIC_NOTIFICATION_CACHE_KEY, user_uuid) + ttl = 24 * 3600 # 24 часа + return await cache.set(key, datetime.utcnow().isoformat(), expire=ttl) + except Exception as e: + logger.error(f"❌ Ошибка сохранения уведомления в Redis: {e}") + return False + + async def _get_notification_time_from_redis(self, user_uuid: str) -> Optional[datetime]: + """Получает время последнего уведомления из Redis""" + try: + key = cache_key(TRAFFIC_NOTIFICATION_CACHE_KEY, user_uuid) + time_str = await cache.get(key) + if time_str: + return datetime.fromisoformat(time_str) + return None + except Exception as e: + logger.error(f"❌ Ошибка получения времени уведомления: {e}") + return None + + # ============== Работа с нодами ============== + + async def _load_nodes_cache(self): + """Загружает названия нод в кеш""" + 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)} нод в кеш") + except Exception as e: + logger.error(f"❌ Ошибка загрузки нод в кеш: {e}") + + def get_node_name(self, node_uuid: Optional[str]) -> Optional[str]: + """Возвращает название ноды по UUID из кеша""" + if not node_uuid: + return None + return self._nodes_cache.get(node_uuid) + + # ============== Фильтрация по нодам ============== + + def should_monitor_node(self, node_uuid: Optional[str]) -> bool: + """Проверяет, нужно ли мониторить пользователя с этой ноды""" + if not node_uuid: + return True # Если нода неизвестна, мониторим + + monitored = self.get_monitored_nodes() + ignored = self.get_ignored_nodes() + + # Если есть список мониторинга — только они + if monitored: + return node_uuid in monitored + + # Если есть список игнорирования — все кроме них + if ignored: + return node_uuid not in ignored + + # Иначе мониторим всех + return True + + # ============== Кулдаун уведомлений ============== + + async def should_send_notification(self, user_uuid: str) -> bool: + """Проверяет, прошёл ли кулдаун для уведомления (Redis + fallback на память)""" + # Пробуем Redis + last_notification = await self._get_notification_time_from_redis(user_uuid) + + # Fallback на память + if last_notification is None: + last_notification = self._memory_notification_cache.get(user_uuid) + + if not last_notification: + return True + + cooldown = self.get_notification_cooldown_seconds() + return (datetime.utcnow() - last_notification).total_seconds() > cooldown + + async def record_notification(self, user_uuid: str): + """Записывает время отправки уведомления (Redis + fallback на память)""" + # Сохраняем в Redis + saved = await self._save_notification_to_redis(user_uuid) + + # Fallback на память + if not saved: + self._memory_notification_cache[user_uuid] = datetime.utcnow() + + async def cleanup_notification_cache(self): + """Очищает старые записи из памяти (Redis очищается автоматически через TTL)""" + now = datetime.utcnow() + expired = [ + uuid for uuid, dt in self._memory_notification_cache.items() + if (now - dt) > timedelta(hours=24) + ] + for uuid in expired: + del self._memory_notification_cache[uuid] + if expired: + logger.debug(f"🧹 Очищено {len(expired)} записей из памяти уведомлений о трафике") + + # ============== Получение пользователей ============== + + async def get_all_users_with_traffic(self) -> List[Dict]: + """ + Получает всех пользователей с их трафиком через батчевые запросы + Возвращает список словарей с информацией о пользователях + """ + all_users = [] + batch_size = self.get_batch_size() + offset = 0 + + try: + async with self.remnawave_service.get_api_client() as api: + while True: + result = await api.get_all_users(start=offset, size=batch_size) + users = result.get('users', []) + + if not users: + break + + all_users.extend(users) + logger.debug(f"📊 Загружено {len(all_users)} пользователей...") + + if len(users) < batch_size: + break + + offset += batch_size + + logger.info(f"✅ Всего загружено {len(all_users)} пользователей из Remnawave") + return all_users except Exception as e: - logger.error(f"Ошибка при получении статистики трафика для {user_uuid}: {e}") - return { - 'total_gb': 0, - 'nodes': [], - 'date_range': { - 'start': None, - 'end': None - } - } + logger.error(f"❌ Ошибка при получении пользователей: {e}") + return [] - async def check_user_traffic_threshold( - self, - db: AsyncSession, - user_uuid: str, - user_telegram_id: int = None - ) -> Tuple[bool, Dict]: + # ============== Быстрая проверка ============== + + async def has_snapshot(self) -> bool: + """Проверяет, есть ли сохранённый snapshot (Redis + fallback на память)""" + # Проверяем Redis (пустой словарь {} - это тоже валидный snapshot!) + snapshot = await self._load_snapshot_from_redis() + if snapshot is not None: + return True + + # Fallback на память + return self._memory_snapshot_time is not None + + async def get_snapshot_age_minutes(self) -> float: + """Возвращает возраст snapshot в минутах (Redis + fallback на память)""" + # Пробуем Redis + snapshot_time = await self._get_snapshot_time_from_redis() + + # Fallback на память + if snapshot_time is None: + snapshot_time = self._memory_snapshot_time + + if not snapshot_time: + return float('inf') + return (datetime.utcnow() - snapshot_time).total_seconds() / 60 + + async def _get_current_snapshot(self) -> Dict[str, float]: + """Получает текущий snapshot (Redis + fallback на память)""" + # Пробуем Redis + snapshot = await self._load_snapshot_from_redis() + if snapshot: + return snapshot + + # Fallback на память + return self._memory_snapshot.copy() + + async def _save_snapshot(self, snapshot: Dict[str, float]) -> bool: + """Сохраняет snapshot (Redis + fallback на память)""" + # Пробуем Redis + saved = await self._save_snapshot_to_redis(snapshot) + + if saved: + # Очищаем память если Redis доступен + self._memory_snapshot.clear() + self._memory_snapshot_time = None + return True + + # Fallback на память + self._memory_snapshot = snapshot.copy() + self._memory_snapshot_time = datetime.utcnow() + logger.warning("⚠️ Redis недоступен, snapshot сохранён в память") + return True + + async def create_initial_snapshot(self) -> int: """ - Проверяет, превышает ли трафик пользователя заданный порог - - Args: - db: Сессия базы данных - user_uuid: UUID пользователя в Remnawave - user_telegram_id: Telegram ID пользователя (для логирования) - - Returns: - Кортеж (превышен ли порог, информация о трафике) + Создаёт начальный snapshot при запуске бота. + Если в Redis уже есть snapshot — использует его (персистентность). + Возвращает количество пользователей в snapshot. """ - if not self.is_traffic_monitoring_enabled(): - return False, {} + # Проверяем есть ли snapshot в Redis (пустой {} тоже валидный snapshot!) + existing_snapshot = await self._load_snapshot_from_redis() + if existing_snapshot is not None: + age = await self.get_snapshot_age_minutes() + logger.info( + f"📦 Найден существующий snapshot в Redis: {len(existing_snapshot)} пользователей, " + f"возраст {age:.1f} мин" + ) + return len(existing_snapshot) - # Получаем статистику трафика - traffic_info = await self.get_user_daily_traffic(user_uuid) - total_gb = traffic_info.get('total_gb', 0) + logger.info("📸 Создание начального snapshot трафика...") + start_time = datetime.utcnow() - # Получаем порог для сравнения - threshold_gb = self.get_traffic_threshold_gb() + users = await self.get_all_users_with_traffic() + new_snapshot: Dict[str, float] = {} - # Проверяем, превышает ли трафик порог - is_exceeded = total_gb > threshold_gb + for user in users: + try: + if not user.uuid: + continue - # Логируем проверку - user_id_info = f"telegram_id={user_telegram_id}" if user_telegram_id else f"uuid={user_uuid}" - status = "ПРЕВЫШЕНИЕ" if is_exceeded else "норма" + user_traffic = user.user_traffic + if not user_traffic: + continue + + current_bytes = user_traffic.used_traffic_bytes or 0 + new_snapshot[user.uuid] = current_bytes + + except Exception as e: + logger.error(f"❌ Ошибка при создании snapshot для {user.uuid}: {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)} пользователей") + + return len(new_snapshot) + + async def run_fast_check(self, bot) -> List[TrafficViolation]: + """ + Быстрая проверка трафика с дельтой + + Логика: + 1. Первый запуск — сохраняем snapshot, не отправляем уведомления + 2. Следующие запуски — сравниваем с snapshot, ищем превышения дельты + 3. После проверки обновляем snapshot (в Redis с fallback на память) + """ + if not self.is_fast_check_enabled(): + return [] + + start_time = datetime.utcnow() + is_first_run = not await self.has_snapshot() + + # Загружаем кеш нод для красивых названий в уведомлениях + await self._load_nodes_cache() + + # Логируем фильтры + monitored_nodes = self.get_monitored_nodes() + ignored_nodes = self.get_ignored_nodes() + excluded_user_uuids = self.get_excluded_user_uuids() + + if monitored_nodes: + logger.info(f"🔍 Мониторим только ноды: {monitored_nodes}") + elif ignored_nodes: + logger.info(f"🚫 Игнорируем ноды: {ignored_nodes}") + else: + logger.info(f"📊 Мониторим все ноды") + + if excluded_user_uuids: + logger.info(f"🚫 Исключены пользователи: {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()} ГБ)...") + + violations: List[TrafficViolation] = [] + threshold_bytes = self.get_fast_check_threshold_gb() * (1024 ** 3) + + users = await self.get_all_users_with_traffic() + new_snapshot: Dict[str, float] = {} + + # Загружаем предыдущий snapshot (из Redis или памяти) + previous_snapshot = await self._get_current_snapshot() + logger.info(f"📦 Предыдущий snapshot: {len(previous_snapshot)} пользователей (is_first_run={is_first_run})") + + checked_users = 0 + users_with_delta = 0 + + for user in users: + try: + if not user.uuid: + continue + + # Получаем трафик из user_traffic + user_traffic = user.user_traffic + if not user_traffic: + continue + + current_bytes = user_traffic.used_traffic_bytes or 0 + new_snapshot[user.uuid] = current_bytes + + # Первый запуск — только сохраняем, не проверяем + if is_first_run: + continue + + # Пользователя не было в предыдущем snapshot — пропускаем (новый пользователь) + if user.uuid not in previous_snapshot: + logger.debug(f"Пользователь {user.uuid[:8]} не найден в предыдущем snapshot, пропускаем") + continue + + # Получаем предыдущее значение + previous_bytes = previous_snapshot.get(user.uuid, 0) + + # Вычисляем дельту (может быть отрицательной при сбросе трафика) + delta_bytes = current_bytes - previous_bytes + if delta_bytes <= 0: + continue # Трафик сбросился или не изменился + + users_with_delta += 1 + delta_gb = delta_bytes / (1024 ** 3) + + # Проверяем превышение дельты + if delta_bytes < threshold_bytes: + 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} ГБ)") + + # Проверяем исключённых пользователей (служебные/тунельные) + if user.uuid.lower() in excluded_user_uuids: + logger.info(f"⏭️ Пропускаем {user.uuid[:8]}... - пользователь в списке исключений (служебный/тунельный)") + continue + + # Проверяем фильтр по нодам + 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 'неизвестна'} не в списке мониторинга") + continue + + # Создаём violation + delta_gb = round(delta_bytes / (1024 ** 3), 2) + node_name = self.get_node_name(last_node_uuid) + violation = TrafficViolation( + user_uuid=user.uuid, + telegram_id=user.telegram_id, + full_name=user.username, + username=None, + used_traffic_gb=delta_gb, # Это дельта, не общий трафик! + threshold_gb=self.get_fast_check_threshold_gb(), + last_node_uuid=last_node_uuid, + last_node_name=node_name, + check_type="fast" + ) + violations.append(violation) + + except Exception as e: + logger.error(f"❌ Ошибка обработки пользователя {user.uuid}: {e}") + + # Обновляем snapshot (в Redis с fallback на память) + await self._save_snapshot(new_snapshot) + logger.info(f"💾 Новый snapshot сохранён: {len(new_snapshot)} пользователей") + + elapsed = (datetime.utcnow() - start_time).total_seconds() + + if is_first_run: + logger.info( + f"✅ Snapshot создан за {elapsed:.1f}с: {len(new_snapshot)} пользователей. " + f"Следующая проверка покажет превышения." + ) + else: + logger.info( + f"✅ Быстрая проверка завершена за {elapsed:.1f}с: " + f"{len(users)} пользователей, {users_with_delta} с дельтой >0, {len(violations)} превышений" + ) + # Отправляем уведомления только если это не первый запуск + await self._send_violation_notifications(violations, bot) + + return violations + + # ============== Суточная проверка ============== + + async def run_daily_check(self, bot) -> List[TrafficViolation]: + """ + Суточная проверка трафика за последние 24 часа + Использует bandwidth-stats API + """ + if not self.is_daily_check_enabled(): + return [] + + logger.info("🚀 Запуск суточной проверки трафика...") + start_time = datetime.utcnow() + + # Загружаем кеш нод для красивых названий в уведомлениях + await self._load_nodes_cache() + + violations: List[TrafficViolation] = [] + threshold_bytes = self.get_daily_threshold_gb() * (1024 ** 3) + + # Получаем период за последние 24 часа + now = datetime.utcnow() + start_date = (now - timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%S.000Z") + end_date = now.strftime("%Y-%m-%dT%H:%M:%S.999Z") + + users = await self.get_all_users_with_traffic() + semaphore = asyncio.Semaphore(self.get_concurrency()) + + async def check_user_daily_traffic(user) -> Optional[TrafficViolation]: + async with semaphore: + try: + if not user.uuid: + return None + + # Получаем статистику за период + async with self.remnawave_service.get_api_client() as api: + stats = await api.get_bandwidth_stats_user(user.uuid, start_date, end_date) + + if not stats: + return None + + # Суммируем трафик по нодам + total_bytes = 0 + if isinstance(stats, list): + for item in stats: + total_bytes += item.get('total', 0) + elif isinstance(stats, dict): + total_bytes = stats.get('total', 0) + + if total_bytes < threshold_bytes: + return None + + # Проверяем фильтр по нодам + user_traffic = user.user_traffic + last_node_uuid = user_traffic.last_connected_node_uuid if user_traffic else None + if not self.should_monitor_node(last_node_uuid): + return None + + used_gb = round(total_bytes / (1024 ** 3), 2) + node_name = self.get_node_name(last_node_uuid) + return TrafficViolation( + user_uuid=user.uuid, + telegram_id=user.telegram_id, + full_name=user.username, + username=None, + used_traffic_gb=used_gb, + threshold_gb=self.get_daily_threshold_gb(), + last_node_uuid=last_node_uuid, + last_node_name=node_name, + check_type="daily" + ) + + except Exception as e: + logger.error(f"❌ Ошибка суточной проверки для {user.uuid}: {e}") + return None + + # Параллельная проверка + tasks = [check_user_daily_traffic(user) for user in users if user.uuid] + results = await asyncio.gather(*tasks, return_exceptions=True) + + for result in results: + if isinstance(result, TrafficViolation): + violations.append(result) + + elapsed = (datetime.utcnow() - start_time).total_seconds() logger.info( - f"📊 Проверка трафика для {user_id_info}: {total_gb} ГБ, " - f"порог: {threshold_gb} ГБ, статус: {status}" + f"✅ Суточная проверка завершена за {elapsed:.1f}с: " + f"{len(users)} пользователей, {len(violations)} превышений" ) - return is_exceeded, traffic_info + # Отправляем уведомления + await self._send_violation_notifications(violations, bot) + + return violations + + # ============== Уведомления ============== + + async def _send_violation_notifications(self, violations: List[TrafficViolation], bot): + """Отправляет уведомления о превышениях""" + if not violations or not bot: + return + + admin_service = AdminNotificationService(bot) + topic_id = settings.SUSPICIOUS_NOTIFICATIONS_TOPIC_ID + + # Ограничиваем количество уведомлений за раз (защита от flood) + max_notifications = 10 + if len(violations) > max_notifications: + logger.warning( + f"⚠️ Слишком много превышений ({len(violations)}), " + f"отправляем только первые {max_notifications}" + ) + violations = violations[:max_notifications] + + for i, violation in enumerate(violations): + try: + if not await self.should_send_notification(violation.user_uuid): + logger.info(f"⏭️ Кулдаун для {violation.user_uuid[:8]}... - пропускаем уведомление (кулдаун {self.get_notification_cooldown_seconds() // 60} мин)") + continue + + # Получаем информацию о пользователе из БД + user_info = "" + async with AsyncSessionLocal() as db: + db_user = await get_user_by_remnawave_uuid(db, violation.user_uuid) + if db_user: + user_info = ( + f"👤 {db_user.full_name or 'Без имени'}\n" + f"🆔 Telegram ID: {db_user.telegram_id}\n" + ) + if db_user.username: + user_info += f"📱 Username: @{db_user.username}\n" + + if violation.check_type == "fast": + check_type_emoji = "⚡" + check_type_name = "Быстрая проверка" + traffic_label = "За интервал" + elif violation.check_type == "daily": + check_type_emoji = "📅" + check_type_name = "Суточная проверка" + traffic_label = "За 24 часа" + else: + check_type_emoji = "🔍" + check_type_name = "Ручная проверка" + traffic_label = "Использовано" + + message = ( + f"⚠️ Превышение трафика\n\n" + f"{user_info}" + f"🔑 UUID: {violation.user_uuid}\n\n" + f"{check_type_emoji} {check_type_name}\n" + f"📊 {traffic_label}: {violation.used_traffic_gb} ГБ\n" + f"📈 Порог: {violation.threshold_gb} ГБ\n" + f"🚨 Превышение: {violation.used_traffic_gb - violation.threshold_gb:.2f} ГБ\n" + ) + + # Показываем название ноды и UUID + if violation.last_node_name: + message += f"\n🖥 Сервер: {violation.last_node_name}" + if violation.last_node_uuid: + message += f"\n {violation.last_node_uuid}" + elif violation.last_node_uuid: + message += f"\n🖥 Сервер: {violation.last_node_uuid}" + + message += f"\n\n⏰ {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')} UTC" + + await admin_service.send_suspicious_traffic_notification(message, bot, topic_id) + await self.record_notification(violation.user_uuid) + + logger.info(f"📨 Уведомление отправлено для {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}") + + +class TrafficMonitoringSchedulerV2: + """ + Планировщик проверок трафика v2 + - Быстрая проверка каждые N минут + - Суточная проверка в заданное время + """ + + def __init__(self, service: TrafficMonitoringServiceV2): + self.service = service + self.bot = None + self._fast_check_task: Optional[asyncio.Task] = None + self._daily_check_task: Optional[asyncio.Task] = None + self._is_running = False + + def set_bot(self, bot): + """Устанавливает экземпляр бота""" + self.bot = bot + + async def start(self): + """Запускает планировщик""" + if self._is_running: + logger.warning("Планировщик мониторинга трафика уже запущен") + return + + if not self.bot: + logger.error("Бот не установлен для планировщика мониторинга") + return + + self._is_running = True + + # Создаём начальный snapshot при старте (без уведомлений!) + if self.service.is_fast_check_enabled(): + await self.service.create_initial_snapshot() + + # Запускаем быструю проверку + if self.service.is_fast_check_enabled(): + interval = self.service.get_fast_check_interval_seconds() + logger.info(f"🚀 Запуск быстрой проверки трафика каждые {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')}") + self._daily_check_task = asyncio.create_task(self._run_daily_check_loop(check_time)) + + async def stop(self): + """Останавливает планировщик""" + self._is_running = False + + if self._fast_check_task: + self._fast_check_task.cancel() + try: + await self._fast_check_task + except asyncio.CancelledError: + pass + self._fast_check_task = None + + if self._daily_check_task: + self._daily_check_task.cancel() + try: + await self._daily_check_task + except asyncio.CancelledError: + pass + self._daily_check_task = None + + logger.info("ℹ️ Планировщик мониторинга трафика остановлен") + + async def _run_fast_check_loop(self, interval_seconds: int): + """Цикл быстрой проверки""" + # Сначала ждём интервал (snapshot уже создан в start()) + logger.info(f"⏳ Первая проверка через {interval_seconds // 60} минут...") + await asyncio.sleep(interval_seconds) + + while self._is_running: + try: + await self.service.cleanup_notification_cache() + await self.service.run_fast_check(self.bot) + await asyncio.sleep(interval_seconds) + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"❌ Ошибка в цикле быстрой проверки: {e}") + await asyncio.sleep(interval_seconds) + + async def _run_daily_check_loop(self, check_time: time): + """Цикл суточной проверки""" + while self._is_running: + try: + # Вычисляем время до следующей проверки + now = datetime.utcnow() + next_run = datetime.combine(now.date(), check_time) + if next_run <= now: + next_run += timedelta(days=1) + + delay = (next_run - now).total_seconds() + logger.debug(f"⏰ Следующая суточная проверка через {delay / 3600:.1f}ч") + + await asyncio.sleep(delay) + + if self._is_running: + await self.service.run_daily_check(self.bot) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"❌ Ошибка в цикле суточной проверки: {e}") + await asyncio.sleep(3600) # Ждём час при ошибке + + async def run_fast_check_now(self) -> List[TrafficViolation]: + """Запускает быструю проверку немедленно""" + return await self.service.run_fast_check(self.bot) + + async def run_daily_check_now(self) -> List[TrafficViolation]: + """Запускает суточную проверку немедленно""" + return await self.service.run_daily_check(self.bot) + + +# ============== Обратная совместимость ============== + +class TrafficMonitoringService: + """Обёртка для обратной совместимости со старым API""" + + def __init__(self): + self._v2 = TrafficMonitoringServiceV2() + self.remnawave_service = self._v2.remnawave_service + + def is_traffic_monitoring_enabled(self) -> bool: + # Используем старый параметр или новые + return ( + settings.TRAFFIC_MONITORING_ENABLED or + settings.TRAFFIC_FAST_CHECK_ENABLED or + settings.TRAFFIC_DAILY_CHECK_ENABLED + ) + + def get_traffic_threshold_gb(self) -> float: + """Возвращает порог трафика""" + if settings.TRAFFIC_FAST_CHECK_ENABLED: + return settings.TRAFFIC_FAST_CHECK_THRESHOLD_GB + return settings.TRAFFIC_THRESHOLD_GB_PER_DAY + + async def check_user_traffic_threshold( + self, + db: AsyncSession, + user_uuid: str, + user_telegram_id: int = None + ) -> tuple: + """Проверяет трафик одного пользователя (для обратной совместимости)""" + try: + threshold_gb = self.get_traffic_threshold_gb() + threshold_bytes = threshold_gb * (1024 ** 3) + + # Получаем пользователя из Remnawave + async with self.remnawave_service.get_api_client() as api: + user = await api.get_user_by_uuid(user_uuid) + + if not user or not user.user_traffic: + return False, {'total_gb': 0, 'nodes': []} + + used_bytes = user.user_traffic.used_traffic_bytes or 0 + total_gb = round(used_bytes / (1024 ** 3), 2) + + is_exceeded = used_bytes > threshold_bytes + + traffic_info = { + 'total_gb': total_gb, + 'nodes': [], + 'threshold_gb': threshold_gb + } + + return is_exceeded, traffic_info + + except Exception as e: + logger.error(f"Ошибка проверки трафика для {user_uuid}: {e}") + return False, {'total_gb': 0, 'nodes': []} async def process_suspicious_traffic( self, db: AsyncSession, user_uuid: str, - traffic_info: Dict, + traffic_info: dict, bot ): - """ - Обрабатывает подозрительный трафик - отправляет уведомление администраторам - """ - try: - # Получаем информацию о пользователе из базы данных - user = await get_user_by_remnawave_uuid(db, user_uuid) - if not user: - logger.warning(f"Пользователь с UUID {user_uuid} не найден в базе данных") - return - - # Формируем сообщение для администраторов - total_gb = traffic_info.get('total_gb', 0) - threshold_gb = self.get_traffic_threshold_gb() - - message = ( - f"⚠️ Подозрительная активность трафика\n\n" - f"👤 Пользователь: {user.full_name} (ID: {user.telegram_id})\n" - f"🔑 UUID: {user_uuid}\n" - f"📊 Трафик за сутки: {total_gb} ГБ\n" - f"📈 Порог: {threshold_gb} ГБ\n" - f"🚨 Превышение: {total_gb - threshold_gb:.2f} ГБ\n\n" - ) - - # Добавляем информацию по нодам, если есть - nodes = traffic_info.get('nodes', []) - if nodes: - message += "Разбивка по нодам:\n" - for node_info in nodes[:5]: # Показываем первые 5 нод - message += f" • {node_info.get('node', 'Unknown')}: {node_info.get('gb', 0)} ГБ\n" - if len(nodes) > 5: - message += f" • и ещё {len(nodes) - 5} нод(ы)\n" - - message += f"\n⏰ Время проверки: {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S UTC')}" - - # Создаем AdminNotificationService с ботом - admin_notification_service = AdminNotificationService(bot) - - # Отправляем уведомление администраторам - topic_id = self.get_suspicious_notifications_topic_id() - - await admin_notification_service.send_suspicious_traffic_notification( - message, - bot, - topic_id - ) - - logger.info( - f"✅ Уведомление о подозрительном трафике отправлено для пользователя {user.telegram_id}" - ) - - except Exception as e: - logger.error(f"❌ Ошибка при обработке подозрительного трафика для {user_uuid}: {e}") + """Отправляет уведомление о подозрительном трафике""" + violation = TrafficViolation( + user_uuid=user_uuid, + telegram_id=None, + full_name=None, + username=None, + used_traffic_gb=traffic_info.get('total_gb', 0), + threshold_gb=traffic_info.get('threshold_gb', self.get_traffic_threshold_gb()), + last_node_uuid=None, + last_node_name=None, + check_type="manual" + ) + await self._v2._send_violation_notifications([violation], bot) async def check_all_users_traffic(self, db: AsyncSession, bot): - """ - Проверяет трафик всех пользователей с активной подпиской - """ - if not self.is_traffic_monitoring_enabled(): - logger.info("Мониторинг трафика отключен, пропускаем проверку всех пользователей") - return + """Старый метод — теперь вызывает быструю проверку""" + await self._v2.run_fast_check(bot) - try: - from app.database.crud.user import get_users_with_active_subscriptions - # Получаем всех пользователей с активной подпиской - users = await get_users_with_active_subscriptions(db) - - logger.info(f"Начинаем проверку трафика для {len(users)} пользователей") - - # Проверяем трафик для каждого пользователя - for user in users: - if user.remnawave_uuid: # Проверяем только пользователей с UUID - is_exceeded, traffic_info = await self.check_user_traffic_threshold( - db, - user.remnawave_uuid, - user.telegram_id - ) - - if is_exceeded: - await self.process_suspicious_traffic( - db, - user.remnawave_uuid, - traffic_info, - bot - ) - - logger.info("Завершена проверка трафика всех пользователей") - - except Exception as e: - logger.error(f"❌ Ошибка при проверке трафика всех пользователей: {e}") +# Глобальные экземпляры (создаём до класса-обёртки) +traffic_monitoring_service_v2 = TrafficMonitoringServiceV2() +traffic_monitoring_scheduler_v2 = TrafficMonitoringSchedulerV2(traffic_monitoring_service_v2) class TrafficMonitoringScheduler: - """ - Класс для планирования периодических проверок трафика - """ - def __init__(self, traffic_service: TrafficMonitoringService): - self.traffic_service = traffic_service - self.check_task = None - self.is_running = False + """Обёртка для обратной совместимости — использует глобальные v2 экземпляры""" + + def __init__(self, traffic_service: TrafficMonitoringService = None): + # Используем глобальные экземпляры! + self._v2_service = traffic_monitoring_service_v2 + self._v2_scheduler = traffic_monitoring_scheduler_v2 self.bot = None - # Кэш уведомлений: {user_uuid: дата_последнего_уведомления} - self._notification_cache: Dict[str, datetime] = {} def set_bot(self, bot): - """Устанавливает экземпляр бота для отправки уведомлений""" self.bot = bot + self._v2_scheduler.set_bot(bot) def is_enabled(self) -> bool: - """Проверяет, включен ли мониторинг трафика""" - return self.traffic_service.is_traffic_monitoring_enabled() + return self._v2_service.is_fast_check_enabled() or self._v2_service.is_daily_check_enabled() def get_interval_hours(self) -> int: - """Получает интервал проверки в часах""" - return self.traffic_service.get_monitoring_interval_hours() + """Для обратной совместимости — возвращает интервал быстрой проверки в часах""" + return max(1, self._v2_service.get_fast_check_interval_seconds() // 3600) + + def get_status_info(self) -> str: + """Возвращает информацию о статусе мониторинга""" + info = [] + if self._v2_service.is_fast_check_enabled(): + interval_min = self._v2_service.get_fast_check_interval_seconds() // 60 + threshold = self._v2_service.get_fast_check_threshold_gb() + info.append(f"Быстрая: каждые {interval_min} мин, порог {threshold} ГБ") + if self._v2_service.is_daily_check_enabled(): + check_time = self._v2_service.get_daily_check_time() + threshold = self._v2_service.get_daily_threshold_gb() + time_str = check_time.strftime('%H:%M') if check_time else "00:00" + info.append(f"Суточная: в {time_str}, порог {threshold} ГБ") + return "; ".join(info) if info else "Отключен" + + async def _should_send_notification(self, user_uuid: str) -> bool: + """Для обратной совместимости""" + return await self._v2_service.should_send_notification(user_uuid) + + async def _record_notification(self, user_uuid: str): + """Для обратной совместимости""" + await self._v2_service.record_notification(user_uuid) async def start_monitoring(self): - """ - Запускает периодическую проверку трафика - """ - if self.is_running: - logger.warning("Мониторинг трафика уже запущен") - return - - if not self.is_enabled(): - logger.info("Мониторинг трафика отключен в настройках") - return - - if not self.bot: - logger.error("Бот не установлен для мониторинга трафика") - return - - self.is_running = True - interval_hours = self.get_interval_hours() - interval_seconds = interval_hours * 3600 - - logger.info(f"🚀 Запуск мониторинга трафика с интервалом {interval_hours} ч") - - # Запускаем задачу с интервалом - self.check_task = asyncio.create_task(self._periodic_check(interval_seconds)) + await self._v2_scheduler.start() def stop_monitoring(self): - """ - Останавливает периодическую проверку трафика - """ - self.is_running = False - if self.check_task: - self.check_task.cancel() - logger.info("ℹ️ Мониторинг трафика остановлен") - - def _should_send_notification(self, user_uuid: str) -> bool: - """ - Проверяет, нужно ли отправлять уведомление для пользователя. - Защита от спама: одно уведомление в сутки на пользователя. - """ - now = datetime.utcnow() - last_notification = self._notification_cache.get(user_uuid) - - if last_notification is None: - return True - - # Если прошло больше 24 часов с последнего уведомления - return (now - last_notification) > timedelta(hours=24) - - def _record_notification(self, user_uuid: str): - """Записывает факт отправки уведомления""" - self._notification_cache[user_uuid] = datetime.utcnow() - - def _cleanup_notification_cache(self): - """Очищает старые записи из кэша (старше 48 часов)""" - now = datetime.utcnow() - expired = [ - uuid for uuid, dt in self._notification_cache.items() - if (now - dt) > timedelta(hours=48) - ] - for uuid in expired: - del self._notification_cache[uuid] - if expired: - logger.debug(f"🧹 Очищено {len(expired)} старых записей из кэша уведомлений о трафике") - - async def _periodic_check(self, interval_seconds: int): - """ - Выполняет периодическую проверку трафика - """ - while self.is_running: - try: - logger.info("📊 Запуск периодической проверки трафика") - - # Очищаем старый кэш - self._cleanup_notification_cache() - - # Получаем сессию БД внутри цикла - async for db in get_db(): - try: - await self._check_all_users_traffic(db) - finally: - break - - # Ждем указанный интервал перед следующей проверкой - await asyncio.sleep(interval_seconds) - - except asyncio.CancelledError: - logger.info("Задача периодической проверки трафика отменена") - break - except Exception as e: - logger.error(f"❌ Ошибка в периодической проверке трафика: {e}") - # Даже при ошибке продолжаем цикл, ждем интервал и пробуем снова - await asyncio.sleep(interval_seconds) - - async def _check_all_users_traffic(self, db: AsyncSession): - """ - Проверяет трафик всех пользователей с активной подпиской - """ - try: - from app.database.crud.user import get_users_with_active_subscriptions - - # Получаем всех пользователей с активной подпиской - users = await get_users_with_active_subscriptions(db) - - checked_count = 0 - exceeded_count = 0 - - logger.info(f"📊 Начинаем проверку трафика для {len(users)} пользователей") - - # Проверяем трафик для каждого пользователя - for user in users: - if user.remnawave_uuid: - is_exceeded, traffic_info = await self.traffic_service.check_user_traffic_threshold( - db, - user.remnawave_uuid, - user.telegram_id - ) - checked_count += 1 - - if is_exceeded: - exceeded_count += 1 - # Проверяем, не отправляли ли уже уведомление - if self._should_send_notification(user.remnawave_uuid): - await self.traffic_service.process_suspicious_traffic( - db, - user.remnawave_uuid, - traffic_info, - self.bot - ) - self._record_notification(user.remnawave_uuid) - else: - logger.debug( - f"⏭️ Пропуск уведомления для {user.telegram_id} — уже отправляли сегодня" - ) - - logger.info( - f"✅ Проверка трафика завершена: проверено {checked_count}, превышений {exceeded_count}" - ) - - except Exception as e: - logger.error(f"❌ Ошибка при проверке трафика всех пользователей: {e}") + asyncio.create_task(self._v2_scheduler.stop()) -# Глобальные экземпляры сервисов +# Обратная совместимость traffic_monitoring_service = TrafficMonitoringService() -traffic_monitoring_scheduler = TrafficMonitoringScheduler(traffic_monitoring_service) \ No newline at end of file +traffic_monitoring_scheduler = TrafficMonitoringScheduler() diff --git a/app/services/tribute_service.py b/app/services/tribute_service.py index 7cba2ae0..fa4c3e54 100644 --- a/app/services/tribute_service.py +++ b/app/services/tribute_service.py @@ -316,9 +316,12 @@ class TributeService: has_saved_cart = False # Умная автоактивация если автопокупка не сработала + activation_notification_sent = False if not auto_purchase_success: try: - await auto_activate_subscription_after_topup(session, user) + _, activation_notification_sent = await auto_activate_subscription_after_topup( + session, user, bot=self.bot, topup_amount=amount_kopeks + ) except Exception as auto_activate_error: logger.error( "Ошибка умной автоактивации для пользователя %s: %s", @@ -327,7 +330,8 @@ class TributeService: exc_info=True, ) - if has_saved_cart and self.bot: + # Отправляем уведомление только если его ещё не отправили + if has_saved_cart and self.bot and not activation_notification_sent: # Если у пользователя есть сохраненная корзина, # отправляем ему уведомление с кнопкой вернуться к оформлению from app.localization.texts import get_texts diff --git a/main.py b/main.py index bca6d293..a6be92b6 100644 --- a/main.py +++ b/main.py @@ -622,10 +622,9 @@ async def main(): traffic_monitoring_task = asyncio.create_task( traffic_monitoring_scheduler.start_monitoring() ) - interval_hours = traffic_monitoring_scheduler.get_interval_hours() - threshold_gb = settings.TRAFFIC_THRESHOLD_GB_PER_DAY - stage.log(f"Интервал проверки: {interval_hours} ч") - stage.log(f"Порог трафика: {threshold_gb} ГБ/сутки") + # Показываем информацию о новом мониторинге v2 + status_info = traffic_monitoring_scheduler.get_status_info() + stage.log(status_info) else: traffic_monitoring_task = None stage.skip("Мониторинг трафика отключен настройками") diff --git a/tests/services/test_traffic_monitoring_redis.py b/tests/services/test_traffic_monitoring_redis.py new file mode 100644 index 00000000..30993e31 --- /dev/null +++ b/tests/services/test_traffic_monitoring_redis.py @@ -0,0 +1,380 @@ +""" +Тесты для хранения snapshot трафика в Redis. +""" +import pytest +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +from app.services.traffic_monitoring_service import ( + TrafficMonitoringServiceV2, + TRAFFIC_SNAPSHOT_KEY, + TRAFFIC_SNAPSHOT_TIME_KEY, + TRAFFIC_NOTIFICATION_CACHE_KEY, +) + + +@pytest.fixture +def service(): + """Создаёт экземпляр сервиса для тестов.""" + return TrafficMonitoringServiceV2() + + +@pytest.fixture +def mock_cache(): + """Мок для cache сервиса.""" + with patch('app.services.traffic_monitoring_service.cache') as mock: + mock.set = AsyncMock(return_value=True) + mock.get = AsyncMock(return_value=None) + yield mock + + +@pytest.fixture +def sample_snapshot(): + """Пример snapshot данных.""" + return { + "uuid-1": 1073741824.0, # 1 GB + "uuid-2": 2147483648.0, # 2 GB + "uuid-3": 5368709120.0, # 5 GB + } + + +# ============== Тесты сохранения snapshot в Redis ============== + +async def test_save_snapshot_to_redis_success(service, mock_cache, sample_snapshot): + """Тест успешного сохранения snapshot в Redis.""" + mock_cache.set = AsyncMock(return_value=True) + + result = await service._save_snapshot_to_redis(sample_snapshot) + + assert result is True + assert mock_cache.set.call_count == 2 # snapshot + time + + # Проверяем что сохранён snapshot + first_call = mock_cache.set.call_args_list[0] + assert first_call[0][0] == TRAFFIC_SNAPSHOT_KEY + assert first_call[0][1] == sample_snapshot + + +async def test_save_snapshot_to_redis_failure(service, mock_cache, sample_snapshot): + """Тест неудачного сохранения snapshot в Redis.""" + mock_cache.set = AsyncMock(return_value=False) + + result = await service._save_snapshot_to_redis(sample_snapshot) + + assert result is False + + +async def test_save_snapshot_to_redis_exception(service, mock_cache, sample_snapshot): + """Тест обработки исключения при сохранении.""" + mock_cache.set = AsyncMock(side_effect=Exception("Redis error")) + + result = await service._save_snapshot_to_redis(sample_snapshot) + + assert result is False + + +# ============== Тесты загрузки snapshot из Redis ============== + +async def test_load_snapshot_from_redis_success(service, mock_cache, sample_snapshot): + """Тест успешной загрузки snapshot из Redis.""" + mock_cache.get = AsyncMock(return_value=sample_snapshot) + + result = await service._load_snapshot_from_redis() + + assert result == sample_snapshot + mock_cache.get.assert_called_once_with(TRAFFIC_SNAPSHOT_KEY) + + +async def test_load_snapshot_from_redis_empty(service, mock_cache): + """Тест загрузки когда snapshot отсутствует.""" + mock_cache.get = AsyncMock(return_value=None) + + result = await service._load_snapshot_from_redis() + + assert result is None + + +async def test_load_snapshot_from_redis_invalid_data(service, mock_cache): + """Тест загрузки невалидных данных.""" + mock_cache.get = AsyncMock(return_value="not a dict") + + result = await service._load_snapshot_from_redis() + + assert result is None + + +async def test_load_snapshot_from_redis_exception(service, mock_cache): + """Тест обработки исключения при загрузке.""" + mock_cache.get = AsyncMock(side_effect=Exception("Redis error")) + + result = await service._load_snapshot_from_redis() + + assert result is None + + +# ============== Тесты времени snapshot ============== + +async def test_get_snapshot_time_from_redis_success(service, mock_cache): + """Тест получения времени snapshot.""" + test_time = datetime(2024, 1, 15, 12, 30, 0) + mock_cache.get = AsyncMock(return_value=test_time.isoformat()) + + result = await service._get_snapshot_time_from_redis() + + assert result == test_time + mock_cache.get.assert_called_once_with(TRAFFIC_SNAPSHOT_TIME_KEY) + + +async def test_get_snapshot_time_from_redis_empty(service, mock_cache): + """Тест когда время отсутствует.""" + mock_cache.get = AsyncMock(return_value=None) + + result = await service._get_snapshot_time_from_redis() + + assert result is None + + +# ============== Тесты has_snapshot ============== + +async def test_has_snapshot_redis_exists(service, mock_cache, sample_snapshot): + """Тест has_snapshot когда snapshot есть в Redis.""" + mock_cache.get = AsyncMock(return_value=sample_snapshot) + + result = await service.has_snapshot() + + assert result is True + + +async def test_has_snapshot_memory_fallback(service, mock_cache): + """Тест has_snapshot с fallback на память.""" + mock_cache.get = AsyncMock(return_value=None) + + # Устанавливаем данные в память + service._memory_snapshot = {"uuid-1": 1000.0} + service._memory_snapshot_time = datetime.utcnow() + + result = await service.has_snapshot() + + assert result is True + + +async def test_has_snapshot_none(service, mock_cache): + """Тест has_snapshot когда snapshot нет нигде.""" + mock_cache.get = AsyncMock(return_value=None) + service._memory_snapshot = {} + service._memory_snapshot_time = None + + result = await service.has_snapshot() + + assert result is False + + +# ============== Тесты get_snapshot_age_minutes ============== + +async def test_get_snapshot_age_minutes_from_redis(service, mock_cache): + """Тест возраста snapshot из Redis.""" + # Snapshot создан 30 минут назад + past_time = datetime.utcnow() - timedelta(minutes=30) + mock_cache.get = AsyncMock(return_value=past_time.isoformat()) + + result = await service.get_snapshot_age_minutes() + + assert 29 <= result <= 31 # Допуск на время выполнения + + +async def test_get_snapshot_age_minutes_memory_fallback(service, mock_cache): + """Тест возраста snapshot из памяти.""" + mock_cache.get = AsyncMock(return_value=None) + service._memory_snapshot_time = datetime.utcnow() - timedelta(minutes=15) + + result = await service.get_snapshot_age_minutes() + + assert 14 <= result <= 16 + + +async def test_get_snapshot_age_minutes_no_snapshot(service, mock_cache): + """Тест возраста когда snapshot нет.""" + mock_cache.get = AsyncMock(return_value=None) + service._memory_snapshot_time = None + + result = await service.get_snapshot_age_minutes() + + assert result == float('inf') + + +# ============== Тесты _save_snapshot (с fallback) ============== + +async def test_save_snapshot_redis_success(service, mock_cache, sample_snapshot): + """Тест сохранения snapshot в Redis успешно.""" + mock_cache.set = AsyncMock(return_value=True) + + # Заполняем память чтобы проверить что она очистится + service._memory_snapshot = {"old": 123.0} + service._memory_snapshot_time = datetime.utcnow() + + result = await service._save_snapshot(sample_snapshot) + + assert result is True + assert service._memory_snapshot == {} # Память очищена + assert service._memory_snapshot_time is None + + +async def test_save_snapshot_fallback_to_memory(service, mock_cache, sample_snapshot): + """Тест fallback на память когда Redis недоступен.""" + mock_cache.set = AsyncMock(return_value=False) + + result = await service._save_snapshot(sample_snapshot) + + assert result is True + assert service._memory_snapshot == sample_snapshot + assert service._memory_snapshot_time is not None + + +# ============== Тесты _get_current_snapshot ============== + +async def test_get_current_snapshot_from_redis(service, mock_cache, sample_snapshot): + """Тест получения snapshot из Redis.""" + mock_cache.get = AsyncMock(return_value=sample_snapshot) + + result = await service._get_current_snapshot() + + assert result == sample_snapshot + + +async def test_get_current_snapshot_fallback_to_memory(service, mock_cache, sample_snapshot): + """Тест fallback на память.""" + mock_cache.get = AsyncMock(return_value=None) + service._memory_snapshot = sample_snapshot + + result = await service._get_current_snapshot() + + assert result == sample_snapshot + + +# ============== Тесты уведомлений ============== + +async def test_save_notification_to_redis(service, mock_cache): + """Тест сохранения времени уведомления.""" + mock_cache.set = AsyncMock(return_value=True) + + result = await service._save_notification_to_redis("uuid-123") + + assert result is True + mock_cache.set.assert_called_once() + call_args = mock_cache.set.call_args + assert "traffic:notifications:uuid-123" in call_args[0][0] + + +async def test_get_notification_time_from_redis(service, mock_cache): + """Тест получения времени уведомления.""" + test_time = datetime(2024, 1, 15, 10, 0, 0) + mock_cache.get = AsyncMock(return_value=test_time.isoformat()) + + result = await service._get_notification_time_from_redis("uuid-123") + + assert result == test_time + + +async def test_should_send_notification_no_previous(service, mock_cache): + """Тест should_send_notification когда уведомлений не было.""" + mock_cache.get = AsyncMock(return_value=None) + service._memory_notification_cache = {} + + result = await service.should_send_notification("uuid-123") + + assert result is True + + +async def test_should_send_notification_cooldown_active(service, mock_cache): + """Тест should_send_notification когда кулдаун активен.""" + # Уведомление было 5 минут назад, кулдаун 60 минут + recent_time = datetime.utcnow() - timedelta(minutes=5) + mock_cache.get = AsyncMock(return_value=recent_time.isoformat()) + + result = await service.should_send_notification("uuid-123") + + assert result is False + + +async def test_should_send_notification_cooldown_expired(service, mock_cache): + """Тест should_send_notification когда кулдаун истёк.""" + # Уведомление было 120 минут назад, кулдаун 60 минут + old_time = datetime.utcnow() - timedelta(minutes=120) + mock_cache.get = AsyncMock(return_value=old_time.isoformat()) + + result = await service.should_send_notification("uuid-123") + + assert result is True + + +async def test_record_notification_redis(service, mock_cache): + """Тест record_notification сохраняет в Redis.""" + mock_cache.set = AsyncMock(return_value=True) + + await service.record_notification("uuid-123") + + mock_cache.set.assert_called_once() + + +async def test_record_notification_fallback_to_memory(service, mock_cache): + """Тест record_notification с fallback на память.""" + mock_cache.set = AsyncMock(return_value=False) + + await service.record_notification("uuid-123") + + assert "uuid-123" in service._memory_notification_cache + + +# ============== Тесты create_initial_snapshot ============== + +async def test_create_initial_snapshot_uses_existing_redis(service, mock_cache, sample_snapshot): + """Тест что create_initial_snapshot использует существующий snapshot из Redis.""" + mock_cache.get = AsyncMock(side_effect=[ + sample_snapshot, # _load_snapshot_from_redis + (datetime.utcnow() - timedelta(minutes=10)).isoformat(), # _get_snapshot_time_from_redis + ]) + + with patch.object(service, 'get_all_users_with_traffic', new_callable=AsyncMock) as mock_get_users: + result = await service.create_initial_snapshot() + + # Не должен вызывать API - используем существующий snapshot + mock_get_users.assert_not_called() + assert result == len(sample_snapshot) + + +async def test_create_initial_snapshot_creates_new(service, mock_cache): + """Тест создания нового snapshot когда в Redis пусто.""" + mock_cache.get = AsyncMock(return_value=None) + mock_cache.set = AsyncMock(return_value=True) + + # Мокаем пользователей из API + mock_user = MagicMock() + mock_user.uuid = "uuid-1" + mock_user.user_traffic = MagicMock() + mock_user.user_traffic.used_traffic_bytes = 1073741824 # 1 GB + + with patch.object(service, 'get_all_users_with_traffic', new_callable=AsyncMock) as mock_get_users: + mock_get_users.return_value = [mock_user] + + result = await service.create_initial_snapshot() + + mock_get_users.assert_called_once() + assert result == 1 + + +# ============== Тесты cleanup_notification_cache ============== + +async def test_cleanup_notification_cache_removes_old(service, mock_cache): + """Тест очистки старых записей из памяти.""" + old_time = datetime.utcnow() - timedelta(hours=25) + recent_time = datetime.utcnow() - timedelta(hours=1) + + service._memory_notification_cache = { + "uuid-old": old_time, + "uuid-recent": recent_time, + } + + await service.cleanup_notification_cache() + + assert "uuid-old" not in service._memory_notification_cache + assert "uuid-recent" in service._memory_notification_cache