Compare commits

...

133 Commits

Author SHA1 Message Date
Egor 0cb714b3a9 Update Dockerfile 2026-01-21 10:36:42 +03:00
Egor 2c3c4ba09c Update docker-registry.yml 2026-01-21 10:36:27 +03:00
Egor 48c6c8dd63 Update docker-hub.yml 2026-01-21 10:36:15 +03:00
Egor d01dd47d57 Merge pull request #2380 from BEDOLAGA-DEV/dev5
Update purchase.py
2026-01-21 10:13:19 +03:00
Egor d51d51db55 Update purchase.py 2026-01-21 10:09:27 +03:00
Egor 275c797566 Merge pull request #2378 from BEDOLAGA-DEV/dev5
Update balance.py
2026-01-21 09:32:05 +03:00
Egor d9a4af341e Update balance.py 2026-01-21 09:31:43 +03:00
Egor a4f337a502 Merge pull request #2377 from BEDOLAGA-DEV/dev5
Update yookassa.py
2026-01-21 09:27:40 +03:00
Egor 1f55d76459 Update yookassa.py 2026-01-21 09:27:17 +03:00
Egor 2760a744db Merge pull request #2376 from BEDOLAGA-DEV/dev5
Update promo.py
2026-01-21 08:21:17 +03:00
Egor f169c08275 Update promo.py 2026-01-21 08:21:02 +03:00
Egor c69b371c53 Merge pull request #2375 from BEDOLAGA-DEV/dev5
Update promo.py
2026-01-21 08:12:34 +03:00
Egor a45d667c89 Update promo.py 2026-01-21 08:12:17 +03:00
Egor 894e4e02b2 Merge pull request #2374 from BEDOLAGA-DEV/dev5
Dev5
2026-01-21 07:56:30 +03:00
Egor c289b96f1a Update subscription_purchase_service.py 2026-01-21 07:56:04 +03:00
Egor 6425cfb0fb Merge pull request #2373 from BEDOLAGA-DEV/main
ц
2026-01-21 07:35:10 +03:00
Egor a56daca368 Update balance.py 2026-01-21 07:34:25 +03:00
Egor d0628eebda Update config.py 2026-01-21 07:33:54 +03:00
Egor 6a36504699 Merge pull request #2372 from BEDOLAGA-DEV/dev5
Update wheel_service.py
2026-01-21 07:04:31 +03:00
Egor 8db061553f Update wheel_service.py 2026-01-21 07:03:07 +03:00
Egor 7eb8750d0f Merge pull request #2371 from BEDOLAGA-DEV/dev5
Update subscription.py
2026-01-21 06:46:40 +03:00
Egor ae7f63aed0 Update subscription.py 2026-01-21 06:46:07 +03:00
Egor c18b4a3cbb Merge pull request #2370 from BEDOLAGA-DEV/dev5
Dev5
2026-01-21 05:55:28 +03:00
Egor c0cada8fb5 Add files via upload 2026-01-21 05:54:54 +03:00
Egor 1117a1dd34 Merge pull request #2369 from BEDOLAGA-DEV/main
w
2026-01-21 05:16:41 +03:00
Egor abd312dacc Merge pull request #2368 from Gy9vin/main
feat(monitoring): добавить настройки мониторинга трафика в админку
2026-01-21 05:14:36 +03:00
Mikhail b99ff79920 Merge branch 'BEDOLAGA-DEV:main' into main 2026-01-20 17:20:46 +03:00
gy9vin dff723aede feat(monitoring): добавить настройки мониторинга трафика в админку
- Добавлена кнопка "⚙️ Настройки трафика" в меню мониторинга
  - Добавлен UI для управления быстрой и суточной проверками трафика
  - Можно включать/выключать проверки, менять пороги и интервалы
  - Настройки сохраняются в БД через BotConfigurationService
  - Добавлены SETTING_HINTS с описаниями параметров
2026-01-20 17:19:57 +03:00
Egor 86097b300e Merge pull request #2367 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 16:57:18 +03:00
Egor f6b795e555 Update miniapp.py 2026-01-20 16:56:59 +03:00
Egor 7719d035a1 Update dependencies.py 2026-01-20 16:56:07 +03:00
Egor 33e11fb25a Update database.py 2026-01-20 16:55:35 +03:00
Egor fcdda41541 Merge pull request #2366 from BEDOLAGA-DEV/dev5
Update subscription.py
2026-01-20 16:44:59 +03:00
Egor c5183f5a9f Update subscription.py 2026-01-20 16:44:38 +03:00
Egor f200f90150 Merge pull request #2365 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 14:45:45 +03:00
Egor e42421d2ff Update dependencies.py 2026-01-20 14:28:35 +03:00
Egor c783884ace Update miniapp.py 2026-01-20 14:27:37 +03:00
Egor 6ca1bae6f8 Merge pull request #2364 from BEDOLAGA-DEV/dev5
Update freekassa_service.py
2026-01-20 13:18:45 +03:00
Egor e1aeff55d7 Update freekassa_service.py 2026-01-20 13:18:14 +03:00
Egor 10107964ba Merge pull request #2363 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 13:06:16 +03:00
Egor ade2794d52 Update start.py 2026-01-20 13:05:45 +03:00
Egor a462657f96 Merge pull request #2362 from BEDOLAGA-DEV/main
ц
2026-01-20 13:04:40 +03:00
Egor 91af4a3818 Update Dockerfile 2026-01-20 08:04:44 +03:00
Egor 05f29c25ec Update docker-registry.yml 2026-01-20 08:04:30 +03:00
Egor 5b851f1047 Update docker-hub.yml 2026-01-20 08:04:08 +03:00
Egor fac258a40d Merge pull request #2361 from BEDOLAGA-DEV/dev5
Update subscription.py
2026-01-20 06:37:30 +03:00
Egor d92071e8aa Update subscription.py 2026-01-20 06:37:04 +03:00
Egor f6c9304a40 Merge pull request #2360 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 06:23:22 +03:00
Egor b994a5ffd6 Add files via upload 2026-01-20 06:23:00 +03:00
Egor 0413a0ee6a Update purchase.py 2026-01-20 06:22:24 +03:00
Egor 1fc8d38c70 Merge pull request #2359 from BEDOLAGA-DEV/dev5
Update subscription.py
2026-01-20 05:39:59 +03:00
Egor f50fbe232d Update subscription.py 2026-01-20 05:39:36 +03:00
Egor 80e1b68863 Merge pull request #2358 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 02:35:07 +03:00
Egor 4f54ffcc2a Update subscription.py 2026-01-20 02:34:35 +03:00
Egor 6fed2542eb Merge pull request #2357 from BEDOLAGA-DEV/main
w
2026-01-20 02:33:50 +03:00
Egor c331c33321 Merge pull request #2356 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 02:20:14 +03:00
PEDZEO 338ee832ea Add fullscreen settings endpoints and models to branding.py 2026-01-20 02:17:35 +03:00
Egor b7a05f7cd1 Update miniapp.py 2026-01-20 02:14:42 +03:00
Egor dd396aa16d Update miniapp.py 2026-01-20 02:14:10 +03:00
Egor 814d224359 Update subscription.py 2026-01-20 02:13:34 +03:00
Egor 95ab0236d9 Update subscription.py 2026-01-20 02:13:01 +03:00
Egor 4fbe07919c Merge pull request #2355 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 01:46:57 +03:00
Egor 1752f52197 Update miniapp.py 2026-01-20 01:39:50 +03:00
Egor 5fe37e57a6 Update subscription.py 2026-01-20 01:39:09 +03:00
PEDZEO da9997609b Add custom logging configuration to WebAPIServer to reduce WebSocket spam 2026-01-20 01:30:17 +03:00
Egor 92b7c0b602 Merge pull request #2354 from BEDOLAGA-DEV/main
ц
2026-01-20 01:24:16 +03:00
Egor 44b3a0dfab Merge pull request #2353 from BEDOLAGA-DEV/dev5
Update subscription_auto_purchase_service.py
2026-01-20 01:23:34 +03:00
Egor b3bbd48723 Update subscription_auto_purchase_service.py 2026-01-20 01:23:06 +03:00
Egor e51afc6bcf Merge pull request #2352 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 01:02:27 +03:00
Egor 0ae70d702e Update purchase.py 2026-01-20 01:00:43 +03:00
Egor 722b800f1d Update subscription_purchase_service.py 2026-01-20 00:59:55 +03:00
Egor cd9287236e Update subscription.py 2026-01-20 00:58:58 +03:00
Egor 18e8e38855 Update miniapp.py 2026-01-20 00:58:24 +03:00
Egor a63d587aad Update subscription_utils.py 2026-01-20 00:54:45 +03:00
Egor aba2df927d Update tariff_purchase.py 2026-01-20 00:53:56 +03:00
Egor d78f85735b Update remnawave_api.py 2026-01-20 00:52:59 +03:00
PEDZEO a7e0f52e27 Merge pull request #2351 from BEDOLAGA-DEV/test 2026-01-20 00:41:18 +03:00
Egor b9ca73a306 Update subscription.py 2026-01-20 00:41:09 +03:00
Egor c26ccb8e96 Update subscription_purchase_service.py 2026-01-20 00:40:22 +03:00
PEDZEO 38bf8633b7 Merge branch 'test' of https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot into test 2026-01-20 00:38:29 +03:00
PEDZEO 73d52f2047 Adjust logging levels for WebSocket connections to reduce verbosity 2026-01-20 00:38:23 +03:00
Egor d781290652 Merge pull request #2350 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 00:28:33 +03:00
Egor 9d453283ec Update global_error.py 2026-01-20 00:28:16 +03:00
Egor 98d01cff49 Update message_patch.py 2026-01-20 00:27:50 +03:00
Egor 255c623143 Merge pull request #2349 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 00:20:20 +03:00
Egor 2d69d98d81 Update subscription_renewal_service.py 2026-01-20 00:19:26 +03:00
Egor 8ad2d50b25 Add files via upload 2026-01-20 00:18:57 +03:00
Egor 372654683a Merge pull request #2348 from BEDOLAGA-DEV/main
w
2026-01-20 00:17:42 +03:00
Egor 6fd470a02f Merge pull request #2347 from BEDOLAGA-DEV/dev5
Dev5
2026-01-19 23:58:03 +03:00
Egor cb4f79dd6e Update monitoring_service.py 2026-01-19 23:57:45 +03:00
Egor b38e06383d Update user.py 2026-01-19 23:57:03 +03:00
Egor 5523c6bf76 Merge pull request #2346 from BEDOLAGA-DEV/dev5
Dev5
2026-01-19 23:47:01 +03:00
Egor 57981b69c7 Update server_squad.py 2026-01-19 23:46:41 +03:00
PEDZEO 6a3e716ad2 Refactor logging levels in websocket.py files to use debug instead of info and warning for connection attempts and token validation. 2026-01-19 23:46:03 +03:00
Egor c21804f8db Add files via upload 2026-01-19 23:45:53 +03:00
Egor 3937bbffc9 Merge pull request #2345 from BEDOLAGA-DEV/dev5
Update auth.py
2026-01-19 23:36:06 +03:00
Egor 70441da7de Update auth.py 2026-01-19 23:35:12 +03:00
Egor df2626adae Merge pull request #2344 from BEDOLAGA-DEV/dev5
Update subscription.py
2026-01-19 23:17:18 +03:00
Egor 9bffeb4151 Update subscription.py 2026-01-19 23:17:00 +03:00
Egor 8524e2c023 Merge pull request #2343 from BEDOLAGA-DEV/dev5
Dev5
2026-01-19 23:06:38 +03:00
Egor 3553ff615d Update subscription.py 2026-01-19 23:06:21 +03:00
Egor ca0682e48f Merge pull request #2342 from BEDOLAGA-DEV/main
w
2026-01-19 22:52:58 +03:00
Egor 1e1c0e89bc Update subscription.py 2026-01-19 22:51:57 +03:00
Egor 93227b7f41 Update yookassa.py 2026-01-19 22:49:58 +03:00
Egor 0a51e4d9ea Merge pull request #2339 from BEDOLAGA-DEV/dev5
Update branding.py
2026-01-19 10:22:50 +03:00
Egor 9e69b0cf22 Update branding.py 2026-01-19 10:17:33 +03:00
Egor aaaaa25231 Merge pull request #2338 from BEDOLAGA-DEV/dev5
Update admin_promocodes.py
2026-01-19 09:32:03 +03:00
Egor 10fea329ad Update admin_promocodes.py 2026-01-19 09:31:41 +03:00
Egor 408c5c520e Merge pull request #2337 from BEDOLAGA-DEV/dev5
Update admin_stats.py
2026-01-19 09:17:15 +03:00
Egor 6775b06b2e Update admin_stats.py 2026-01-19 08:51:48 +03:00
Egor 9460c86f7a Merge pull request #2336 from BEDOLAGA-DEV/dev5
Dev5
2026-01-19 07:53:16 +03:00
Egor f8d7b3288c Update cloudpayments.py 2026-01-19 07:52:54 +03:00
Egor bd6498fb73 Update subscription.py 2026-01-19 07:52:16 +03:00
Egor cdb3507a56 Update tariff_purchase.py 2026-01-19 06:26:15 +03:00
Egor 0b18c16f47 Update subscription_auto_purchase_service.py 2026-01-19 06:25:30 +03:00
Egor f452434b13 Merge pull request #2335 from BEDOLAGA-DEV/main
w
2026-01-19 06:24:22 +03:00
PEDZEO e6e688a395 fix(routes): reorder notification and ticket routers to prevent route conflicts
- Moved the notifications router to be included before the tickets router to avoid conflicts.
- Updated comments for clarity regarding the order of router inclusion.
2026-01-19 01:30:31 +03:00
PEDZEO b1206a84c7 feat(notifications): enhance notification security and ownership checks
- Added ownership verification for user notifications to ensure only the rightful owner can mark them as read.
- Implemented checks to confirm that admin notifications are correctly identified before allowing them to be marked as read.
- Introduced a new method to retrieve notifications by ID in the TicketNotificationCRUD for improved data handling.
2026-01-19 00:39:36 +03:00
PEDZEO 63e45e12de Merge pull request #2334 from BEDOLAGA-DEV/test
feat(notifications): implement ticket notifications for users and admins
2026-01-19 00:34:03 +03:00
PEDZEO 792ff22471 Merge branch 'main' into test 2026-01-19 00:33:21 +03:00
PEDZEO 346806bce0 feat(notifications): integrate WebSocket notifications for ticket replies and new tickets
- Added WebSocket notifications for admins on new ticket creation and user replies.
- Implemented notification handling in the ticket management routes.
- Enhanced error logging for notification failures.
2026-01-19 00:28:57 +03:00
PEDZEO 5630e99812 Merge branch 'main' of https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot 2026-01-19 00:03:43 +03:00
PEDZEO 67c3dba1cc feat(notifications): implement ticket notifications for users and admins
- Added a new TicketNotification model to handle notifications for ticket events.
- Implemented user and admin notifications for new tickets and replies in the cabinet.
- Introduced settings to enable or disable notifications for users and admins.
- Enhanced ticket settings to include notification preferences.
- Integrated WebSocket notifications for real-time updates.
2026-01-19 00:02:41 +03:00
Egor c1e901ba5e Merge pull request #2333 from BEDOLAGA-DEV/dev5
Update auth.py
2026-01-18 23:34:02 +03:00
Egor b6f2052464 Update auth.py 2026-01-18 23:33:45 +03:00
PEDZEO c63db708cc feat(tickets): notify admins on new ticket creation and replies
- Added functionality to notify admins when a new ticket is created.
- Implemented notification for admins when a user replies to a ticket.
- Included error handling for notification failures.
2026-01-18 23:23:46 +03:00
Egor 3fc702ec65 Merge pull request #2332 from BEDOLAGA-DEV/dev5
Dev5
2026-01-18 22:15:37 +03:00
Egor 0aff94de74 Update main.py 2026-01-18 22:15:03 +03:00
Egor d8c60ce19f Merge pull request #2331 from BEDOLAGA-DEV/main
w
2026-01-18 22:14:34 +03:00
Egor f3995c7ca8 Merge pull request #2330 from DrillUser/patch-1
+убрать очепятку
2026-01-18 21:31:22 +03:00
Egor 1330f394bb Merge pull request #2328 from Gy9vin/main
fix(contests): исправлены критические баги системы конкурсов
2026-01-18 21:31:01 +03:00
Vladislav ec27d63f70 +убрать очепятку 2026-01-18 14:09:04 +03:00
gy9vin 78d785f83f fix(contests): исправлены критические баги системы конкурсов
- Исправлен вызов get_active_rounds в админ-панели (передавалось 2 параметра вместо 1)
- Обновлены кнопки редактирования призов с prize_days на prize_type/prize_value
- Мигрирован Cabinet API с устаревшего prize_days на новые поля
- Добавлена поддержка нескольких типов призов (дни, баланс, кастом)
- Обновлена документация API конкурсов
2026-01-18 10:15:45 +03:00
58 changed files with 3963 additions and 219 deletions
+1 -1
View File
@@ -214,7 +214,7 @@ TRIAL_ACTIVATION_PRICE=0
# Сколько устройств доступно по дефолту при покупке платной подписки
DEFAULT_DEVICE_LIMIT=3
# Максимум устройств достопных к покупке (0 = Нет лимита)
# Максимум устройств доступных к покупке (0 = Нет лимита)
MAX_DEVICES_LIMIT=15
# Дефолт параметры для подписок выданных через админку
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v3.1.0-$(git rev-parse --short HEAD)"
VERSION="v3.1.2-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v3.1.0-dev-$(git rev-parse --short HEAD)"
VERSION="v3.1.2-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v3.1.0-pr-$(git rev-parse --short HEAD)"
VERSION="v3.1.2-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v3.1.0-$(git rev-parse --short HEAD)"
VERSION="v3.1.2-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v3.1.0-dev-$(git rev-parse --short HEAD)"
VERSION="v3.1.2-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v3.1.0-pr-$(git rev-parse --short HEAD)"
VERSION="v3.1.2-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v3.1.0"
ARG VERSION="v3.1.2"
ARG BUILD_DATE
ARG VCS_REF
+61
View File
@@ -1,18 +1,34 @@
"""FastAPI dependencies for cabinet module."""
import logging
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
from aiogram import Bot
from app.database.database import AsyncSessionLocal
from app.database.models import User
from app.database.crud.user import get_user_by_id
from app.config import settings
from app.services.maintenance_service import maintenance_service
from .auth.jwt_handler import get_token_payload
logger = logging.getLogger(__name__)
security = HTTPBearer(auto_error=False)
# Кешированный Bot для проверки подписки на канал
_channel_check_bot: Optional[Bot] = None
def _get_channel_check_bot() -> Bot:
"""Получить или создать Bot для проверки подписки на канал."""
global _channel_check_bot
if _channel_check_bot is None:
_channel_check_bot = Bot(token=settings.BOT_TOKEN)
return _channel_check_bot
async def get_cabinet_db() -> AsyncSession:
"""Get database session for cabinet operations."""
@@ -40,6 +56,11 @@ async def get_current_cabinet_user(
Raises:
HTTPException: If token is invalid, expired, or user not found
"""
# Check maintenance mode first (except for admins - checked later)
if maintenance_service.is_maintenance_active():
# We need to check token first to see if user is admin
pass # Will check after getting user
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -80,6 +101,46 @@ async def get_current_cabinet_user(
detail="User account is not active",
)
# Check maintenance mode (allow admins to pass)
if maintenance_service.is_maintenance_active():
if not settings.is_admin(user.telegram_id):
status_info = maintenance_service.get_status_info()
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"code": "maintenance",
"message": maintenance_service.get_maintenance_message() or "Service is under maintenance",
"reason": status_info.get("reason"),
},
)
# Check required channel subscription
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
# Skip check for admins
if not settings.is_admin(user.telegram_id):
try:
bot = _get_channel_check_bot()
chat_member = await bot.get_chat_member(
chat_id=settings.CHANNEL_SUB_ID,
user_id=user.telegram_id
)
# Не закрываем сессию - бот переиспользуется
if chat_member.status not in ["member", "administrator", "creator"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"code": "channel_subscription_required",
"message": "Please subscribe to our channel to continue",
"channel_link": settings.CHANNEL_LINK,
},
)
except HTTPException:
raise
except Exception as e:
logger.warning(f"Failed to check channel subscription for user {user.telegram_id}: {e}")
# Don't block user if check fails
return user
+10 -1
View File
@@ -7,6 +7,8 @@ from .subscription import router as subscription_router
from .balance import router as balance_router
from .referral import router as referral_router
from .tickets import router as tickets_router
from .ticket_notifications import router as ticket_notifications_router
from .ticket_notifications import admin_router as admin_ticket_notifications_router
from .admin_tickets import router as admin_tickets_router
from .admin_settings import router as admin_settings_router
from .admin_apps import router as admin_apps_router
@@ -32,6 +34,7 @@ from .admin_payments import router as admin_payments_router
from .admin_promo_offers import router as admin_promo_offers_router
from .admin_remnawave import router as admin_remnawave_router
from .media import router as media_router
from .websocket import router as websocket_router
# Main cabinet router
router = APIRouter(prefix="/cabinet", tags=["Cabinet"])
@@ -41,6 +44,8 @@ router.include_router(auth_router)
router.include_router(subscription_router)
router.include_router(balance_router)
router.include_router(referral_router)
# Notifications router MUST be before tickets router to avoid route conflict
router.include_router(ticket_notifications_router)
router.include_router(tickets_router)
router.include_router(promocode_router)
router.include_router(contests_router)
@@ -54,7 +59,8 @@ router.include_router(media_router)
# Wheel routes
router.include_router(wheel_router)
# Admin routes
# Admin routes (notifications router MUST be before tickets router to avoid route conflict)
router.include_router(admin_ticket_notifications_router)
router.include_router(admin_tickets_router)
router.include_router(admin_settings_router)
router.include_router(admin_apps_router)
@@ -72,4 +78,7 @@ router.include_router(admin_payments_router)
router.include_router(admin_promo_offers_router)
router.include_router(admin_remnawave_router)
# WebSocket route
router.include_router(websocket_router)
__all__ = ["router"]
+24
View File
@@ -249,6 +249,18 @@ def _validate_create_payload(payload: PromoCodeCreateRequest) -> None:
"Subscription days must be positive for this promo code type"
)
if payload.type == PromoCodeType.DISCOUNT:
if payload.balance_bonus_kopeks <= 0 or payload.balance_bonus_kopeks > 100:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Discount percent must be between 1 and 100"
)
if payload.subscription_days <= 0:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Discount validity hours must be positive"
)
if normalized_valid_from and normalized_valid_until and normalized_valid_from > normalized_valid_until:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
@@ -289,6 +301,18 @@ def _validate_update_payload(payload: PromoCodeUpdateRequest, promocode: PromoCo
"Subscription days must be positive for this promo code type"
)
if new_type == PromoCodeType.DISCOUNT:
if balance_bonus <= 0 or balance_bonus > 100:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Discount percent must be between 1 and 100"
)
if subscription_days <= 0:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Discount validity hours must be positive"
)
valid_from = (
_normalize_datetime(payload.valid_from)
if payload.valid_from is not None
+552 -2
View File
@@ -13,11 +13,14 @@ from sqlalchemy import select, func, and_
from app.database.crud.subscription import get_subscriptions_statistics
from app.database.crud.transaction import get_transactions_statistics, get_revenue_by_period
from app.database.crud.server_squad import get_server_statistics
from app.database.crud.campaign import get_campaigns_list, get_campaign_statistics, get_campaigns_count
from app.services.remnawave_service import RemnaWaveService
from app.config import settings
from ..dependencies import get_cabinet_db, get_current_admin_user
from app.database.models import User, Subscription, Tariff, SubscriptionStatus
from app.database.models import (
User, Subscription, Tariff, SubscriptionStatus,
Transaction, TransactionType, ReferralEarning,
)
logger = logging.getLogger(__name__)
@@ -36,6 +39,15 @@ class NodeStatus(BaseModel):
users_online: int
traffic_used_bytes: Optional[int] = None
uptime: Optional[str] = None
xray_version: Optional[str] = None
node_version: Optional[str] = None
last_status_message: Optional[str] = None
xray_uptime: Optional[str] = None
is_xray_running: Optional[bool] = None
cpu_count: Optional[int] = None
cpu_model: Optional[str] = None
total_ram: Optional[str] = None
country_code: Optional[str] = None
class NodesOverview(BaseModel):
@@ -116,6 +128,81 @@ class DashboardStats(BaseModel):
tariff_stats: Optional[TariffStats] = None
# ============ Extended Stats Schemas ============
class TopReferrerItem(BaseModel):
"""Single referrer in top list."""
user_id: int
telegram_id: int
username: Optional[str] = None
display_name: str
invited_count: int
invited_today: int = 0
invited_week: int = 0
invited_month: int = 0
earnings_today_kopeks: int = 0
earnings_week_kopeks: int = 0
earnings_month_kopeks: int = 0
earnings_total_kopeks: int = 0
class TopReferrersResponse(BaseModel):
"""Top referrers response."""
by_earnings: List[TopReferrerItem]
by_invited: List[TopReferrerItem]
total_referrers: int
total_referrals: int
total_earnings_kopeks: int
class TopCampaignItem(BaseModel):
"""Single campaign in top list."""
id: int
name: str
start_parameter: str
bonus_type: str
is_active: bool
registrations: int
conversions: int
conversion_rate: float
total_revenue_kopeks: int
avg_revenue_per_user_kopeks: int
created_at: Optional[str] = None
class TopCampaignsResponse(BaseModel):
"""Top campaigns response."""
campaigns: List[TopCampaignItem]
total_campaigns: int
total_registrations: int
total_revenue_kopeks: int
class RecentPaymentItem(BaseModel):
"""Single recent payment."""
id: int
user_id: int
telegram_id: int
username: Optional[str] = None
display_name: str
amount_kopeks: int
amount_rubles: float
type: str
type_display: str
payment_method: Optional[str] = None
description: Optional[str] = None
created_at: str
is_completed: bool
class RecentPaymentsResponse(BaseModel):
"""Recent payments response."""
payments: List[RecentPaymentItem]
total_count: int
total_today_kopeks: int
total_week_kopeks: int
# ============ Routes ============
@router.get("/dashboard", response_model=DashboardStats)
@@ -300,6 +387,15 @@ async def _get_nodes_overview() -> NodesOverview:
users_online=n.get("users_online", 0) or 0,
traffic_used_bytes=n.get("traffic_used_bytes"),
uptime=n.get("uptime"),
xray_version=n.get("xray_version"),
node_version=n.get("node_version"),
last_status_message=n.get("last_status_message"),
xray_uptime=n.get("xray_uptime"),
is_xray_running=n.get("is_xray_running"),
cpu_count=n.get("cpu_count"),
cpu_model=n.get("cpu_model"),
total_ram=n.get("total_ram"),
country_code=n.get("country_code"),
)
for n in nodes
]
@@ -426,3 +522,457 @@ async def _get_tariff_stats(db: AsyncSession) -> Optional[TariffStats]:
except Exception as e:
logger.error(f"Failed to get tariff stats: {e}", exc_info=True)
return None
# ============ Extended Stats Routes ============
@router.get("/referrals/top", response_model=TopReferrersResponse)
async def get_top_referrers(
limit: int = 20,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get top referrers with earnings breakdown by period."""
try:
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
# Get all referrers with their stats
referrers_query = await db.execute(
select(
User.referred_by_id.label('referrer_id'),
func.count(User.id).label('total_invited')
)
.where(User.referred_by_id.isnot(None))
.group_by(User.referred_by_id)
)
referrers_data = {row.referrer_id: {'total_invited': row.total_invited} for row in referrers_query}
# Get invited counts by period for each referrer
# Today
today_invited_query = await db.execute(
select(
User.referred_by_id.label('referrer_id'),
func.count(User.id).label('count')
)
.where(
and_(
User.referred_by_id.isnot(None),
User.created_at >= today_start
)
)
.group_by(User.referred_by_id)
)
for row in today_invited_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['invited_today'] = row.count
# Week
week_invited_query = await db.execute(
select(
User.referred_by_id.label('referrer_id'),
func.count(User.id).label('count')
)
.where(
and_(
User.referred_by_id.isnot(None),
User.created_at >= week_ago
)
)
.group_by(User.referred_by_id)
)
for row in week_invited_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['invited_week'] = row.count
# Month
month_invited_query = await db.execute(
select(
User.referred_by_id.label('referrer_id'),
func.count(User.id).label('count')
)
.where(
and_(
User.referred_by_id.isnot(None),
User.created_at >= month_ago
)
)
.group_by(User.referred_by_id)
)
for row in month_invited_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['invited_month'] = row.count
# Get earnings from ReferralEarning table
# Total earnings
total_earnings_query = await db.execute(
select(
ReferralEarning.user_id.label('referrer_id'),
func.sum(ReferralEarning.amount_kopeks).label('total')
)
.group_by(ReferralEarning.user_id)
)
for row in total_earnings_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_total'] = row.total or 0
# Today earnings
today_earnings_query = await db.execute(
select(
ReferralEarning.user_id.label('referrer_id'),
func.sum(ReferralEarning.amount_kopeks).label('total')
)
.where(ReferralEarning.created_at >= today_start)
.group_by(ReferralEarning.user_id)
)
for row in today_earnings_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_today'] = row.total or 0
# Week earnings
week_earnings_query = await db.execute(
select(
ReferralEarning.user_id.label('referrer_id'),
func.sum(ReferralEarning.amount_kopeks).label('total')
)
.where(ReferralEarning.created_at >= week_ago)
.group_by(ReferralEarning.user_id)
)
for row in week_earnings_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_week'] = row.total or 0
# Month earnings
month_earnings_query = await db.execute(
select(
ReferralEarning.user_id.label('referrer_id'),
func.sum(ReferralEarning.amount_kopeks).label('total')
)
.where(ReferralEarning.created_at >= month_ago)
.group_by(ReferralEarning.user_id)
)
for row in month_earnings_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_month'] = row.total or 0
# Also add REFERRAL_REWARD transactions
trans_total_query = await db.execute(
select(
Transaction.user_id.label('referrer_id'),
func.sum(Transaction.amount_kopeks).label('total')
)
.where(Transaction.type == TransactionType.REFERRAL_REWARD.value)
.group_by(Transaction.user_id)
)
for row in trans_total_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_total'] = \
referrers_data[row.referrer_id].get('earnings_total', 0) + (row.total or 0)
trans_today_query = await db.execute(
select(
Transaction.user_id.label('referrer_id'),
func.sum(Transaction.amount_kopeks).label('total')
)
.where(
and_(
Transaction.type == TransactionType.REFERRAL_REWARD.value,
Transaction.created_at >= today_start
)
)
.group_by(Transaction.user_id)
)
for row in trans_today_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_today'] = \
referrers_data[row.referrer_id].get('earnings_today', 0) + (row.total or 0)
trans_week_query = await db.execute(
select(
Transaction.user_id.label('referrer_id'),
func.sum(Transaction.amount_kopeks).label('total')
)
.where(
and_(
Transaction.type == TransactionType.REFERRAL_REWARD.value,
Transaction.created_at >= week_ago
)
)
.group_by(Transaction.user_id)
)
for row in trans_week_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_week'] = \
referrers_data[row.referrer_id].get('earnings_week', 0) + (row.total or 0)
trans_month_query = await db.execute(
select(
Transaction.user_id.label('referrer_id'),
func.sum(Transaction.amount_kopeks).label('total')
)
.where(
and_(
Transaction.type == TransactionType.REFERRAL_REWARD.value,
Transaction.created_at >= month_ago
)
)
.group_by(Transaction.user_id)
)
for row in trans_month_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_month'] = \
referrers_data[row.referrer_id].get('earnings_month', 0) + (row.total or 0)
# Get user info for all referrers
referrer_ids = list(referrers_data.keys())
if referrer_ids:
users_query = await db.execute(
select(User.id, User.telegram_id, User.username, User.first_name, User.last_name)
.where(User.id.in_(referrer_ids))
)
users_info = {u.id: u for u in users_query}
else:
users_info = {}
# Build referrer items
referrer_items = []
for referrer_id, data in referrers_data.items():
user = users_info.get(referrer_id)
if not user:
continue
display_name = ""
if user.first_name:
display_name = user.first_name
if user.last_name:
display_name += f" {user.last_name}"
elif user.username:
display_name = f"@{user.username}"
else:
display_name = f"ID{user.telegram_id}"
referrer_items.append(TopReferrerItem(
user_id=user.id,
telegram_id=user.telegram_id,
username=user.username,
display_name=display_name,
invited_count=data.get('total_invited', 0),
invited_today=data.get('invited_today', 0),
invited_week=data.get('invited_week', 0),
invited_month=data.get('invited_month', 0),
earnings_today_kopeks=data.get('earnings_today', 0),
earnings_week_kopeks=data.get('earnings_week', 0),
earnings_month_kopeks=data.get('earnings_month', 0),
earnings_total_kopeks=data.get('earnings_total', 0),
))
# Sort by earnings and by invited
by_earnings = sorted(referrer_items, key=lambda x: x.earnings_total_kopeks, reverse=True)[:limit]
by_invited = sorted(referrer_items, key=lambda x: x.invited_count, reverse=True)[:limit]
# Calculate totals
total_referrers = len(referrer_items)
total_referrals = sum(r.invited_count for r in referrer_items)
total_earnings = sum(r.earnings_total_kopeks for r in referrer_items)
return TopReferrersResponse(
by_earnings=by_earnings,
by_invited=by_invited,
total_referrers=total_referrers,
total_referrals=total_referrals,
total_earnings_kopeks=total_earnings,
)
except Exception as e:
logger.error(f"Failed to get top referrers: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to load referrers statistics",
)
@router.get("/campaigns/top", response_model=TopCampaignsResponse)
async def get_top_campaigns(
limit: int = 20,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get top advertising campaigns with statistics."""
try:
# Get all campaigns
campaigns = await get_campaigns_list(db, offset=0, limit=100, include_inactive=True)
campaign_items = []
total_registrations = 0
total_revenue = 0
for campaign in campaigns:
stats = await get_campaign_statistics(db, campaign.id)
campaign_items.append(TopCampaignItem(
id=campaign.id,
name=campaign.name,
start_parameter=campaign.start_parameter,
bonus_type=campaign.bonus_type,
is_active=campaign.is_active,
registrations=stats.get("registrations", 0),
conversions=stats.get("conversion_count", 0),
conversion_rate=stats.get("conversion_rate", 0.0),
total_revenue_kopeks=stats.get("total_revenue_kopeks", 0),
avg_revenue_per_user_kopeks=stats.get("avg_revenue_per_user_kopeks", 0),
created_at=campaign.created_at.isoformat() if campaign.created_at else None,
))
total_registrations += stats.get("registrations", 0)
total_revenue += stats.get("total_revenue_kopeks", 0)
# Sort by revenue
campaign_items.sort(key=lambda x: x.total_revenue_kopeks, reverse=True)
total_campaigns = await get_campaigns_count(db)
return TopCampaignsResponse(
campaigns=campaign_items[:limit],
total_campaigns=total_campaigns,
total_registrations=total_registrations,
total_revenue_kopeks=total_revenue,
)
except Exception as e:
logger.error(f"Failed to get top campaigns: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to load campaigns statistics",
)
@router.get("/payments/recent", response_model=RecentPaymentsResponse)
async def get_recent_payments(
limit: int = 50,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get recent payments with user info."""
try:
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
# Get recent transactions (deposits and subscription payments)
transactions_query = await db.execute(
select(Transaction)
.where(
Transaction.type.in_([
TransactionType.DEPOSIT.value,
TransactionType.SUBSCRIPTION_PAYMENT.value,
])
)
.order_by(Transaction.created_at.desc())
.limit(limit)
)
transactions = transactions_query.scalars().all()
# Get user info for all transactions
user_ids = list(set(t.user_id for t in transactions))
if user_ids:
users_query = await db.execute(
select(User.id, User.telegram_id, User.username, User.first_name, User.last_name)
.where(User.id.in_(user_ids))
)
users_info = {u.id: u for u in users_query}
else:
users_info = {}
# Type display names
type_display = {
TransactionType.DEPOSIT.value: "Пополнение",
TransactionType.SUBSCRIPTION_PAYMENT.value: "Оплата подписки",
TransactionType.WITHDRAWAL.value: "Вывод",
TransactionType.REFUND.value: "Возврат",
TransactionType.REFERRAL_REWARD.value: "Реферальный бонус",
TransactionType.POLL_REWARD.value: "Награда за опрос",
}
payment_items = []
for trans in transactions:
user = users_info.get(trans.user_id)
if not user:
continue
display_name = ""
if user.first_name:
display_name = user.first_name
if user.last_name:
display_name += f" {user.last_name}"
elif user.username:
display_name = f"@{user.username}"
else:
display_name = f"ID{user.telegram_id}"
payment_items.append(RecentPaymentItem(
id=trans.id,
user_id=user.id,
telegram_id=user.telegram_id,
username=user.username,
display_name=display_name,
amount_kopeks=trans.amount_kopeks,
amount_rubles=trans.amount_kopeks / 100,
type=trans.type,
type_display=type_display.get(trans.type, trans.type),
payment_method=trans.payment_method,
description=trans.description,
created_at=trans.created_at.isoformat() if trans.created_at else "",
is_completed=trans.is_completed,
))
# Calculate totals
total_count_result = await db.execute(
select(func.count(Transaction.id))
.where(
Transaction.type.in_([
TransactionType.DEPOSIT.value,
TransactionType.SUBSCRIPTION_PAYMENT.value,
])
)
)
total_count = total_count_result.scalar() or 0
today_total_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= today_start
)
)
)
total_today = today_total_result.scalar() or 0
week_total_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= week_ago
)
)
)
total_week = week_total_result.scalar() or 0
return RecentPaymentsResponse(
payments=payment_items,
total_count=total_count,
total_today_kopeks=total_today,
total_week_kopeks=total_week,
)
except Exception as e:
logger.error(f"Failed to get recent payments: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to load recent payments",
)
+32
View File
@@ -13,7 +13,9 @@ from pydantic import BaseModel, Field
from app.database.models import User, Ticket, TicketMessage
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
from app.database.crud.ticket_notification import TicketNotificationCRUD
from app.config import settings
from app.cabinet.routes.websocket import notify_user_ticket_reply
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.tickets import TicketMessageResponse
@@ -110,6 +112,9 @@ class TicketSettingsResponse(BaseModel):
sla_check_interval_seconds: int
sla_reminder_cooldown_minutes: int
support_system_mode: str # tickets, contact, both
# Cabinet notifications settings
cabinet_user_notifications_enabled: bool = True
cabinet_admin_notifications_enabled: bool = True
class TicketSettingsUpdateRequest(BaseModel):
@@ -119,6 +124,9 @@ class TicketSettingsUpdateRequest(BaseModel):
sla_check_interval_seconds: Optional[int] = Field(None, ge=30, le=600, description="Check interval (30-600 seconds)")
sla_reminder_cooldown_minutes: Optional[int] = Field(None, ge=1, le=120, description="Reminder cooldown (1-120 minutes)")
support_system_mode: Optional[str] = Field(None, description="Support mode: tickets, contact, both")
# Cabinet notifications settings
cabinet_user_notifications_enabled: Optional[bool] = Field(None, description="Enable user notifications in cabinet")
cabinet_admin_notifications_enabled: Optional[bool] = Field(None, description="Enable admin notifications in cabinet")
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
@@ -348,6 +356,17 @@ async def reply_to_ticket(
except Exception as e:
logger.warning(f"Failed to send Telegram notification: {e}")
# Уведомить пользователя в кабинете
try:
notification = await TicketNotificationCRUD.create_user_notification_for_admin_reply(
db, ticket, request.message
)
if notification:
# Отправить WebSocket уведомление
await notify_user_ticket_reply(ticket.user_id, ticket.id, (request.message or "")[:100])
except Exception as e:
logger.warning(f"Failed to create cabinet notification for admin reply: {e}")
return _message_to_response(message)
@@ -475,12 +494,16 @@ async def get_ticket_settings(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket system settings."""
from app.services.support_settings_service import SupportSettingsService
return TicketSettingsResponse(
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
support_system_mode=settings.get_support_system_mode(),
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
)
@@ -493,6 +516,7 @@ async def update_ticket_settings(
"""Update ticket system settings."""
import os
from pathlib import Path
from app.services.support_settings_service import SupportSettingsService
# Validate support_system_mode
if request.support_system_mode is not None:
@@ -515,6 +539,12 @@ async def update_ticket_settings(
if request.support_system_mode is not None:
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
# Update cabinet notification settings
if request.cabinet_user_notifications_enabled is not None:
SupportSettingsService.set_cabinet_user_notifications_enabled(request.cabinet_user_notifications_enabled)
if request.cabinet_admin_notifications_enabled is not None:
SupportSettingsService.set_cabinet_admin_notifications_enabled(request.cabinet_admin_notifications_enabled)
# Try to persist to .env file
try:
env_file = Path(".env")
@@ -563,4 +593,6 @@ async def update_ticket_settings(
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
support_system_mode=settings.get_support_system_mode(),
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
)
+15 -1
View File
@@ -94,6 +94,16 @@ async def _store_refresh_token(
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
expires_at = get_refresh_token_expires_at()
# Check if token already exists (handles race conditions)
existing = await db.execute(
select(CabinetRefreshToken).where(
CabinetRefreshToken.token_hash == token_hash
)
)
if existing.scalar_one_or_none():
# Token already stored, skip
return
token_record = CabinetRefreshToken(
user_id=user_id,
token_hash=token_hash,
@@ -101,7 +111,11 @@ async def _store_refresh_token(
expires_at=expires_at,
)
db.add(token_record)
await db.commit()
try:
await db.commit()
except Exception:
# Handle race condition if token was inserted between check and insert
await db.rollback()
@router.post("/telegram", response_model=AuthResponse)
+14 -9
View File
@@ -134,7 +134,7 @@ async def get_payment_methods():
if settings.is_yookassa_enabled():
methods.append(PaymentMethodResponse(
id="yookassa",
name="YooKassa",
name=settings.get_yookassa_display_name(),
description="Pay via YooKassa",
min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS,
@@ -149,7 +149,7 @@ async def get_payment_methods():
if settings.is_cryptobot_enabled():
methods.append(PaymentMethodResponse(
id="cryptobot",
name="CryptoBot",
name=settings.get_cryptobot_display_name(),
description="Pay with cryptocurrency via CryptoBot",
min_amount_kopeks=1000,
max_amount_kopeks=10000000,
@@ -160,7 +160,7 @@ async def get_payment_methods():
if settings.TELEGRAM_STARS_ENABLED:
methods.append(PaymentMethodResponse(
id="telegram_stars",
name="Telegram Stars",
name=settings.get_telegram_stars_display_name(),
description="Pay with Telegram Stars",
min_amount_kopeks=100,
max_amount_kopeks=1000000,
@@ -171,7 +171,7 @@ async def get_payment_methods():
if settings.is_heleket_enabled():
methods.append(PaymentMethodResponse(
id="heleket",
name="Heleket Crypto",
name=settings.get_heleket_display_name(),
description="Pay with cryptocurrency via Heleket",
min_amount_kopeks=1000,
max_amount_kopeks=10000000,
@@ -193,7 +193,7 @@ async def get_payment_methods():
if settings.is_pal24_enabled():
methods.append(PaymentMethodResponse(
id="pal24",
name="PAL24",
name=settings.get_pal24_display_name(),
description="Pay via PAL24",
min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS,
@@ -231,7 +231,7 @@ async def get_payment_methods():
if settings.is_wata_enabled():
methods.append(PaymentMethodResponse(
id="wata",
name="Wata",
name=settings.get_wata_display_name(),
description="Pay via Wata",
min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS,
@@ -242,7 +242,7 @@ async def get_payment_methods():
if settings.is_cloudpayments_enabled():
methods.append(PaymentMethodResponse(
id="cloudpayments",
name="CloudPayments",
name=settings.get_cloudpayments_display_name(),
description="Pay with bank card via CloudPayments",
min_amount_kopeks=settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS,
@@ -404,12 +404,17 @@ async def create_topup(
# Use payment_option to select card or sbp (default: card)
option = (request.payment_option or "").strip().lower()
# Use description with telegram_id for tax receipts
description = settings.get_balance_payment_description(
request.amount_kopeks,
telegram_user_id=user.telegram_id
)
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}",
description=description,
metadata=yookassa_metadata,
)
else:
@@ -417,7 +422,7 @@ async def create_topup(
result = await yookassa_service.create_payment(
amount=amount_rubles,
currency="RUB",
description=f"Пополнение баланса на {amount_rubles:.2f}",
description=description,
metadata=yookassa_metadata,
)
+90
View File
@@ -30,6 +30,8 @@ BRANDING_NAME_KEY = "CABINET_BRANDING_NAME"
BRANDING_LOGO_KEY = "CABINET_BRANDING_LOGO" # Stores "custom" or "default"
THEME_COLORS_KEY = "CABINET_THEME_COLORS" # Stores JSON with theme colors
ENABLED_THEMES_KEY = "CABINET_ENABLED_THEMES" # Stores JSON with enabled themes {"dark": true, "light": false}
ANIMATION_ENABLED_KEY = "CABINET_ANIMATION_ENABLED" # Stores "true" or "false"
FULLSCREEN_ENABLED_KEY = "CABINET_FULLSCREEN_ENABLED" # Stores "true" or "false"
# Allowed image types
ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp", "image/svg+xml"}
@@ -95,6 +97,26 @@ class EnabledThemesUpdate(BaseModel):
light: Optional[bool] = None
class AnimationEnabledResponse(BaseModel):
"""Animation enabled setting."""
enabled: bool = True
class AnimationEnabledUpdate(BaseModel):
"""Request to update animation setting."""
enabled: bool
class FullscreenEnabledResponse(BaseModel):
"""Fullscreen enabled setting."""
enabled: bool = False
class FullscreenEnabledUpdate(BaseModel):
"""Request to update fullscreen setting."""
enabled: bool
# Default theme colors
DEFAULT_THEME_COLORS = {
"accent": "#3b82f6",
@@ -504,3 +526,71 @@ async def update_enabled_themes(
logger.info(f"Admin {admin.telegram_id} updated enabled themes: {current_themes}")
return EnabledThemesResponse(**current_themes)
# ============ Animation Routes ============
@router.get("/animation", response_model=AnimationEnabledResponse)
async def get_animation_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get animation enabled setting.
This is a public endpoint - no authentication required.
"""
animation_value = await get_setting_value(db, ANIMATION_ENABLED_KEY)
if animation_value is not None:
enabled = animation_value.lower() == "true"
return AnimationEnabledResponse(enabled=enabled)
# Default: enabled
return AnimationEnabledResponse(enabled=True)
@router.patch("/animation", response_model=AnimationEnabledResponse)
async def update_animation_enabled(
payload: AnimationEnabledUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update animation enabled setting. Admin only."""
await set_setting_value(db, ANIMATION_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f"Admin {admin.telegram_id} set animation enabled: {payload.enabled}")
return AnimationEnabledResponse(enabled=payload.enabled)
# ============ Fullscreen Routes ============
@router.get("/fullscreen", response_model=FullscreenEnabledResponse)
async def get_fullscreen_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get fullscreen enabled setting.
This is a public endpoint - no authentication required.
"""
fullscreen_value = await get_setting_value(db, FULLSCREEN_ENABLED_KEY)
if fullscreen_value is not None:
enabled = fullscreen_value.lower() == "true"
return FullscreenEnabledResponse(enabled=enabled)
# Default: disabled
return FullscreenEnabledResponse(enabled=False)
@router.patch("/fullscreen", response_model=FullscreenEnabledResponse)
async def update_fullscreen_enabled(
payload: FullscreenEnabledUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update fullscreen enabled setting. Admin only."""
await set_setting_value(db, FULLSCREEN_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f"Admin {admin.telegram_id} set fullscreen enabled: {payload.enabled}")
return FullscreenEnabledResponse(enabled=payload.enabled)
+47 -15
View File
@@ -42,7 +42,8 @@ class ContestInfo(BaseModel):
slug: str
name: str
description: Optional[str] = None
prize_days: int
prize_type: str
prize_value: str
is_available: bool
already_played: bool = False
@@ -65,7 +66,8 @@ class ContestResult(BaseModel):
"""Result of contest attempt."""
is_winner: bool
message: str
prize_days: Optional[int] = None
prize_type: Optional[str] = None
prize_value: Optional[str] = None
# ============ Helpers ============
@@ -80,19 +82,47 @@ def _user_allowed(subscription) -> bool:
}
async def _award_prize(db: AsyncSession, user_id: int, prize_days: int) -> str:
async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_value: str) -> str:
"""Award prize to winner."""
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
return "Error: subscription not found"
if prize_type == "days":
try:
days = int(prize_value)
except ValueError:
return "Error: invalid prize value"
subscription.end_date = subscription.end_date + timedelta(days=prize_days)
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
return "Error: subscription not found"
logger.info(f"🎁 Extended subscription for user {user_id} by {prize_days} days (contest prize)")
return f"Subscription extended by {prize_days} days"
subscription.end_date = subscription.end_date + timedelta(days=days)
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
logger.info(f"🎁 Extended subscription for user {user_id} by {days} days (contest prize)")
return f"Subscription extended by {days} days"
elif prize_type == "balance":
from app.database.crud.user import get_user_by_id
try:
amount = float(prize_value)
except ValueError:
return "Error: invalid prize value"
user = await get_user_by_id(db, user_id)
if not user:
return "Error: user not found"
user.balance += amount
await db.commit()
await db.refresh(user)
logger.info(f"🎁 Added {amount} to balance for user {user_id} (contest prize)")
return f"Balance increased by {amount}"
else:
logger.warning(f"Unknown prize type: {prize_type}")
return f"Prize type '{prize_type}' not supported"
# ============ Routes ============
@@ -169,7 +199,8 @@ async def get_contests(
slug=tpl_slug,
name=rnd.template.name if rnd.template else tpl_slug,
description=rnd.template.description if rnd.template else None,
prize_days=rnd.template.prize_days if rnd.template else 0,
prize_type=rnd.template.prize_type if rnd.template else "days",
prize_value=rnd.template.prize_value if rnd.template else "1",
is_available=True,
already_played=attempt is not None,
))
@@ -368,11 +399,12 @@ async def submit_contest_answer(
if is_winner:
await increment_winner_count(db, round_obj)
prize_text = await _award_prize(db, user.id, tpl.prize_days)
prize_text = await _award_prize(db, user.id, tpl.prize_type, tpl.prize_value)
return ContestResult(
is_winner=True,
message=f"🎉 Congratulations! You won! {prize_text}",
prize_days=tpl.prize_days,
prize_type=tpl.prize_type,
prize_value=tpl.prize_value,
)
else:
lose_messages = {
+101 -1
View File
@@ -15,6 +15,8 @@ from app.database.crud.discount_offer import (
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.promo_group import get_auto_assign_promo_groups
from app.database.crud.transaction import get_user_total_spent_kopeks
from app.services.promo_offer_service import promo_offer_service
from app.config import settings
@@ -70,6 +72,29 @@ class PromoGroupDiscounts(BaseModel):
period_discounts: Dict[str, int] = {}
class LoyaltyTierInfo(BaseModel):
"""Info about a single loyalty tier (promo group)."""
id: int
name: str
threshold_rubles: float
server_discount_percent: int = 0
traffic_discount_percent: int = 0
device_discount_percent: int = 0
period_discounts: Dict[str, int] = {}
is_current: bool = False
is_achieved: bool = False
class LoyaltyTiersResponse(BaseModel):
"""Response with all loyalty tiers and user progress."""
tiers: List[LoyaltyTierInfo]
current_spent_rubles: float
current_tier_name: Optional[str] = None
next_tier_name: Optional[str] = None
next_tier_threshold_rubles: Optional[float] = None
progress_percent: float = 0
# ============ Routes ============
@router.get("/offers", response_model=List[PromoOfferInfo])
@@ -134,7 +159,7 @@ async def get_promo_group_discounts(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's promo group discounts."""
await db.refresh(user, ["promo_groups"])
await db.refresh(user, ["promo_group", "user_promo_groups"])
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
@@ -160,6 +185,81 @@ async def get_promo_group_discounts(
)
@router.get("/loyalty-tiers", response_model=LoyaltyTiersResponse)
async def get_loyalty_tiers(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all loyalty tiers (promo groups with auto-assign thresholds) and user's progress."""
# Get user's total spent
total_spent_kopeks = await get_user_total_spent_kopeks(db, user.id)
total_spent_rubles = total_spent_kopeks / 100
# Get user's current promo group
await db.refresh(user, ["promo_group", "user_promo_groups"])
current_promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
current_tier_name = current_promo_group.name if current_promo_group else None
# Get all auto-assign promo groups (sorted by threshold ascending)
auto_groups = await get_auto_assign_promo_groups(db)
tiers: List[LoyaltyTierInfo] = []
next_tier_name: Optional[str] = None
next_tier_threshold: Optional[float] = None
for group in auto_groups:
threshold_kopeks = group.auto_assign_total_spent_kopeks or 0
threshold_rubles = threshold_kopeks / 100
is_achieved = total_spent_kopeks >= threshold_kopeks
is_current = current_promo_group and current_promo_group.id == group.id
# Get period discounts
period_discounts = {}
raw_period_discounts = getattr(group, "period_discounts", None)
if isinstance(raw_period_discounts, dict):
for key, value in raw_period_discounts.items():
try:
period_discounts[str(key)] = int(value)
except (TypeError, ValueError):
continue
tiers.append(
LoyaltyTierInfo(
id=group.id,
name=group.name,
threshold_rubles=threshold_rubles,
server_discount_percent=group.server_discount_percent or 0,
traffic_discount_percent=group.traffic_discount_percent or 0,
device_discount_percent=group.device_discount_percent or 0,
period_discounts=period_discounts,
is_current=is_current,
is_achieved=is_achieved,
)
)
# Find next tier (first not achieved)
if not is_achieved and next_tier_name is None:
next_tier_name = group.name
next_tier_threshold = threshold_rubles
# Calculate progress to next tier
progress_percent = 0.0
if next_tier_threshold and next_tier_threshold > 0:
progress_percent = min(100.0, (total_spent_rubles / next_tier_threshold) * 100)
elif tiers and all(t.is_achieved for t in tiers):
# All tiers achieved
progress_percent = 100.0
return LoyaltyTiersResponse(
tiers=tiers,
current_spent_rubles=total_spent_rubles,
current_tier_name=current_tier_name,
next_tier_name=next_tier_name,
next_tier_threshold_rubles=next_tier_threshold,
progress_percent=progress_percent,
)
@router.post("/claim", response_model=ClaimOfferResponse)
async def claim_promo_offer(
request: ClaimOfferRequest,
+527 -95
View File
@@ -3,6 +3,7 @@
import base64
import json
import logging
import re
from datetime import datetime, timedelta
from typing import List, Optional, Dict, Any
@@ -31,6 +32,8 @@ from app.services.subscription_purchase_service import (
PurchaseValidationError,
PurchaseBalanceError,
)
from app.services.user_cart_service import user_cart_service
from app.utils.cache import cache, cache_key, RateLimitCache
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.subscription import (
@@ -129,6 +132,9 @@ def _subscription_to_response(
if last_charge:
next_daily_charge_at = last_charge + timedelta(days=1)
# Проверяем настройку скрытия ссылки (скрывается только текст, кнопки работают)
hide_link = settings.should_hide_subscription_link()
return SubscriptionResponse(
id=subscription.id,
status=actual_status, # Use actual_status instead of raw status
@@ -148,6 +154,7 @@ def _subscription_to_response(
autopay_enabled=subscription.autopay_enabled or False,
autopay_days_before=subscription.autopay_days_before or 3,
subscription_url=subscription.subscription_url,
hide_subscription_link=hide_link,
is_active=is_active,
is_expired=is_expired,
traffic_purchases=traffic_purchases or [],
@@ -334,9 +341,60 @@ async def renew_subscription(
# Check balance
if user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
# Get tariff info for cart
tariff_id = user.subscription.tariff_id
tariff_name = None
tariff_traffic_limit_gb = None
tariff_device_limit = None
tariff_allowed_squads = None
if tariff_id:
tariff = await get_tariff_by_id(db, tariff_id)
if tariff:
tariff_name = tariff.name
tariff_traffic_limit_gb = tariff.traffic_limit_gb
tariff_device_limit = tariff.device_limit
tariff_allowed_squads = tariff.allowed_squads or []
# Save cart for auto-purchase after balance top-up
cart_data = {
'cart_mode': 'extend',
'subscription_id': user.subscription.id,
'tariff_id': tariff_id,
'period_days': request.period_days,
'total_price': price_kopeks,
'user_id': user.id,
'saved_cart': True,
'missing_amount': missing,
'return_to_cart': True,
'description': f"Продление подписки на {request.period_days} дней" + (f" ({tariff_name})" if tariff_name else ""),
'discount_percent': discount_percent,
'source': 'cabinet',
}
# Add tariff parameters for tariffs mode
if tariff_id:
cart_data['traffic_limit_gb'] = tariff_traffic_limit_gb
cart_data['device_limit'] = tariff_device_limit
cart_data['allowed_squads'] = tariff_allowed_squads
try:
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f"Cart saved for auto-renewal (cabinet) user {user.id}")
except Exception as e:
logger.error(f"Error saving cart for auto-renewal (cabinet): {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Insufficient balance. Need {price_kopeks / 100:.2f} RUB, have {user.balance_kopeks / 100:.2f} RUB",
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
"code": "insufficient_funds",
"message": f"Недостаточно средств. Не хватает {settings.format_price(missing)}",
"missing_amount": missing,
"cart_saved": True,
"cart_mode": "extend",
},
)
# Deduct balance and extend subscription
@@ -586,7 +644,10 @@ async def purchase_traffic(
# Синхронизируем с RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
if getattr(user, "remnawave_uuid", None):
await subscription_service.update_remnawave_user(db, subscription)
else:
await subscription_service.create_remnawave_user(db, subscription)
except Exception as e:
logger.error(f"Failed to sync traffic with RemnaWave: {e}")
@@ -1077,7 +1138,14 @@ async def preview_purchase(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> Dict[str, Any]:
"""Calculate and preview the total price for selected options."""
"""Calculate and preview the total price for selected options (classic mode only)."""
# This endpoint is for classic mode only, tariffs mode uses /purchase-tariff
if settings.is_tariffs_mode():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This endpoint is not available in tariffs mode. Use /purchase-tariff instead.",
)
try:
context = await purchase_service.build_options(db, user)
@@ -1115,7 +1183,14 @@ async def submit_purchase(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> Dict[str, Any]:
"""Submit subscription purchase (deduct from balance)."""
"""Submit subscription purchase (deduct from balance, classic mode only)."""
# This endpoint is for classic mode only, tariffs mode uses /purchase-tariff
if settings.is_tariffs_mode():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This endpoint is not available in tariffs mode. Use /purchase-tariff instead.",
)
try:
context = await purchase_service.build_options(db, user)
@@ -1147,9 +1222,35 @@ async def submit_purchase(
detail=str(e),
)
except PurchaseBalanceError as e:
# Save cart for auto-purchase after balance top-up
try:
total_price = pricing.final_total if 'pricing' in locals() else 0
cart_data = {
'cart_mode': 'subscription_purchase',
'period_id': request.selection.period_id,
'period_days': request.selection.period_days,
'traffic_gb': request.selection.traffic_value, # _prepare_auto_purchase expects traffic_gb
'countries': request.selection.servers, # _prepare_auto_purchase expects countries
'devices': request.selection.devices,
'total_price': total_price,
'user_id': user.id,
'saved_cart': True,
'return_to_cart': True,
'source': 'cabinet',
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f"Cart saved for auto-purchase (cabinet /purchase) user {user.id}")
except Exception as cart_error:
logger.error(f"Error saving cart for auto-purchase (cabinet /purchase): {cart_error}")
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=str(e),
detail={
"code": "insufficient_funds",
"message": str(e),
"cart_saved": True,
"cart_mode": "subscription_purchase",
},
)
except Exception as e:
logger.error(f"Failed to submit purchase for user {user.id}: {e}")
@@ -1265,12 +1366,57 @@ async def purchase_tariff(
# Check balance
if user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
# Save cart for auto-purchase after balance top-up
if is_daily_tariff:
cart_data = {
'cart_mode': 'daily_tariff_purchase',
'tariff_id': tariff.id,
'is_daily': True,
'daily_price_kopeks': price_kopeks,
'total_price': price_kopeks,
'user_id': user.id,
'saved_cart': True,
'missing_amount': missing,
'return_to_cart': True,
'description': f"Покупка суточного тарифа {tariff.name}",
'traffic_limit_gb': tariff.traffic_limit_gb,
'device_limit': tariff.device_limit,
'allowed_squads': tariff.allowed_squads or [],
'source': 'cabinet',
}
else:
cart_data = {
'cart_mode': 'tariff_purchase',
'tariff_id': tariff.id,
'period_days': period_days,
'total_price': price_kopeks,
'user_id': user.id,
'saved_cart': True,
'missing_amount': missing,
'return_to_cart': True,
'description': f"Покупка тарифа {tariff.name} на {period_days} дней",
'traffic_limit_gb': traffic_limit_gb,
'device_limit': tariff.device_limit,
'allowed_squads': tariff.allowed_squads or [],
'discount_percent': discount_percent,
'source': 'cabinet',
}
try:
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f"Cart saved for auto-purchase (cabinet) user {user.id}, tariff {tariff.id}")
except Exception as e:
logger.error(f"Error saving cart for auto-purchase (cabinet): {e}")
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
"code": "insufficient_funds",
"message": f"Недостаточно средств. Не хватает {settings.format_price(missing)}",
"missing_amount": missing,
"cart_saved": True,
"cart_mode": cart_data['cart_mode'],
},
)
@@ -1339,8 +1485,25 @@ async def purchase_tariff(
await db.refresh(subscription)
# Sync with RemnaWave
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
service = SubscriptionService()
await service.update_remnawave_user(db, subscription)
try:
if getattr(user, "remnawave_uuid", None):
await service.update_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason="покупка тарифа (cabinet)",
)
else:
await service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason="покупка тарифа (cabinet)",
)
except Exception as remnawave_error:
logger.error(f"Failed to sync subscription with RemnaWave: {remnawave_error}")
# Save cart for auto-renewal (not for daily tariffs - they have their own charging)
if not is_daily_tariff:
@@ -1420,25 +1583,34 @@ async def purchase_devices(
detail="Ваша подписка неактивна",
)
# Get tariff for device price
# Get tariff for device price (if exists)
tariff = None
if subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if not tariff or not tariff.device_price_kopeks:
# Determine device price and max limit from tariff or settings
if tariff and tariff.device_price_kopeks:
device_price = tariff.device_price_kopeks
max_device_limit = tariff.max_device_limit
else:
# Classic mode - use settings
device_price = settings.PRICE_PER_DEVICE
max_device_limit = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
if not device_price or device_price <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Докупка устройств недоступна для вашего тарифа",
detail="Докупка устройств недоступна",
)
# Check max device limit
current_devices = subscription.device_limit or 1
new_device_count = current_devices + request.devices
if tariff.max_device_limit and new_device_count > tariff.max_device_limit:
if max_device_limit and new_device_count > max_device_limit:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Максимальное количество устройств для вашего тарифа: {tariff.max_device_limit}",
detail=f"Максимальное количество устройств: {max_device_limit}",
)
# Calculate prorated price based on remaining days
@@ -1452,7 +1624,7 @@ async def purchase_devices(
total_days = 30 # Base period for device price calculation
# Price = device_price * devices * (days_left / 30)
price_kopeks = int(tariff.device_price_kopeks * request.devices * days_left / total_days)
price_kopeks = int(device_price * request.devices * days_left / total_days)
price_kopeks = max(100, price_kopeks) # Minimum 1 ruble
# Check balance
@@ -1484,7 +1656,13 @@ async def purchase_devices(
# Sync with RemnaWave
service = SubscriptionService()
await service.update_remnawave_user(db, subscription)
try:
if getattr(user, "remnawave_uuid", None):
await service.update_remnawave_user(db, subscription)
else:
await service.create_remnawave_user(db, subscription)
except Exception as e:
logger.error(f"Failed to sync devices with RemnaWave: {e}")
await db.refresh(user)
@@ -1534,15 +1712,23 @@ async def get_device_price(
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if not tariff or not tariff.device_price_kopeks:
# Determine device price and max limit from tariff or settings
if tariff and tariff.device_price_kopeks:
device_price = tariff.device_price_kopeks
max_device_limit = tariff.max_device_limit
else:
# Classic mode - use settings
device_price = settings.PRICE_PER_DEVICE
max_device_limit = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
if not device_price or device_price <= 0:
return {
"available": False,
"reason": "Докупка устройств недоступна для вашего тарифа",
"reason": "Докупка устройств недоступна",
}
# Check max device limit
current_devices = subscription.device_limit or 1
max_device_limit = tariff.max_device_limit
can_add = max_device_limit - current_devices if max_device_limit else None
if max_device_limit and current_devices >= max_device_limit:
@@ -1572,7 +1758,7 @@ async def get_device_price(
days_left = max(1, (end_date - now).days)
total_days = 30
price_per_device_kopeks = int(tariff.device_price_kopeks * days_left / total_days)
price_per_device_kopeks = int(device_price * days_left / total_days)
price_per_device_kopeks = max(100, price_per_device_kopeks)
total_price_kopeks = price_per_device_kopeks * devices
@@ -1587,7 +1773,7 @@ async def get_device_price(
"max_device_limit": max_device_limit,
"can_add": can_add,
"days_left": days_left,
"base_device_price_kopeks": tariff.device_price_kopeks,
"base_device_price_kopeks": device_price,
}
@@ -1659,67 +1845,127 @@ def _convert_remnawave_block_to_step(block: Dict[str, Any], url_scheme: str = ""
return step
# Known app URL schemes (fallback if RemnaWave doesn't provide urlScheme)
KNOWN_APP_URL_SCHEMES = {
# iOS
"happ": "happ://add/",
"streisand": "streisand://import/",
"shadowrocket": "sub://",
"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=",
"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=",
}
def _extract_scheme_from_buttons(buttons: List[Dict[str, Any]]) -> str:
"""Extract URL scheme from buttons list."""
for btn in buttons:
if not isinstance(btn, dict):
continue
link = btn.get("link", "") or btn.get("url", "") or btn.get("buttonLink", "")
if not link:
continue
# Check for subscription link placeholder (case-insensitive)
link_upper = link.upper()
if "{{SUBSCRIPTION_LINK}}" in link_upper or "SUBSCRIPTION_LINK" in link_upper:
# Extract scheme: "prizrak-box://install-config?url={{SUBSCRIPTION_LINK}}" -> "prizrak-box://install-config?url="
scheme = re.sub(r'\{\{SUBSCRIPTION_LINK\}\}', '', link, flags=re.IGNORECASE)
if scheme and "://" in scheme:
return scheme
# Also check for type="subscriptionLink" buttons with custom schemes
btn_type = btn.get("type", "")
if btn_type == "subscriptionLink" and "://" in link and not link.startswith("http"):
# Extract base scheme from link like "prizrak-box://install-config?url="
scheme = link.split("{{")[0] if "{{" in link else link
if scheme and "://" in scheme:
return scheme
return ""
def _get_url_scheme_for_app(app: Dict[str, Any]) -> str:
"""Get URL scheme for app - from config, buttons, or fallback by name."""
# 1. Check urlScheme field
scheme = str(app.get("urlScheme", "")).strip()
if scheme:
return scheme
# 2. Extract from buttons in blocks (RemnaWave format)
blocks = app.get("blocks", [])
for block in blocks:
if not isinstance(block, dict):
continue
buttons = block.get("buttons", [])
scheme = _extract_scheme_from_buttons(buttons)
if scheme:
return scheme
# 3. Check buttons directly in app (alternative structure)
direct_buttons = app.get("buttons", [])
if direct_buttons:
scheme = _extract_scheme_from_buttons(direct_buttons)
if scheme:
return scheme
# 4. Check in step structures (cabinet format)
for step_key in ["installationStep", "addSubscriptionStep", "connectAndUseStep"]:
step = app.get(step_key, {})
if isinstance(step, dict):
step_buttons = step.get("buttons", [])
scheme = _extract_scheme_from_buttons(step_buttons)
if scheme:
return scheme
# No scheme found
logger.debug(f"_get_url_scheme_for_app: No scheme found for app '{app.get('name')}', "
f"has blocks: {bool(app.get('blocks'))}, "
f"has buttons: {bool(app.get('buttons'))}, "
f"has urlScheme: {bool(app.get('urlScheme'))}")
return ""
def _find_subscription_block(blocks: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Find block that contains subscriptionLink button."""
for block in blocks:
if not isinstance(block, dict):
continue
buttons = block.get("buttons", [])
for btn in buttons:
if not isinstance(btn, dict):
continue
# Check for subscriptionLink type or {{SUBSCRIPTION_LINK}} in link
btn_type = btn.get("type", "")
link = btn.get("link", "") or btn.get("url", "")
if btn_type == "subscriptionLink" or (link and "SUBSCRIPTION_LINK" in link.upper()):
return block
return None
def _find_connect_block(blocks: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Find block that is about connection/usage (usually last or has specific keywords)."""
# Look for block with "connect" or "use" in title
for block in blocks:
if not isinstance(block, dict):
continue
title = block.get("title", {})
title_en = title.get("en", "") if isinstance(title, dict) else ""
title_lower = title_en.lower()
if "connect" in title_lower or "use" in title_lower:
return block
# Fallback to last block if no match
return blocks[-1] if blocks else None
def _convert_remnawave_app_to_cabinet(app: Dict[str, Any]) -> Dict[str, Any]:
"""Convert RemnaWave app format to cabinet app format."""
blocks = app.get("blocks", [])
url_scheme = app.get("urlScheme", "")
url_scheme = _get_url_scheme_for_app(app)
# If urlScheme is missing, try to determine from app name
if not url_scheme:
app_name = app.get("name", "").lower().strip()
url_scheme = KNOWN_APP_URL_SCHEMES.get(app_name, "")
# Debug log for conversion (не логируем отсутствие urlScheme - для Happ это нормально)
app_name = app.get("name", "unknown")
if url_scheme:
logger.debug(f"_convert_remnawave_app_to_cabinet: app '{app_name}' -> urlScheme='{url_scheme}'")
# Map blocks to steps based on position
installation_step = _convert_remnawave_block_to_step(blocks[0], url_scheme) if len(blocks) > 0 else {"description": {}}
subscription_step = _convert_remnawave_block_to_step(blocks[1], url_scheme) if len(blocks) > 1 else {"description": {}}
connect_step = _convert_remnawave_block_to_step(blocks[2], url_scheme) if len(blocks) > 2 else {"description": {}}
# Smart block mapping: find blocks by their content, not just position
# 1. First block is usually installation
installation_block = blocks[0] if len(blocks) > 0 else None
# 2. Find subscription block (with subscriptionLink button)
subscription_block = _find_subscription_block(blocks)
# 3. Find connect/use block (usually last or has "connect" in title)
connect_block = _find_connect_block(blocks)
# Convert blocks to steps
installation_step = _convert_remnawave_block_to_step(installation_block, url_scheme) if installation_block else {"description": {}}
subscription_step = _convert_remnawave_block_to_step(subscription_block, url_scheme) if subscription_block else {"description": {}}
connect_step = _convert_remnawave_block_to_step(connect_block, url_scheme) if connect_block else {"description": {}}
# Ensure subscription step has a deepLink button if urlScheme exists
if url_scheme:
@@ -1749,7 +1995,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": url_scheme, # Use resolved url_scheme (with fallback from app name)
"urlScheme": url_scheme,
"isNeedBase64Encoding": app.get("isNeedBase64Encoding", False),
"installationStep": installation_step,
"addSubscriptionStep": subscription_step,
@@ -1829,22 +2075,36 @@ def _load_app_config() -> Dict[str, Any]:
return _load_app_config_from_file()
def _create_deep_link(app: Dict[str, Any], subscription_url: str) -> Optional[str]:
"""Create deep link for app with subscription URL."""
if not subscription_url or not isinstance(app, dict):
logger.debug(f"_create_deep_link: no subscription_url or invalid app")
def _is_happ_app(app: Dict[str, Any]) -> bool:
"""Check if app is Happ (uses happ_cryptolink scheme)."""
name = str(app.get("name", "")).lower()
svg_icon_key = str(app.get("svgIconKey", "")).lower()
return name == "happ" or svg_icon_key == "happ"
def _create_deep_link(
app: Dict[str, Any],
subscription_url: str,
subscription_crypto_link: Optional[str] = None
) -> Optional[str]:
"""Create deep link for app with subscription URL.
Uses urlScheme from RemnaWave config or fallback by app name.
For Happ apps, uses subscription_crypto_link directly (contains happ:// scheme).
"""
if not isinstance(app, dict):
return None
scheme = str(app.get("urlScheme", "")).strip()
if not scheme:
# Try fallback from app name
app_name = app.get("name", "").lower().strip()
scheme = KNOWN_APP_URL_SCHEMES.get(app_name, "")
if scheme:
logger.info(f"_create_deep_link: used fallback urlScheme for '{app_name}': {scheme}")
# For Happ, use crypto_link directly if available (already has happ:// scheme)
if _is_happ_app(app) and subscription_crypto_link:
return subscription_crypto_link
if not subscription_url:
return None
scheme = _get_url_scheme_for_app(app)
if not scheme:
logger.warning(f"_create_deep_link: no urlScheme for app '{app.get('name', 'unknown')}'")
logger.debug(f"_create_deep_link: no urlScheme for app '{app.get('name', 'unknown')}'")
return None
payload = subscription_url
@@ -1868,6 +2128,7 @@ async def get_available_countries(
) -> Dict[str, Any]:
"""Get available countries/servers for the user."""
from app.database.crud.server_squad import get_available_server_squads
from app.utils.pricing_utils import calculate_prorated_price, apply_percentage_discount
await db.refresh(user, ["subscription"])
@@ -1878,25 +2139,59 @@ async def get_available_countries(
)
connected_squads = []
days_left = 0
if user.subscription:
connected_squads = user.subscription.connected_squads or []
# Calculate days left for prorated pricing
if user.subscription.end_date:
from datetime import datetime
delta = user.subscription.end_date - datetime.utcnow()
days_left = max(0, delta.days)
# Get discount from promo group
servers_discount_percent = 0
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if promo_group:
servers_discount_percent = promo_group.get_discount_percent("servers", None)
countries = []
for server in available_servers:
base_price = server.price_kopeks
# Apply discount
if servers_discount_percent > 0:
discounted_price, _ = apply_percentage_discount(base_price, servers_discount_percent)
else:
discounted_price = base_price
# Calculate prorated price if subscription exists
prorated_price = discounted_price
if user.subscription and user.subscription.end_date:
prorated_price, _ = calculate_prorated_price(
discounted_price,
user.subscription.end_date,
)
countries.append({
"uuid": server.squad_uuid,
"name": server.display_name,
"country_code": server.country_code,
"price_kopeks": server.price_kopeks,
"price_rubles": server.price_kopeks / 100,
"base_price_kopeks": base_price,
"price_kopeks": prorated_price, # Prorated price with discount
"price_per_month_kopeks": discounted_price, # Monthly price with discount
"price_rubles": prorated_price / 100,
"is_available": server.is_available and not server.is_full,
"is_connected": server.squad_uuid in connected_squads,
"has_discount": servers_discount_percent > 0,
"discount_percent": servers_discount_percent,
})
return {
"countries": countries,
"connected_count": len(connected_squads),
"has_subscription": user.subscription is not None,
"days_left": days_left,
"discount_percent": servers_discount_percent,
}
@@ -2038,7 +2333,10 @@ async def update_countries(
# Sync with RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, user.subscription)
if getattr(user, "remnawave_uuid", None):
await subscription_service.update_remnawave_user(db, user.subscription)
else:
await subscription_service.create_remnawave_user(db, user.subscription)
except Exception as e:
logger.error(f"Failed to sync countries with RemnaWave: {e}")
@@ -2157,8 +2455,10 @@ async def get_app_config(
await db.refresh(user, ["subscription"])
subscription_url = None
subscription_crypto_link = None
if user.subscription:
subscription_url = user.subscription.subscription_url
subscription_crypto_link = user.subscription.subscription_crypto_link
# Load config from RemnaWave (if configured) or local file
config = await _load_app_config_async()
@@ -2190,8 +2490,8 @@ async def get_app_config(
}
# Add deep link if subscription exists
if subscription_url:
app_data["deepLink"] = _create_deep_link(app, subscription_url)
if subscription_url or subscription_crypto_link:
app_data["deepLink"] = _create_deep_link(app, subscription_url, subscription_crypto_link)
platform_apps.append(app_data)
@@ -2209,11 +2509,15 @@ async def get_app_config(
"appleTV": {"ru": "Apple TV", "en": "Apple TV"},
}
hide_link = settings.should_hide_subscription_link()
return {
"platforms": platforms,
"platformNames": platform_names,
"hasSubscription": bool(subscription_url),
"subscriptionUrl": subscription_url,
"hasSubscription": bool(subscription_url or subscription_crypto_link),
"subscriptionUrl": subscription_url if not hide_link else None,
"subscriptionCryptoLink": subscription_crypto_link if not hide_link else None,
"hideLink": hide_link,
"branding": config.get("config", {}).get("branding", {}),
}
@@ -2706,7 +3010,10 @@ async def switch_tariff(
# Sync with RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, user.subscription)
if getattr(user, "remnawave_uuid", None):
await subscription_service.update_remnawave_user(db, user.subscription)
else:
await subscription_service.create_remnawave_user(db, user.subscription)
except Exception as e:
logger.error(f"Failed to sync tariff switch with RemnaWave: {e}")
@@ -2917,7 +3224,10 @@ async def switch_traffic_package(
# Sync with RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, user.subscription)
if getattr(user, "remnawave_uuid", None):
await subscription_service.update_remnawave_user(db, user.subscription)
else:
await subscription_service.create_remnawave_user(db, user.subscription)
except Exception as e:
logger.error(f"Failed to sync traffic switch with RemnaWave: {e}")
@@ -2933,3 +3243,125 @@ async def switch_traffic_package(
"balance_kopeks": user.balance_kopeks,
"balance_label": settings.format_price(user.balance_kopeks),
}
# ============ Traffic Refresh ============
# Rate limit: 1 request per 60 seconds per user
TRAFFIC_REFRESH_RATE_LIMIT = 1
TRAFFIC_REFRESH_RATE_WINDOW = 60 # seconds
TRAFFIC_CACHE_TTL = 60 # Cache traffic data for 60 seconds
@router.post("/refresh-traffic")
async def refresh_traffic(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Refresh traffic usage from RemnaWave panel.
Rate limited to 1 request per 60 seconds.
"""
if not user.subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No active subscription",
)
# Check rate limit
is_limited = await RateLimitCache.is_rate_limited(
user.telegram_id,
"traffic_refresh",
TRAFFIC_REFRESH_RATE_LIMIT,
TRAFFIC_REFRESH_RATE_WINDOW,
)
if is_limited:
# Check if we have cached data
traffic_cache_key = cache_key("traffic", user.telegram_id)
cached_data = await cache.get(traffic_cache_key)
if cached_data:
return {
"success": True,
"cached": True,
"rate_limited": True,
"retry_after_seconds": TRAFFIC_REFRESH_RATE_WINDOW,
**cached_data,
}
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Rate limited. Try again in {TRAFFIC_REFRESH_RATE_WINDOW} seconds.",
headers={"Retry-After": str(TRAFFIC_REFRESH_RATE_WINDOW)},
)
# Fetch traffic from RemnaWave
try:
remnawave_service = RemnaWaveService()
traffic_stats = await remnawave_service.get_user_traffic_stats(user.telegram_id)
if not traffic_stats:
# Return current database values if RemnaWave unavailable
traffic_data = {
"traffic_used_bytes": int((user.subscription.traffic_used_gb or 0) * (1024**3)),
"traffic_used_gb": round(user.subscription.traffic_used_gb or 0, 2),
"traffic_limit_bytes": int((user.subscription.traffic_limit_gb or 0) * (1024**3)),
"traffic_limit_gb": user.subscription.traffic_limit_gb or 0,
"traffic_used_percent": round(
((user.subscription.traffic_used_gb or 0) / (user.subscription.traffic_limit_gb or 1)) * 100
if user.subscription.traffic_limit_gb
else 0,
1,
),
"is_unlimited": (user.subscription.traffic_limit_gb or 0) == 0,
}
return {
"success": True,
"cached": False,
"source": "database",
**traffic_data,
}
# Update subscription with fresh data
used_gb = traffic_stats.get("used_traffic_gb", 0)
if abs((user.subscription.traffic_used_gb or 0) - used_gb) > 0.01:
user.subscription.traffic_used_gb = used_gb
user.subscription.updated_at = datetime.utcnow()
await db.commit()
# Calculate percentage
limit_gb = user.subscription.traffic_limit_gb or 0
if limit_gb > 0:
percent = min(100, (used_gb / limit_gb) * 100)
else:
percent = 0
traffic_data = {
"traffic_used_bytes": traffic_stats.get("used_traffic_bytes", 0),
"traffic_used_gb": round(used_gb, 2),
"traffic_limit_bytes": traffic_stats.get("traffic_limit_bytes", 0),
"traffic_limit_gb": limit_gb,
"traffic_used_percent": round(percent, 1),
"is_unlimited": limit_gb == 0,
"lifetime_used_bytes": traffic_stats.get("lifetime_used_traffic_bytes", 0),
"lifetime_used_gb": round(traffic_stats.get("lifetime_used_traffic_gb", 0), 2),
}
# Cache the result
traffic_cache_key = cache_key("traffic", user.telegram_id)
await cache.set(traffic_cache_key, traffic_data, TRAFFIC_CACHE_TTL)
return {
"success": True,
"cached": False,
"source": "remnawave",
**traffic_data,
}
except Exception as e:
logger.error(f"Error refreshing traffic for user {user.telegram_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to refresh traffic data",
)
+206
View File
@@ -0,0 +1,206 @@
"""Ticket notifications routes for cabinet."""
import logging
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.database.models import User
from app.database.crud.ticket_notification import TicketNotificationCRUD
from ..dependencies import get_cabinet_db, get_current_cabinet_user, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tickets/notifications", tags=["Cabinet Ticket Notifications"])
admin_router = APIRouter(prefix="/admin/tickets/notifications", tags=["Cabinet Admin Ticket Notifications"])
# Schemas
class TicketNotificationResponse(BaseModel):
"""Single ticket notification."""
id: int
ticket_id: int
notification_type: str
message: Optional[str] = None
is_read: bool
created_at: datetime
read_at: Optional[datetime] = None
class Config:
from_attributes = True
class TicketNotificationListResponse(BaseModel):
"""List of ticket notifications."""
items: List[TicketNotificationResponse]
unread_count: int
class UnreadCountResponse(BaseModel):
"""Unread notifications count."""
unread_count: int
# User endpoints
@router.get("", response_model=TicketNotificationListResponse)
async def get_user_notifications(
unread_only: bool = Query(False, description="Only return unread notifications"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket notifications for current user."""
notifications = await TicketNotificationCRUD.get_user_notifications(
db, user.id, unread_only=unread_only, limit=limit, offset=offset
)
unread_count = await TicketNotificationCRUD.count_unread_user(db, user.id)
return TicketNotificationListResponse(
items=[TicketNotificationResponse.model_validate(n) for n in notifications],
unread_count=unread_count,
)
@router.get("/unread-count", response_model=UnreadCountResponse)
async def get_user_unread_count(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get unread notifications count for current user."""
count = await TicketNotificationCRUD.count_unread_user(db, user.id)
return UnreadCountResponse(unread_count=count)
@router.post("/{notification_id}/read")
async def mark_notification_as_read(
notification_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark a notification as read."""
# Security: Verify notification belongs to current user and is not an admin notification
notification = await TicketNotificationCRUD.get_by_id(db, notification_id)
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found",
)
# Check ownership: notification must belong to user and not be an admin notification
if notification.user_id != user.id or notification.is_for_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to mark this notification as read",
)
await TicketNotificationCRUD.mark_as_read(db, notification_id)
return {"success": True}
@router.post("/read-all")
async def mark_all_notifications_as_read(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all notifications as read for current user."""
count = await TicketNotificationCRUD.mark_all_as_read_user(db, user.id)
return {"success": True, "marked_count": count}
@router.post("/ticket/{ticket_id}/read")
async def mark_ticket_notifications_as_read(
ticket_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all notifications for a specific ticket as read."""
count = await TicketNotificationCRUD.mark_ticket_notifications_as_read(
db, ticket_id, user.id, is_admin=False
)
return {"success": True, "marked_count": count}
# Admin endpoints
@admin_router.get("", response_model=TicketNotificationListResponse)
async def get_admin_notifications(
unread_only: bool = Query(False, description="Only return unread notifications"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket notifications for admins."""
notifications = await TicketNotificationCRUD.get_admin_notifications(
db, unread_only=unread_only, limit=limit, offset=offset
)
unread_count = await TicketNotificationCRUD.count_unread_admin(db)
return TicketNotificationListResponse(
items=[TicketNotificationResponse.model_validate(n) for n in notifications],
unread_count=unread_count,
)
@admin_router.get("/unread-count", response_model=UnreadCountResponse)
async def get_admin_unread_count(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get unread notifications count for admins."""
count = await TicketNotificationCRUD.count_unread_admin(db)
return UnreadCountResponse(unread_count=count)
@admin_router.post("/{notification_id}/read")
async def mark_admin_notification_as_read(
notification_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark an admin notification as read."""
# Security: Verify notification exists and is an admin notification
notification = await TicketNotificationCRUD.get_by_id(db, notification_id)
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found",
)
# Check that this is actually an admin notification
if not notification.is_for_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This is not an admin notification",
)
await TicketNotificationCRUD.mark_as_read(db, notification_id)
return {"success": True}
@admin_router.post("/read-all")
async def mark_all_admin_notifications_as_read(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all admin notifications as read."""
count = await TicketNotificationCRUD.mark_all_as_read_admin(db)
return {"success": True, "marked_count": count}
@admin_router.post("/ticket/{ticket_id}/read")
async def mark_admin_ticket_notifications_as_read(
ticket_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all admin notifications for a specific ticket as read."""
count = await TicketNotificationCRUD.mark_ticket_notifications_as_read(
db, ticket_id, admin.id, is_admin=True
)
return {"success": True, "marked_count": count}
+35
View File
@@ -12,6 +12,9 @@ from sqlalchemy.orm import selectinload
from app.database.models import User, Ticket, TicketMessage
from app.config import settings
from app.handlers.tickets import notify_admins_about_new_ticket, notify_admins_about_ticket_reply
from app.database.crud.ticket_notification import TicketNotificationCRUD
from app.cabinet.routes.websocket import notify_admins_new_ticket, notify_admins_ticket_reply
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.tickets import (
@@ -161,6 +164,21 @@ async def create_ticket(
# Refresh to get relationships
await db.refresh(ticket, ["messages"])
# Уведомить админов о новом тикете (Telegram)
try:
await notify_admins_about_new_ticket(ticket, db)
except Exception as e:
logger.error(f"Error notifying admins about new ticket from cabinet: {e}")
# Уведомить админов в кабинете
try:
notification = await TicketNotificationCRUD.create_admin_notification_for_new_ticket(db, ticket)
if notification:
# Отправить WebSocket уведомление
await notify_admins_new_ticket(ticket.id, ticket.title, user.id)
except Exception as e:
logger.error(f"Error creating cabinet notification for new ticket: {e}")
messages = [_message_to_response(m) for m in ticket.messages]
return TicketDetailResponse(
@@ -268,4 +286,21 @@ async def add_ticket_message(
await db.commit()
await db.refresh(message)
# Уведомить админов об ответе пользователя (Telegram)
try:
await notify_admins_about_ticket_reply(ticket, request.message, db)
except Exception as e:
logger.error(f"Error notifying admins about ticket reply from cabinet: {e}")
# Уведомить админов в кабинете
try:
notification = await TicketNotificationCRUD.create_admin_notification_for_user_reply(
db, ticket, request.message
)
if notification:
# Отправить WebSocket уведомление
await notify_admins_ticket_reply(ticket.id, (request.message or "")[:100], user.id)
except Exception as e:
logger.error(f"Error creating cabinet notification for user reply: {e}")
return _message_to_response(message)
+251
View File
@@ -0,0 +1,251 @@
"""WebSocket endpoint for cabinet real-time notifications."""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, Dict, Set
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.database.database import AsyncSessionLocal
from app.database.crud.user import get_user_by_id
from app.config import settings
from app.cabinet.auth.jwt_handler import get_token_payload
logger = logging.getLogger(__name__)
router = APIRouter()
class CabinetConnectionManager:
"""Менеджер WebSocket подключений для кабинета."""
def __init__(self):
# user_id -> set of websocket connections
self._user_connections: Dict[int, Set[WebSocket]] = {}
# admin user_ids -> set of websocket connections
self._admin_connections: Dict[int, Set[WebSocket]] = {}
self._lock = asyncio.Lock()
async def connect(self, websocket: WebSocket, user_id: int, is_admin: bool) -> None:
"""Зарегистрировать подключение."""
async with self._lock:
if user_id not in self._user_connections:
self._user_connections[user_id] = set()
self._user_connections[user_id].add(websocket)
if is_admin:
if user_id not in self._admin_connections:
self._admin_connections[user_id] = set()
self._admin_connections[user_id].add(websocket)
logger.debug(
"Cabinet WS connected: user_id=%d, is_admin=%s, total_users=%d",
user_id, is_admin, len(self._user_connections)
)
async def disconnect(self, websocket: WebSocket, user_id: int) -> None:
"""Отменить регистрацию подключения."""
async with self._lock:
if user_id in self._user_connections:
self._user_connections[user_id].discard(websocket)
if not self._user_connections[user_id]:
del self._user_connections[user_id]
if user_id in self._admin_connections:
self._admin_connections[user_id].discard(websocket)
if not self._admin_connections[user_id]:
del self._admin_connections[user_id]
logger.debug("Cabinet WS disconnected: user_id=%d", user_id)
async def send_to_user(self, user_id: int, message: dict) -> None:
"""Отправить сообщение конкретному пользователю."""
# Snapshot connections under the lock to avoid mutation during iteration
async with self._lock:
connections = list(self._user_connections.get(user_id, set()))
if not connections:
return
disconnected = set()
data = json.dumps(message, default=str, ensure_ascii=False)
for ws in connections:
try:
await ws.send_text(data)
except Exception as e:
logger.warning("Failed to send to user %d: %s", user_id, e)
disconnected.add(ws)
# Cleanup disconnected
if disconnected:
async with self._lock:
for ws in disconnected:
self._user_connections.get(user_id, set()).discard(ws)
async def send_to_admins(self, message: dict) -> None:
"""Отправить сообщение всем админам."""
# Snapshot connections under the lock to avoid mutation during iteration
async with self._lock:
if not self._admin_connections:
return
# Create a snapshot: list of (user_id, list of websockets)
admin_snapshot = [
(user_id, list(connections))
for user_id, connections in self._admin_connections.items()
]
data = json.dumps(message, default=str, ensure_ascii=False)
disconnected_by_user: Dict[int, Set[WebSocket]] = {}
for user_id, connections in admin_snapshot:
for ws in connections:
try:
await ws.send_text(data)
except Exception as e:
logger.warning("Failed to send to admin %d: %s", user_id, e)
if user_id not in disconnected_by_user:
disconnected_by_user[user_id] = set()
disconnected_by_user[user_id].add(ws)
# Cleanup disconnected
if disconnected_by_user:
async with self._lock:
for user_id, ws_set in disconnected_by_user.items():
for ws in ws_set:
self._admin_connections.get(user_id, set()).discard(ws)
# Глобальный менеджер подключений
cabinet_ws_manager = CabinetConnectionManager()
async def verify_cabinet_ws_token(token: str) -> tuple[int | None, bool]:
"""
Проверить JWT токен для WebSocket.
Returns:
tuple[user_id, is_admin] или (None, False) если токен невалидный
"""
if not token:
return None, False
payload = get_token_payload(token, expected_type="access")
if not payload:
return None, False
try:
user_id = int(payload.get("sub"))
except (TypeError, ValueError):
return None, False
async with AsyncSessionLocal() as db:
user = await get_user_by_id(db, user_id)
if not user or user.status != "active":
return None, False
is_admin = settings.is_admin(user.telegram_id)
return user_id, is_admin
@router.websocket("/ws")
async def cabinet_websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint для real-time уведомлений кабинета."""
client_host = websocket.client.host if websocket.client else "unknown"
# Получаем токен из query params
token = websocket.query_params.get("token")
if not token:
logger.debug("Cabinet WS: No token from %s", client_host)
# Принимаем и сразу закрываем с кодом ошибки
await websocket.accept()
await websocket.close(code=1008, reason="Unauthorized: No token")
return
# Верифицируем токен
user_id, is_admin = await verify_cabinet_ws_token(token)
if not user_id:
logger.debug("Cabinet WS: Invalid token from %s", client_host)
# Принимаем и сразу закрываем с кодом ошибки
await websocket.accept()
await websocket.close(code=1008, reason="Unauthorized: Invalid token")
return
# Принимаем соединение
try:
await websocket.accept()
logger.debug("Cabinet WS accepted: user_id=%d, is_admin=%s", user_id, is_admin)
except Exception as e:
logger.error("Cabinet WS: Failed to accept from %s: %s", client_host, e)
return
# Регистрируем подключение
await cabinet_ws_manager.connect(websocket, user_id, is_admin)
try:
# Приветственное сообщение
await websocket.send_json({
"type": "connected",
"user_id": user_id,
"is_admin": is_admin,
})
# Обрабатываем входящие сообщения
while True:
try:
data = await websocket.receive_text()
message = json.loads(data)
# Ping/pong для keepalive
if message.get("type") == "ping":
await websocket.send_json({"type": "pong"})
except json.JSONDecodeError:
logger.warning("Cabinet WS: Invalid JSON from user %d", user_id)
except WebSocketDisconnect:
break
except Exception as e:
logger.exception("Cabinet WS error for user %d: %s", user_id, e)
break
except WebSocketDisconnect:
logger.debug("Cabinet WS disconnected: user_id=%d", user_id)
except Exception as e:
logger.exception("Cabinet WS error: %s", e)
finally:
await cabinet_ws_manager.disconnect(websocket, user_id)
# Функции для отправки уведомлений (используются из других модулей)
async def notify_user_ticket_reply(user_id: int, ticket_id: int, message: str) -> None:
"""Уведомить пользователя об ответе в тикете."""
await cabinet_ws_manager.send_to_user(user_id, {
"type": "ticket.admin_reply",
"ticket_id": ticket_id,
"message": message,
})
async def notify_admins_new_ticket(ticket_id: int, title: str, user_id: int) -> None:
"""Уведомить админов о новом тикете."""
await cabinet_ws_manager.send_to_admins({
"type": "ticket.new",
"ticket_id": ticket_id,
"title": title,
"user_id": user_id,
})
async def notify_admins_ticket_reply(ticket_id: int, message: str, user_id: int) -> None:
"""Уведомить админов об ответе пользователя."""
await cabinet_ws_manager.send_to_admins({
"type": "ticket.user_reply",
"ticket_id": ticket_id,
"message": message,
"user_id": user_id,
})
+1
View File
@@ -42,6 +42,7 @@ class SubscriptionResponse(BaseModel):
autopay_enabled: bool
autopay_days_before: int
subscription_url: Optional[str] = None
hide_subscription_link: bool = False # Скрывать ли отображение ссылки (но кнопки работают)
is_active: bool
is_expired: bool
traffic_purchases: List[TrafficPurchaseInfo] = []
+36 -1
View File
@@ -294,6 +294,7 @@ class Settings(BaseSettings):
TELEGRAM_STARS_ENABLED: bool = True
TELEGRAM_STARS_RATE_RUB: float = 1.3
TELEGRAM_STARS_DISPLAY_NAME: str = "Telegram Stars"
TRIBUTE_ENABLED: bool = False
TRIBUTE_API_KEY: Optional[str] = None
@@ -303,6 +304,7 @@ class Settings(BaseSettings):
TRIBUTE_WEBHOOK_PORT: int = 8081
YOOKASSA_ENABLED: bool = False
YOOKASSA_DISPLAY_NAME: str = "YooKassa"
YOOKASSA_SHOP_ID: Optional[str] = None
YOOKASSA_SECRET_KEY: Optional[str] = None
YOOKASSA_RETURN_URL: Optional[str] = None
@@ -348,6 +350,7 @@ class Settings(BaseSettings):
PAYMENT_SUBSCRIPTION_TEMPLATE: str = "{service_name} - {description}"
CRYPTOBOT_ENABLED: bool = False
CRYPTOBOT_DISPLAY_NAME: str = "CryptoBot"
CRYPTOBOT_API_TOKEN: Optional[str] = None
CRYPTOBOT_WEBHOOK_SECRET: Optional[str] = None
CRYPTOBOT_BASE_URL: str = "https://pay.crypt.bot"
@@ -359,6 +362,7 @@ class Settings(BaseSettings):
CRYPTOBOT_INVOICE_EXPIRES_HOURS: int = 24
HELEKET_ENABLED: bool = False
HELEKET_DISPLAY_NAME: str = "Heleket Crypto"
HELEKET_MERCHANT_ID: Optional[str] = None
HELEKET_API_KEY: Optional[str] = None
HELEKET_BASE_URL: str = "https://api.heleket.com/v1"
@@ -394,6 +398,7 @@ class Settings(BaseSettings):
MULENPAY_IFRAME_EXPECTED_ORIGIN: Optional[str] = None
PAL24_ENABLED: bool = False
PAL24_DISPLAY_NAME: str = "PAL24"
PAL24_API_TOKEN: Optional[str] = None
PAL24_SHOP_ID: Optional[str] = None
PAL24_SIGNATURE_TOKEN: Optional[str] = None
@@ -425,6 +430,7 @@ class Settings(BaseSettings):
PLATEGA_WEBHOOK_PORT: int = 8086
WATA_ENABLED: bool = False
WATA_DISPLAY_NAME: str = "Wata"
WATA_BASE_URL: str = "https://api.wata.pro/api/h2h"
WATA_ACCESS_TOKEN: Optional[str] = None
WATA_TERMINAL_PUBLIC_ID: Optional[str] = None
@@ -444,6 +450,7 @@ class Settings(BaseSettings):
# CloudPayments
CLOUDPAYMENTS_ENABLED: bool = False
CLOUDPAYMENTS_DISPLAY_NAME: str = "CloudPayments"
CLOUDPAYMENTS_PUBLIC_ID: Optional[str] = None
CLOUDPAYMENTS_API_SECRET: Optional[str] = None
CLOUDPAYMENTS_API_URL: str = "https://api.cloudpayments.ru"
@@ -1481,6 +1488,10 @@ class Settings(BaseSettings):
self.YOOKASSA_SHOP_ID is not None and
self.YOOKASSA_SECRET_KEY is not None)
def get_yookassa_display_name(self) -> str:
name = (self.YOOKASSA_DISPLAY_NAME or "").strip()
return name if name else "YooKassa"
def is_nalogo_enabled(self) -> bool:
return (self.NALOGO_ENABLED and
self.NALOGO_INN is not None and
@@ -1500,6 +1511,10 @@ class Settings(BaseSettings):
return (self.CRYPTOBOT_ENABLED and
self.CRYPTOBOT_API_TOKEN is not None)
def get_cryptobot_display_name(self) -> str:
name = (self.CRYPTOBOT_DISPLAY_NAME or "").strip()
return name if name else "CryptoBot"
def is_heleket_enabled(self) -> bool:
return (
self.HELEKET_ENABLED
@@ -1507,6 +1522,10 @@ class Settings(BaseSettings):
and self.HELEKET_API_KEY is not None
)
def get_heleket_display_name(self) -> str:
name = (self.HELEKET_DISPLAY_NAME or "").strip()
return name if name else "Heleket Crypto"
def is_mulenpay_enabled(self) -> bool:
return (
self.MULENPAY_ENABLED
@@ -1545,6 +1564,10 @@ class Settings(BaseSettings):
and self.PAL24_SHOP_ID is not None
)
def get_pal24_display_name(self) -> str:
name = (self.PAL24_DISPLAY_NAME or "").strip()
return name if name else "PAL24"
def is_platega_enabled(self) -> bool:
return (
self.PLATEGA_ENABLED
@@ -1629,6 +1652,10 @@ class Settings(BaseSettings):
and self.WATA_TERMINAL_PUBLIC_ID is not None
)
def get_wata_display_name(self) -> str:
name = (self.WATA_DISPLAY_NAME or "").strip()
return name if name else "Wata"
def is_cloudpayments_enabled(self) -> bool:
return (
self.CLOUDPAYMENTS_ENABLED
@@ -1636,6 +1663,10 @@ class Settings(BaseSettings):
and self.CLOUDPAYMENTS_API_SECRET is not None
)
def get_cloudpayments_display_name(self) -> str:
name = (self.CLOUDPAYMENTS_DISPLAY_NAME or "").strip()
return name if name else "CloudPayments"
def is_freekassa_enabled(self) -> bool:
return (
self.FREEKASSA_ENABLED
@@ -1931,7 +1962,11 @@ class Settings(BaseSettings):
def get_stars_rate(self) -> float:
return self.TELEGRAM_STARS_RATE_RUB
def get_telegram_stars_display_name(self) -> str:
name = (self.TELEGRAM_STARS_DISPLAY_NAME or "").strip()
return name if name else "Telegram Stars"
def stars_to_rubles(self, stars: int) -> float:
return stars * self.get_stars_rate()
+17 -1
View File
@@ -797,7 +797,7 @@ async def get_server_ids_by_uuids(
db: AsyncSession,
squad_uuids: List[str]
) -> List[int]:
result = await db.execute(
select(ServerSquad.id)
.where(ServerSquad.squad_uuid.in_(squad_uuids))
@@ -805,6 +805,22 @@ async def get_server_ids_by_uuids(
return [row[0] for row in result.fetchall()]
async def get_server_squads_by_uuids(
db: AsyncSession,
squad_uuids: List[str]
) -> List[ServerSquad]:
"""Получает список ServerSquad объектов по их UUID с загрузкой allowed_promo_groups."""
if not squad_uuids:
return []
result = await db.execute(
select(ServerSquad)
.options(selectinload(ServerSquad.allowed_promo_groups))
.where(ServerSquad.squad_uuid.in_(squad_uuids))
)
return list(result.scalars().all())
async def ensure_servers_synced(db: AsyncSession) -> None:
"""
Проверяет и синхронизирует серверы при запуске.
+53 -4
View File
@@ -1081,12 +1081,61 @@ async def get_server_monthly_price(
async def get_servers_monthly_prices(
db: AsyncSession,
server_squad_ids: List[int]
server_squad_ids: List[int],
*,
user: Optional["User"] = None,
) -> List[int]:
"""Получает месячные цены серверов с проверкой доступности для промогруппы пользователя."""
from app.database.models import ServerSquad
from sqlalchemy.orm import selectinload
prices = []
# Загружаем промогруппы пользователя если нужно
user_promo_group = None
user_promo_group_id = None
if user:
try:
# Пробуем загрузить промогруппы если ещё не загружены
await db.refresh(user, ["user_promo_groups", "promo_group"])
except Exception:
pass
try:
user_promo_group = user.get_primary_promo_group()
user_promo_group_id = user_promo_group.id if user_promo_group else None
except Exception as e:
logger.warning(f"Не удалось получить промогруппу пользователя: {e}")
for server_id in server_squad_ids:
price = await get_server_monthly_price(db, server_id)
prices.append(price)
# Загружаем сервер с промогруппами
result = await db.execute(
select(ServerSquad)
.options(selectinload(ServerSquad.allowed_promo_groups))
.where(ServerSquad.id == server_id)
)
server = result.scalar_one_or_none()
if not server:
prices.append(0)
continue
# Проверяем доступность сервера для промогруппы пользователя
is_allowed = True
if user_promo_group_id is not None and server.allowed_promo_groups:
allowed_ids = {pg.id for pg in server.allowed_promo_groups}
is_allowed = user_promo_group_id in allowed_ids
if server.is_available and is_allowed:
prices.append(server.price_kopeks)
else:
# Сервер недоступен для промогруппы пользователя
logger.warning(
f"⚠️ Сервер {server.display_name} (id={server_id}) недоступен для "
f"промогруппы пользователя (promo_group_id={user_promo_group_id}), "
f"allowed_promo_groups={[pg.id for pg in server.allowed_promo_groups] if server.allowed_promo_groups else []}"
)
prices.append(server.price_kopeks) # Всё равно берём реальную цену
return prices
def _get_discount_percent(
@@ -1146,7 +1195,7 @@ async def calculate_subscription_total_cost(
total_traffic_price = discounted_traffic_per_month * months_in_period
total_traffic_discount = traffic_discount_per_month * months_in_period
servers_prices = await get_servers_monthly_prices(db, server_squad_ids)
servers_prices = await get_servers_monthly_prices(db, server_squad_ids, user=user)
servers_price_per_month = sum(servers_prices)
servers_discount_percent = _get_discount_percent(
user,
+253
View File
@@ -0,0 +1,253 @@
"""CRUD operations for TicketNotification."""
import logging
from datetime import datetime
from typing import List, Optional
from sqlalchemy import select, func, desc, and_, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import TicketNotification, Ticket, User
logger = logging.getLogger(__name__)
class TicketNotificationCRUD:
"""CRUD operations for ticket notifications in cabinet."""
@staticmethod
async def get_by_id(db: AsyncSession, notification_id: int) -> Optional[TicketNotification]:
"""Get notification by ID."""
query = select(TicketNotification).where(TicketNotification.id == notification_id)
result = await db.execute(query)
return result.scalar_one_or_none()
@staticmethod
async def create(
db: AsyncSession,
ticket_id: int,
user_id: int,
notification_type: str,
message: Optional[str] = None,
is_for_admin: bool = False,
) -> TicketNotification:
"""Create a new ticket notification."""
notification = TicketNotification(
ticket_id=ticket_id,
user_id=user_id,
notification_type=notification_type,
message=message,
is_for_admin=is_for_admin,
is_read=False,
created_at=datetime.utcnow(),
)
db.add(notification)
await db.commit()
await db.refresh(notification)
return notification
@staticmethod
async def get_user_notifications(
db: AsyncSession,
user_id: int,
unread_only: bool = False,
limit: int = 50,
offset: int = 0,
) -> List[TicketNotification]:
"""Get notifications for a user (not admin)."""
query = (
select(TicketNotification)
.where(
TicketNotification.user_id == user_id,
TicketNotification.is_for_admin == False,
)
.options(selectinload(TicketNotification.ticket))
.order_by(desc(TicketNotification.created_at))
)
if unread_only:
query = query.where(TicketNotification.is_read == False)
query = query.offset(offset).limit(limit)
result = await db.execute(query)
return list(result.scalars().all())
@staticmethod
async def get_admin_notifications(
db: AsyncSession,
unread_only: bool = False,
limit: int = 50,
offset: int = 0,
) -> List[TicketNotification]:
"""Get notifications for admins."""
query = (
select(TicketNotification)
.where(TicketNotification.is_for_admin == True)
.options(selectinload(TicketNotification.ticket))
.order_by(desc(TicketNotification.created_at))
)
if unread_only:
query = query.where(TicketNotification.is_read == False)
query = query.offset(offset).limit(limit)
result = await db.execute(query)
return list(result.scalars().all())
@staticmethod
async def count_unread_user(db: AsyncSession, user_id: int) -> int:
"""Count unread notifications for a user."""
query = select(func.count()).select_from(TicketNotification).where(
TicketNotification.user_id == user_id,
TicketNotification.is_for_admin == False,
TicketNotification.is_read == False,
)
result = await db.execute(query)
return result.scalar() or 0
@staticmethod
async def count_unread_admin(db: AsyncSession) -> int:
"""Count unread notifications for admins."""
query = select(func.count()).select_from(TicketNotification).where(
TicketNotification.is_for_admin == True,
TicketNotification.is_read == False,
)
result = await db.execute(query)
return result.scalar() or 0
@staticmethod
async def mark_as_read(db: AsyncSession, notification_id: int) -> bool:
"""Mark a notification as read."""
query = (
update(TicketNotification)
.where(TicketNotification.id == notification_id)
.values(is_read=True, read_at=datetime.utcnow())
)
result = await db.execute(query)
await db.commit()
return result.rowcount > 0
@staticmethod
async def mark_all_as_read_user(db: AsyncSession, user_id: int) -> int:
"""Mark all notifications as read for a user."""
query = (
update(TicketNotification)
.where(
TicketNotification.user_id == user_id,
TicketNotification.is_for_admin == False,
TicketNotification.is_read == False,
)
.values(is_read=True, read_at=datetime.utcnow())
)
result = await db.execute(query)
await db.commit()
return result.rowcount
@staticmethod
async def mark_all_as_read_admin(db: AsyncSession) -> int:
"""Mark all admin notifications as read."""
query = (
update(TicketNotification)
.where(
TicketNotification.is_for_admin == True,
TicketNotification.is_read == False,
)
.values(is_read=True, read_at=datetime.utcnow())
)
result = await db.execute(query)
await db.commit()
return result.rowcount
@staticmethod
async def mark_ticket_notifications_as_read(
db: AsyncSession, ticket_id: int, user_id: int, is_admin: bool = False
) -> int:
"""Mark all notifications for a specific ticket as read."""
query = (
update(TicketNotification)
.where(
TicketNotification.ticket_id == ticket_id,
TicketNotification.is_read == False,
)
.values(is_read=True, read_at=datetime.utcnow())
)
if is_admin:
query = query.where(TicketNotification.is_for_admin == True)
else:
query = query.where(
TicketNotification.user_id == user_id,
TicketNotification.is_for_admin == False,
)
result = await db.execute(query)
await db.commit()
return result.rowcount
@staticmethod
async def create_admin_notification_for_new_ticket(
db: AsyncSession, ticket: Ticket
) -> Optional[TicketNotification]:
"""Create notification for admins about new ticket."""
from app.services.support_settings_service import SupportSettingsService
if not SupportSettingsService.get_cabinet_admin_notifications_enabled():
return None
title = (ticket.title or "").strip()[:50]
message = f"Новый тикет #{ticket.id}: {title}"
return await TicketNotificationCRUD.create(
db=db,
ticket_id=ticket.id,
user_id=ticket.user_id,
notification_type="new_ticket",
message=message,
is_for_admin=True,
)
@staticmethod
async def create_user_notification_for_admin_reply(
db: AsyncSession, ticket: Ticket, reply_preview: str
) -> Optional[TicketNotification]:
"""Create notification for user about admin reply."""
from app.services.support_settings_service import SupportSettingsService
if not SupportSettingsService.get_cabinet_user_notifications_enabled():
return None
preview = (reply_preview or "").strip()[:100]
message = f"Ответ на тикет #{ticket.id}: {preview}..."
return await TicketNotificationCRUD.create(
db=db,
ticket_id=ticket.id,
user_id=ticket.user_id,
notification_type="admin_reply",
message=message,
is_for_admin=False,
)
@staticmethod
async def create_admin_notification_for_user_reply(
db: AsyncSession, ticket: Ticket, reply_preview: str
) -> Optional[TicketNotification]:
"""Create notification for admins about user reply."""
from app.services.support_settings_service import SupportSettingsService
if not SupportSettingsService.get_cabinet_admin_notifications_enabled():
return None
preview = (reply_preview or "").strip()[:100]
message = f"Ответ в тикете #{ticket.id}: {preview}..."
return await TicketNotificationCRUD.create(
db=db,
ticket_id=ticket.id,
user_id=ticket.user_id,
notification_type="user_reply",
message=message,
is_for_admin=True,
)
+1 -1
View File
@@ -564,7 +564,7 @@ async def subtract_user_balance(
rollback_error,
)
logger.error(f" ✅ Средства списаны: {old_balance}{user.balance_kopeks}")
logger.info(f" ✅ Средства списаны: {old_balance}{user.balance_kopeks}")
return True
except Exception as e:
+4 -4
View File
@@ -40,10 +40,10 @@ if IS_SQLITE:
else:
poolclass = AsyncAdaptedQueuePool
pool_kwargs = {
"pool_size": 20,
"max_overflow": 30,
"pool_size": 30, # Увеличен с 20
"max_overflow": 50, # Увеличен с 30
"pool_timeout": 30,
"pool_recycle": 3600,
"pool_recycle": 1800, # Уменьшен с 3600 до 30 мин для более быстрого recycling
"pool_pre_ping": True,
# Агрессивная очистка мертвых соединений
"pool_reset_on_return": "rollback",
@@ -62,7 +62,7 @@ _pg_connect_args = {
"idle_in_transaction_session_timeout": "300000", # 5 минут
},
"command_timeout": 60,
"timeout": 10,
"timeout": 30, # Увеличен с 10 до 30 сек для высокой нагрузки
}
engine = create_async_engine(
+34
View File
@@ -2563,3 +2563,37 @@ class WheelSpin(Base):
def __repr__(self) -> str:
return f"<WheelSpin id={self.id} user_id={self.user_id} prize='{self.prize_display_name}'>"
class TicketNotification(Base):
"""Уведомления о тикетах для кабинета (веб-интерфейс)."""
__tablename__ = "ticket_notifications"
__table_args__ = (
Index("ix_ticket_notifications_user_read", "user_id", "is_read"),
Index("ix_ticket_notifications_admin_read", "is_for_admin", "is_read"),
)
id = Column(Integer, primary_key=True, index=True)
ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
# Тип уведомления: new_ticket, admin_reply, user_reply
notification_type = Column(String(50), nullable=False)
# Текст уведомления
message = Column(Text, nullable=True)
# Для админа или для пользователя
is_for_admin = Column(Boolean, default=False, nullable=False)
# Прочитано ли уведомление
is_read = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=func.now())
read_at = Column(DateTime, nullable=True)
ticket = relationship("Ticket", backref="notifications")
user = relationship("User", backref="ticket_notifications")
def __repr__(self) -> str:
return f"<TicketNotification id={self.id} type={self.notification_type} for_admin={self.is_for_admin}>"
+1 -1
View File
@@ -431,7 +431,7 @@ class RemnaWaveAPI:
data['telegramId'] = telegram_id
if email:
data['email'] = email
if hwid_device_limit:
if hwid_device_limit is not None:
data['hwidDeviceLimit'] = hwid_device_limit
if description:
data['description'] = description
+2 -2
View File
@@ -194,8 +194,8 @@ async def manual_start_round(
return
# Проверяем, есть ли уже активный раунд для этого шаблона
from app.database.crud.contest import get_active_rounds
exists = await get_active_rounds(db, tpl.id)
from app.database.crud.contest import get_active_round_by_template
exists = await get_active_round_by_template(db, tpl.id)
if exists:
await callback.answer(texts.t("ADMIN_ROUND_ALREADY_ACTIVE", "Раунд уже активен."), show_alert=True)
await show_daily_contest(callback, db_user, db)
+313
View File
@@ -1794,5 +1794,318 @@ async def process_notification_value_input(message: Message, state: FSMContext):
await state.clear()
# ============== Настройки мониторинга трафика ==============
def _format_traffic_toggle(enabled: bool) -> str:
return "🟢 Вкл" if enabled else "🔴 Выкл"
def _build_traffic_settings_keyboard() -> InlineKeyboardMarkup:
"""Строит клавиатуру настроек мониторинга трафика."""
fast_enabled = settings.TRAFFIC_FAST_CHECK_ENABLED
daily_enabled = settings.TRAFFIC_DAILY_CHECK_ENABLED
fast_interval = settings.TRAFFIC_FAST_CHECK_INTERVAL_MINUTES
fast_threshold = settings.TRAFFIC_FAST_CHECK_THRESHOLD_GB
daily_time = settings.TRAFFIC_DAILY_CHECK_TIME
daily_threshold = settings.TRAFFIC_DAILY_THRESHOLD_GB
cooldown = settings.TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=f"{_format_traffic_toggle(fast_enabled)} Быстрая проверка",
callback_data="admin_traffic_toggle_fast"
)],
[InlineKeyboardButton(
text=f"⏱ Интервал: {fast_interval} мин",
callback_data="admin_traffic_edit_fast_interval"
)],
[InlineKeyboardButton(
text=f"📊 Порог дельты: {fast_threshold} ГБ",
callback_data="admin_traffic_edit_fast_threshold"
)],
[InlineKeyboardButton(
text=f"{_format_traffic_toggle(daily_enabled)} Суточная проверка",
callback_data="admin_traffic_toggle_daily"
)],
[InlineKeyboardButton(
text=f"🕐 Время проверки: {daily_time}",
callback_data="admin_traffic_edit_daily_time"
)],
[InlineKeyboardButton(
text=f"📈 Суточный порог: {daily_threshold} ГБ",
callback_data="admin_traffic_edit_daily_threshold"
)],
[InlineKeyboardButton(
text=f"⏳ Кулдаун: {cooldown} мин",
callback_data="admin_traffic_edit_cooldown"
)],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")],
])
def _build_traffic_settings_text() -> str:
"""Строит текст настроек мониторинга трафика."""
fast_enabled = settings.TRAFFIC_FAST_CHECK_ENABLED
daily_enabled = settings.TRAFFIC_DAILY_CHECK_ENABLED
fast_status = _format_traffic_toggle(fast_enabled)
daily_status = _format_traffic_toggle(daily_enabled)
text = (
"⚙️ <b>Настройки мониторинга трафика</b>\n\n"
f"<b>Быстрая проверка:</b> {fast_status}\n"
f"• Интервал: {settings.TRAFFIC_FAST_CHECK_INTERVAL_MINUTES} мин\n"
f"• Порог дельты: {settings.TRAFFIC_FAST_CHECK_THRESHOLD_GB} ГБ\n\n"
f"<b>Суточная проверка:</b> {daily_status}\n"
f"• Время: {settings.TRAFFIC_DAILY_CHECK_TIME} UTC\n"
f"• Порог: {settings.TRAFFIC_DAILY_THRESHOLD_GB} ГБ\n\n"
f"<b>Общие:</b>\n"
f"• Кулдаун уведомлений: {settings.TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES} мин\n"
)
# Информация о фильтрах
monitored_nodes = settings.get_traffic_monitored_nodes()
ignored_nodes = settings.get_traffic_ignored_nodes()
excluded_uuids = settings.get_traffic_excluded_user_uuids()
if monitored_nodes:
text += f"• Мониторим только: {len(monitored_nodes)} нод(ы)\n"
if ignored_nodes:
text += f"• Игнорируем: {len(ignored_nodes)} нод(ы)\n"
if excluded_uuids:
text += f"• Исключено юзеров: {len(excluded_uuids)}\n"
return text
@router.callback_query(F.data == "admin_mon_traffic_settings")
@admin_required
async def admin_traffic_settings(callback: CallbackQuery):
"""Показывает настройки мониторинга трафика."""
try:
text = _build_traffic_settings_text()
keyboard = _build_traffic_settings_keyboard()
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка отображения настроек трафика: {e}")
await callback.answer("❌ Ошибка загрузки настроек", show_alert=True)
@router.callback_query(F.data == "admin_traffic_toggle_fast")
@admin_required
async def toggle_fast_check(callback: CallbackQuery):
"""Переключает быструю проверку трафика."""
try:
from app.services.system_settings_service import BotConfigurationService
current = settings.TRAFFIC_FAST_CHECK_ENABLED
new_value = not current
async with AsyncSessionLocal() as db:
await BotConfigurationService.set_value(db, "TRAFFIC_FAST_CHECK_ENABLED", new_value)
await db.commit()
await callback.answer("✅ Включено" if new_value else "⏸️ Отключено")
# Обновляем отображение
text = _build_traffic_settings_text()
keyboard = _build_traffic_settings_keyboard()
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка переключения быстрой проверки: {e}")
await callback.answer("❌ Ошибка", show_alert=True)
@router.callback_query(F.data == "admin_traffic_toggle_daily")
@admin_required
async def toggle_daily_check(callback: CallbackQuery):
"""Переключает суточную проверку трафика."""
try:
from app.services.system_settings_service import BotConfigurationService
current = settings.TRAFFIC_DAILY_CHECK_ENABLED
new_value = not current
async with AsyncSessionLocal() as db:
await BotConfigurationService.set_value(db, "TRAFFIC_DAILY_CHECK_ENABLED", new_value)
await db.commit()
await callback.answer("✅ Включено" if new_value else "⏸️ Отключено")
text = _build_traffic_settings_text()
keyboard = _build_traffic_settings_keyboard()
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка переключения суточной проверки: {e}")
await callback.answer("❌ Ошибка", show_alert=True)
@router.callback_query(F.data == "admin_traffic_edit_fast_interval")
@admin_required
async def edit_fast_interval(callback: CallbackQuery, state: FSMContext):
"""Начинает редактирование интервала быстрой проверки."""
await state.set_state(AdminStates.editing_traffic_setting)
await state.update_data(
traffic_setting_key="TRAFFIC_FAST_CHECK_INTERVAL_MINUTES",
traffic_setting_type="int",
settings_message_chat=callback.message.chat.id,
settings_message_id=callback.message.message_id,
)
await callback.answer()
await callback.message.answer(
"⏱ Введите интервал быстрой проверки в минутах (минимум 1):"
)
@router.callback_query(F.data == "admin_traffic_edit_fast_threshold")
@admin_required
async def edit_fast_threshold(callback: CallbackQuery, state: FSMContext):
"""Начинает редактирование порога быстрой проверки."""
await state.set_state(AdminStates.editing_traffic_setting)
await state.update_data(
traffic_setting_key="TRAFFIC_FAST_CHECK_THRESHOLD_GB",
traffic_setting_type="float",
settings_message_chat=callback.message.chat.id,
settings_message_id=callback.message.message_id,
)
await callback.answer()
await callback.message.answer(
"📊 Введите порог дельты трафика в ГБ (например: 5.0):"
)
@router.callback_query(F.data == "admin_traffic_edit_daily_time")
@admin_required
async def edit_daily_time(callback: CallbackQuery, state: FSMContext):
"""Начинает редактирование времени суточной проверки."""
await state.set_state(AdminStates.editing_traffic_setting)
await state.update_data(
traffic_setting_key="TRAFFIC_DAILY_CHECK_TIME",
traffic_setting_type="time",
settings_message_chat=callback.message.chat.id,
settings_message_id=callback.message.message_id,
)
await callback.answer()
await callback.message.answer(
"🕐 Введите время суточной проверки в формате HH:MM (UTC):\n"
"Например: 00:00, 03:00, 12:30"
)
@router.callback_query(F.data == "admin_traffic_edit_daily_threshold")
@admin_required
async def edit_daily_threshold(callback: CallbackQuery, state: FSMContext):
"""Начинает редактирование суточного порога."""
await state.set_state(AdminStates.editing_traffic_setting)
await state.update_data(
traffic_setting_key="TRAFFIC_DAILY_THRESHOLD_GB",
traffic_setting_type="float",
settings_message_chat=callback.message.chat.id,
settings_message_id=callback.message.message_id,
)
await callback.answer()
await callback.message.answer(
"📈 Введите суточный порог трафика в ГБ (например: 50.0):"
)
@router.callback_query(F.data == "admin_traffic_edit_cooldown")
@admin_required
async def edit_cooldown(callback: CallbackQuery, state: FSMContext):
"""Начинает редактирование кулдауна уведомлений."""
await state.set_state(AdminStates.editing_traffic_setting)
await state.update_data(
traffic_setting_key="TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES",
traffic_setting_type="int",
settings_message_chat=callback.message.chat.id,
settings_message_id=callback.message.message_id,
)
await callback.answer()
await callback.message.answer(
"⏳ Введите кулдаун уведомлений в минутах (минимум 1):"
)
@router.message(AdminStates.editing_traffic_setting)
async def process_traffic_setting_input(message: Message, state: FSMContext):
"""Обрабатывает ввод настройки мониторинга трафика."""
from app.services.system_settings_service import BotConfigurationService
data = await state.get_data()
if not data:
await state.clear()
await message.answer("ℹ️ Контекст утерян, попробуйте снова из меню настроек.")
return
raw_value = (message.text or "").strip()
setting_key = data.get("traffic_setting_key")
setting_type = data.get("traffic_setting_type")
# Валидация и парсинг значения
try:
if setting_type == "int":
value = int(raw_value)
if value < 1:
raise ValueError("Значение должно быть >= 1")
elif setting_type == "float":
value = float(raw_value.replace(",", "."))
if value <= 0:
raise ValueError("Значение должно быть > 0")
elif setting_type == "time":
# Валидация формата HH:MM
import re
if not re.match(r"^\d{1,2}:\d{2}$", raw_value):
raise ValueError("Неверный формат времени. Используйте HH:MM")
parts = raw_value.split(":")
hours, minutes = int(parts[0]), int(parts[1])
if hours < 0 or hours > 23 or minutes < 0 or minutes > 59:
raise ValueError("Неверное время")
value = f"{hours:02d}:{minutes:02d}"
else:
value = raw_value
except ValueError as e:
await message.answer(f"{str(e)}")
return
# Сохраняем значение
try:
async with AsyncSessionLocal() as db:
await BotConfigurationService.set_value(db, setting_key, value)
await db.commit()
back_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="⬅️ К настройкам трафика", callback_data="admin_mon_traffic_settings")]
]
)
await message.answer("✅ Настройка сохранена!", reply_markup=back_keyboard)
# Обновляем исходное сообщение с настройками
chat_id = data.get("settings_message_chat")
message_id = data.get("settings_message_id")
if chat_id and message_id:
try:
text = _build_traffic_settings_text()
keyboard = _build_traffic_settings_keyboard()
await message.bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=text,
parse_mode="HTML",
reply_markup=keyboard
)
except Exception:
pass # Игнорируем если сообщение уже удалено
except Exception as e:
logger.error(f"Ошибка сохранения настройки трафика: {e}")
await message.answer(f"❌ Ошибка сохранения: {str(e)}")
await state.clear()
def register_handlers(dp):
dp.include_router(router)
+19 -1
View File
@@ -1986,9 +1986,18 @@ async def required_sub_channel_check(
await state.set_data(state_data)
if settings.SKIP_RULES_ACCEPT:
if settings.SKIP_REFERRAL_CODE:
if settings.SKIP_REFERRAL_CODE or state_data.get('referral_code'):
from app.utils.user_utils import generate_unique_referral_code
# Проверяем реферальный код из ссылки
referrer_id = None
ref_code_from_link = state_data.get('referral_code')
if ref_code_from_link:
referrer = await get_user_by_referral_code(db, ref_code_from_link)
if referrer:
referrer_id = referrer.id
logger.info(f"✅ CHANNEL CHECK: Реферер найден из ссылки: {referrer.id}")
referral_code = await generate_unique_referral_code(db, query.from_user.id)
user = await create_user(
@@ -1999,9 +2008,18 @@ async def required_sub_channel_check(
last_name=query.from_user.last_name,
language=language,
referral_code=referral_code,
referred_by_id=referrer_id,
)
await db.refresh(user, ['subscription'])
# Обрабатываем реферальную регистрацию
if referrer_id:
try:
await process_referral_registration(db, user.id, referrer_id, bot)
logger.info(f"✅ CHANNEL CHECK: Реферальная регистрация обработана для {user.id}")
except Exception as e:
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
# Показываем главное меню после создания пользователя
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
user.subscription
+63 -4
View File
@@ -771,6 +771,13 @@ def _get_trial_payment_keyboard(language: str, can_pay_from_balance: bool = Fals
callback_data="trial_payment_wata"
)])
if settings.is_platega_enabled():
platega_name = settings.get_platega_display_name()
keyboard.append([types.InlineKeyboardButton(
text=f"💳 {platega_name}",
callback_data="trial_payment_platega"
)])
# Кнопка назад
keyboard.append([types.InlineKeyboardButton(
text=texts.BACK,
@@ -1430,6 +1437,13 @@ async def return_to_saved_cart(
texts = get_texts(db_user.language)
# Проверяем режим корзины - если это тарифная корзина, перенаправляем на соответствующий обработчик
cart_mode = cart_data.get('cart_mode')
if cart_mode in ('tariff_purchase', 'daily_tariff_purchase', 'extend') and cart_data.get('tariff_id'):
from .tariff_purchase import return_to_saved_tariff_cart
await return_to_saved_tariff_cart(callback, state, db_user, db, cart_data)
return
preserved_metadata_keys = {
'saved_cart',
'missing_amount',
@@ -2871,19 +2885,19 @@ async def confirm_purchase(
await db.refresh(db_user)
subscription_service = SubscriptionService()
# При покупке подписки ВСЕГДА сбрасываем трафик в панели
if db_user.remnawave_uuid:
remnawave_user = await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_traffic=True,
reset_reason="покупка подписки",
)
else:
remnawave_user = await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_traffic=True,
reset_reason="покупка подписки",
)
@@ -2892,7 +2906,7 @@ async def confirm_purchase(
remnawave_user = await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_traffic=True,
reset_reason="покупка подписки (повторная попытка)",
)
@@ -3758,6 +3772,7 @@ async def handle_trial_payment_method(
elif payment_method == "yookassa_sbp":
# Оплата через YooKassa СБП
payment_result = await payment_service.create_yookassa_sbp_payment(
db=db,
amount_kopeks=trial_price_kopeks,
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
days=settings.TRIAL_DURATION_DAYS
@@ -4009,6 +4024,50 @@ async def handle_trial_payment_method(
parse_mode="HTML",
)
elif payment_method == "platega":
# Оплата через Platega
active_methods = settings.get_platega_active_methods()
if not active_methods:
await callback.answer("❌ Platega не настроена", show_alert=True)
return
# Используем первый активный метод
method_code = active_methods[0]
payment_result = await payment_service.create_platega_payment(
db=db,
user_id=db_user.id,
amount_kopeks=trial_price_kopeks,
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
days=settings.TRIAL_DURATION_DAYS
),
language=db_user.language,
payment_method_code=method_code,
)
if not payment_result or not payment_result.get("redirect_url"):
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
return
platega_name = settings.get_platega_display_name()
await callback.message.edit_text(
texts.t(
"PAID_TRIAL_PLATEGA",
"💳 <b>Оплата через {provider}</b>\n\n"
"Нажмите кнопку ниже для перехода к оплате.\n\n"
"💰 Сумма: {amount}"
).format(provider=platega_name, amount=settings.format_price(trial_price_kopeks)),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["redirect_url"])],
[InlineKeyboardButton(
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
callback_data=f"check_trial_platega_{pending_subscription.id}"
)],
[InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
]),
parse_mode="HTML",
)
else:
await callback.answer(f"❌ Неизвестный метод оплаты: {payment_method}", show_alert=True)
return
+216 -7
View File
@@ -621,13 +621,33 @@ async def select_tariff(
)
else:
missing = daily_price - user_balance
# Сохраняем данные корзины для автопокупки суточного тарифа
cart_data = {
'cart_mode': 'daily_tariff_purchase',
'tariff_id': tariff_id,
'is_daily': True,
'daily_price_kopeks': daily_price,
'total_price': daily_price,
'user_id': db_user.id,
'saved_cart': True,
'missing_amount': missing,
'return_to_cart': True,
'description': f"Покупка суточного тарифа {tariff.name}",
'traffic_limit_gb': tariff.traffic_limit_gb,
'device_limit': tariff.device_limit,
'allowed_squads': tariff.allowed_squads or [],
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
await callback.message.edit_text(
f"❌ <b>Недостаточно средств</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"🔄 Тип: Суточный\n"
f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>\n\n"
f"🛒 <i>Корзина сохранена! После пополнения баланса подписка будет оформлена автоматически.</i>",
reply_markup=get_daily_tariff_insufficient_balance_keyboard(tariff_id, db_user.language),
parse_mode="HTML"
)
@@ -909,12 +929,13 @@ async def handle_custom_confirm(
)
# Обновляем пользователя в Remnawave
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_traffic=True,
reset_reason="покупка тарифа",
)
except Exception as e:
@@ -1087,15 +1108,35 @@ async def select_tariff_period(
parse_mode="HTML"
)
else:
# Недостаточно средств
# Недостаточно средств - сохраняем корзину для автопокупки
missing = final_price - user_balance
# Сохраняем данные корзины для автопокупки после пополнения
cart_data = {
'cart_mode': 'tariff_purchase',
'tariff_id': tariff_id,
'period_days': period,
'total_price': final_price,
'user_id': db_user.id,
'saved_cart': True,
'missing_amount': missing,
'return_to_cart': True,
'description': f"Покупка тарифа {tariff.name} на {period} дней",
'traffic_limit_gb': tariff.traffic_limit_gb,
'device_limit': tariff.device_limit,
'allowed_squads': tariff.allowed_squads or [],
'discount_percent': discount_percent,
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
await callback.message.edit_text(
f"❌ <b>Недостаточно средств</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"📅 Период: {_format_period(period)}\n"
f"💰 Стоимость: {_format_price_kopeks(final_price)}\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>\n\n"
f"🛒 <i>Корзина сохранена! После пополнения баланса подписка будет оформлена автоматически.</i>",
reply_markup=get_tariff_insufficient_balance_keyboard(tariff_id, period, db_user.language),
parse_mode="HTML"
)
@@ -1188,12 +1229,13 @@ async def confirm_tariff_purchase(
)
# Обновляем пользователя в Remnawave
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_traffic=True,
reset_reason="покупка тарифа",
)
except Exception as e:
@@ -1355,12 +1397,13 @@ async def confirm_daily_tariff_purchase(
await db.refresh(subscription)
# Обновляем пользователя в Remnawave
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_traffic=True,
reset_reason="покупка суточного тарифа",
)
except Exception as e:
@@ -1584,13 +1627,37 @@ async def select_tariff_extend_period(
)
else:
missing = final_price - user_balance
# Получаем текущую подписку для сохранения в корзину
subscription = await get_subscription_by_user_id(db, db_user.id)
# Сохраняем данные корзины для автопокупки после пополнения
cart_data = {
'cart_mode': 'extend',
'tariff_id': tariff_id,
'subscription_id': subscription.id if subscription else None,
'period_days': period,
'total_price': final_price,
'user_id': db_user.id,
'saved_cart': True,
'missing_amount': missing,
'return_to_cart': True,
'description': f"Продление тарифа {tariff.name} на {period} дней",
'traffic_limit_gb': tariff.traffic_limit_gb,
'device_limit': tariff.device_limit,
'allowed_squads': tariff.allowed_squads or [],
'discount_percent': discount_percent,
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
await callback.message.edit_text(
f"❌ <b>Недостаточно средств</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"📅 Период: {_format_period(period)}\n"
f"💰 К оплате: {_format_price_kopeks(final_price)}\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>\n\n"
f"🛒 <i>Корзина сохранена! После пополнения баланса подписка будет продлена автоматически.</i>",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
[InlineKeyboardButton(text=texts.BACK, callback_data="subscription_extend")]
@@ -3073,6 +3140,148 @@ async def confirm_instant_switch(
await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True)
async def return_to_saved_tariff_cart(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession,
cart_data: dict,
):
"""Восстанавливает сохраненную корзину тарифа после пополнения баланса."""
texts = get_texts(db_user.language)
cart_mode = cart_data.get('cart_mode')
tariff_id = cart_data.get('tariff_id')
if not tariff_id:
await callback.answer("❌ Данные корзины повреждены", show_alert=True)
return
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not tariff.is_active:
await callback.answer("❌ Тариф больше недоступен", show_alert=True)
# Очищаем корзину
await user_cart_service.delete_user_cart(db_user.id)
return
total_price = cart_data.get('total_price', 0)
user_balance = db_user.balance_kopeks or 0
traffic = _format_traffic(tariff.traffic_limit_gb)
# Проверяем баланс
if user_balance < total_price:
missing = total_price - user_balance
if cart_mode == 'daily_tariff_purchase':
await callback.message.edit_text(
f"❌ <b>Все еще недостаточно средств</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"🔄 Тип: Суточный\n"
f"💰 Стоимость: {_format_price_kopeks(total_price)}\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
reply_markup=get_daily_tariff_insufficient_balance_keyboard(tariff_id, db_user.language),
parse_mode="HTML"
)
elif cart_mode == 'extend':
period = cart_data.get('period_days', 30)
await callback.message.edit_text(
f"❌ <b>Все еще недостаточно средств</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"📅 Период: {_format_period(period)}\n"
f"💰 Стоимость: {_format_price_kopeks(total_price)}\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
reply_markup=get_tariff_insufficient_balance_keyboard(tariff_id, period, db_user.language),
parse_mode="HTML"
)
else: # tariff_purchase
period = cart_data.get('period_days', 30)
await callback.message.edit_text(
f"❌ <b>Все еще недостаточно средств</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"📅 Период: {_format_period(period)}\n"
f"💰 Стоимость: {_format_price_kopeks(total_price)}\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
reply_markup=get_tariff_insufficient_balance_keyboard(tariff_id, period, db_user.language),
parse_mode="HTML"
)
await callback.answer()
return
# Баланс достаточен - показываем подтверждение
discount_percent = cart_data.get('discount_percent', 0)
if cart_mode == 'daily_tariff_purchase':
daily_price = cart_data.get('daily_price_kopeks', total_price)
await callback.message.edit_text(
f"✅ <b>Подтверждение покупки</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"📊 Трафик: {traffic}\n"
f"📱 Устройств: {tariff.device_limit}\n"
f"🔄 Тип: Суточный\n"
f"💰 <b>Стоимость в день: {_format_price_kopeks(daily_price)}</b>\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"После оплаты: {_format_price_kopeks(user_balance - daily_price)}",
reply_markup=get_daily_tariff_confirm_keyboard(tariff_id, db_user.language),
parse_mode="HTML"
)
elif cart_mode == 'extend':
period = cart_data.get('period_days', 30)
discount_text = ""
if discount_percent > 0:
original_price = int(total_price / (1 - discount_percent / 100))
discount_text = f"\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(original_price - total_price)})"
await callback.message.edit_text(
f"✅ <b>Подтверждение продления</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"📊 Трафик: {traffic}\n"
f"📱 Устройств: {tariff.device_limit}\n"
f"📅 Период: {_format_period(period)}\n"
f"{discount_text}\n"
f"💰 <b>Итого: {_format_price_kopeks(total_price)}</b>\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"После оплаты: {_format_price_kopeks(user_balance - total_price)}",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="✅ Подтвердить продление",
callback_data=f"tariff_ext_confirm:{tariff_id}:{period}"
)],
[InlineKeyboardButton(
text=texts.BACK,
callback_data=f"tariff_extend:{tariff_id}"
)]
]),
parse_mode="HTML"
)
else: # tariff_purchase
period = cart_data.get('period_days', 30)
discount_text = ""
if discount_percent > 0:
original_price = int(total_price / (1 - discount_percent / 100))
discount_text = f"\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(original_price - total_price)})"
await callback.message.edit_text(
f"✅ <b>Подтверждение покупки</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"📊 Трафик: {traffic}\n"
f"📱 Устройств: {tariff.device_limit}\n"
f"📅 Период: {_format_period(period)}\n"
f"{discount_text}\n"
f"💰 <b>Итого: {_format_price_kopeks(total_price)}</b>\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"После оплаты: {_format_price_kopeks(user_balance - total_price)}",
reply_markup=get_tariff_confirm_keyboard(tariff_id, period, db_user.language),
parse_mode="HTML"
)
await callback.answer("✅ Корзина восстановлена!")
def register_tariff_purchase_handlers(dp: Dispatcher):
"""Регистрирует обработчики покупки по тарифам."""
# Список тарифов (для режима tariffs)
+9 -2
View File
@@ -571,11 +571,14 @@ def get_daily_contest_manage_keyboard(
InlineKeyboardButton(text=_t(texts, "ADMIN_CONTEST_START_MANUAL", "🧪 Ручной старт"), callback_data=f"admin_daily_manual_{template_id}"),
],
[
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PRIZE", "🏅 Приз (дни)"), callback_data=f"admin_daily_edit_{template_id}_prize_days"),
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_MAX_WINNERS", "👥 Победителей"), callback_data=f"admin_daily_edit_{template_id}_max_winners"),
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PRIZE_TYPE", "🏅 Тип приза"), callback_data=f"admin_daily_edit_{template_id}_prize_type"),
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PRIZE_VALUE", "💰 Значение приза"), callback_data=f"admin_daily_edit_{template_id}_prize_value"),
],
[
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_MAX_WINNERS", "👥 Победителей"), callback_data=f"admin_daily_edit_{template_id}_max_winners"),
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_ATTEMPTS", "🔁 Попытки"), callback_data=f"admin_daily_edit_{template_id}_attempts_per_user"),
],
[
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_TIMES", "⏰ Раундов/день"), callback_data=f"admin_daily_edit_{template_id}_times_per_day"),
],
[
@@ -1776,6 +1779,10 @@ def get_monitoring_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
InlineKeyboardButton(
text=_t(texts, "ADMIN_MONITORING_TEST_NOTIFICATIONS", "🧪 Тест уведомлений"),
callback_data="admin_mon_test_notifications"
),
InlineKeyboardButton(
text="⚙️ Настройки трафика",
callback_data="admin_mon_traffic_settings"
)
],
[
+21
View File
@@ -1552,6 +1552,27 @@
"TRIAL_INACTIVE_24H": "⏳ <b>A full day passed without activity</b>\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!",
"TRIAL_SERVER_DEFAULT_NAME": "🎯 Trial server",
"TRIAL_SERVER_RANDOM_POOL": "🎲 Random choice among {count} servers",
"PAID_TRIAL_HEADER": "⚡ <b>Trial Subscription</b>",
"PAID_TRIAL_SELECT_PAYMENT": "Choose a payment method:",
"PAID_TRIAL_CAN_PAY_BALANCE": "You can pay for the trial from your balance or choose another payment method.",
"PAID_TRIAL_PAYMENT_DESC": "Trial subscription for {days} days",
"PAID_TRIAL_INVOICE_TITLE": "Trial subscription for {days} days",
"PAID_TRIAL_STARS_LABEL": "Trial subscription",
"PAID_TRIAL_STARS_WAITING": "⭐ To pay for the trial subscription, click the payment button in the message above.\n\nAfter successful payment, the subscription will be activated automatically.",
"PAID_TRIAL_YOOKASSA_SBP": "🏦 <b>SBP Payment</b>\n\nScan the QR code or follow the link to pay.\n\n💰 Amount: {amount}",
"PAID_TRIAL_YOOKASSA_CARD": "💳 <b>Card Payment</b>\n\nClick the button below to proceed to payment.\n\n💰 Amount: {amount}",
"PAID_TRIAL_CRYPTOBOT": "🪙 <b>CryptoBot Payment</b>\n\nClick the button below to proceed to payment.\n\n💰 Amount: {amount}",
"PAID_TRIAL_HELEKET": "🪙 <b>Heleket Payment</b>\n\nClick the button below to proceed to payment.\n\n💰 Amount: {amount}",
"PAID_TRIAL_MULENPAY": "💳 <b>{name} Payment</b>\n\nClick the button below to proceed to payment.\n\n💰 Amount: {amount}",
"PAID_TRIAL_PAL24": "💳 <b>PayPalych Payment</b>\n\nClick the button below to proceed to payment.\n\n💰 Amount: {amount}",
"PAID_TRIAL_WATA": "💳 <b>WATA Payment</b>\n\nClick the button below to proceed to payment.\n\n💰 Amount: {amount}",
"PAID_TRIAL_PAY_BALANCE": "💳 Pay from balance",
"PAID_TRIAL_BALANCE_SUCCESS": "✅ Trial subscription paid successfully!\n\nActivation in progress...",
"PERIOD": "Period",
"TRAFFIC": "Traffic",
"DEVICES": "Devices",
"PRICE": "Price",
"YOUR_BALANCE": "Your balance",
"UNBLOCK": "✅ Unblock",
"UNKNOWN_CALLBACK_ALERT": "❓ Unknown action. Please try again.",
"UNKNOWN_COMMAND_MESSAGE": "❓ I didn't understand that command. Use the menu buttons.",
+21
View File
@@ -1569,6 +1569,27 @@
"TRIAL_INACTIVE_24H": "⏳ <b>Прошли сутки с начала теста</b>\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!",
"TRIAL_SERVER_DEFAULT_NAME": "🎯 Тестовый сервер",
"TRIAL_SERVER_RANDOM_POOL": "🎲 Случайный из {count} серверов",
"PAID_TRIAL_HEADER": "⚡ <b>Пробная подписка</b>",
"PAID_TRIAL_SELECT_PAYMENT": "Выберите подходящий способ оплаты:",
"PAID_TRIAL_CAN_PAY_BALANCE": "Вы можете оплатить пробную подписку с баланса или выбрать другой способ оплаты.",
"PAID_TRIAL_PAYMENT_DESC": "Пробная подписка на {days} дней",
"PAID_TRIAL_INVOICE_TITLE": "Пробная подписка на {days} дней",
"PAID_TRIAL_STARS_LABEL": "Пробная подписка",
"PAID_TRIAL_STARS_WAITING": "⭐ Для оплаты пробной подписки нажмите кнопку оплаты в сообщении выше.\n\nПосле успешной оплаты подписка будет активирована автоматически.",
"PAID_TRIAL_YOOKASSA_SBP": "🏦 <b>Оплата через СБП</b>\n\nОтсканируйте QR-код или перейдите по ссылке для оплаты.\n\n💰 Сумма: {amount}",
"PAID_TRIAL_YOOKASSA_CARD": "💳 <b>Оплата картой</b>\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}",
"PAID_TRIAL_CRYPTOBOT": "🪙 <b>Оплата через CryptoBot</b>\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}",
"PAID_TRIAL_HELEKET": "🪙 <b>Оплата через Heleket</b>\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}",
"PAID_TRIAL_MULENPAY": "💳 <b>Оплата через {name}</b>\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}",
"PAID_TRIAL_PAL24": "💳 <b>Оплата через PayPalych</b>\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}",
"PAID_TRIAL_WATA": "💳 <b>Оплата через WATA</b>\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}",
"PAID_TRIAL_PAY_BALANCE": "💳 Оплатить с баланса",
"PAID_TRIAL_BALANCE_SUCCESS": "✅ Пробная подписка успешно оплачена!\n\nАктивация выполняется...",
"PERIOD": "Период",
"TRAFFIC": "Трафик",
"DEVICES": "Устройства",
"PRICE": "Стоимость",
"YOUR_BALANCE": "Ваш баланс",
"UNBLOCK": "✅ Разблокировать",
"UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.",
"UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.",
+4 -7
View File
@@ -44,13 +44,10 @@ class AuthMiddleware(BaseMiddleware):
data: Dict[str, Any]
) -> Any:
# Глобальная проверка: если callback с недоступным сообщением — игнорируем
if isinstance(event, CallbackQuery) and isinstance(event.message, InaccessibleMessage):
try:
await event.answer()
except Exception:
pass
return None
# Callback с недоступным сообщением (>48ч) — пропускаем к хендлерам,
# они сами отправят новое сообщение через edit_or_answer_photo
# if isinstance(event, CallbackQuery) and isinstance(event.message, InaccessibleMessage):
# pass # Раньше здесь был return None, теперь пропускаем дальше
user: TgUser = None
if isinstance(event, (Message, CallbackQuery)):
+13 -1
View File
@@ -25,11 +25,15 @@ class GlobalErrorMiddleware(BaseMiddleware):
async def _handle_telegram_error(self, event: TelegramObject, error: TelegramBadRequest):
error_message = str(error).lower()
if self._is_old_query_error(error_message):
return await self._handle_old_query(event, error)
elif self._is_message_not_modified_error(error_message):
return await self._handle_message_not_modified(event, error)
elif self._is_topic_required_error(error_message):
# Канал с топиками — просто игнорируем
logger.debug(f"📋 [GlobalErrorMiddleware] Игнорируем ошибку топика: {error}")
return None
elif self._is_bad_request_error(error_message):
return await self._handle_bad_request(event, error)
else:
@@ -53,6 +57,14 @@ class GlobalErrorMiddleware(BaseMiddleware):
"bot was blocked by the user",
"user is deactivated"
])
def _is_topic_required_error(self, error_message: str) -> bool:
return any(phrase in error_message for phrase in [
"topic must be specified",
"topic_closed",
"topic_deleted",
"forum_closed"
])
async def _handle_old_query(self, event: TelegramObject, error: TelegramBadRequest):
if isinstance(event, CallbackQuery):
+1 -1
View File
@@ -266,7 +266,7 @@ class FreekassaService:
params["i"] = ps_id
query = "&".join(f"{k}={v}" for k, v in params.items())
return f"https://pay.freekassa.ru/?{query}"
return f"https://pay.fk.money/?{query}"
async def create_order(
self,
+13 -6
View File
@@ -284,12 +284,18 @@ class MonitoringService:
if not user or not user.remnawave_uuid:
logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}")
return None
# Обновляем subscription в сессии, чтобы избежать detached instance
try:
await db.refresh(subscription)
except Exception:
pass
current_time = datetime.utcnow()
is_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
is_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date > current_time)
if (subscription.status == SubscriptionStatus.ACTIVE.value and
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
subscription.status = SubscriptionStatus.EXPIRED.value
await db.commit()
@@ -581,8 +587,9 @@ class MonitoringService:
)
continue
except TelegramBadRequest as error:
logger.error(
"❌ Ошибка Telegram при проверке подписки пользователя %s: %s",
# PARTICIPANT_ID_INVALID - пользователь никогда не был в канале, это нормально
logger.warning(
"⚠️ Ошибка Telegram при проверке подписки пользователя %s: %s",
user.telegram_id,
error,
)
+3 -1
View File
@@ -260,7 +260,9 @@ class CloudPaymentsPaymentMixin:
# Auto-purchase if enabled
auto_purchase_success = False
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user)
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db, user, bot=getattr(self, "bot", None)
)
except Exception as error:
logger.exception("Ошибка автопокупки после CloudPayments: %s", error)
+6 -3
View File
@@ -122,11 +122,13 @@ class YooKassaPaymentMixin:
except Exception as e:
logger.warning(f"Не удалось получить telegram_id для user_id={user_id}: {e}")
# Preserve existing type from metadata if passed (e.g., "trial")
existing_type = payment_metadata.get("type")
payment_metadata.update(
{
"user_id": str(user_id),
"amount_kopeks": str(amount_kopeks),
"type": "balance_topup",
"type": existing_type or "balance_topup",
}
)
@@ -225,11 +227,13 @@ class YooKassaPaymentMixin:
except Exception as e:
logger.warning(f"Не удалось получить telegram_id для user_id={user_id}: {e}")
# Preserve existing type from metadata if passed (e.g., "trial")
existing_type = payment_metadata.get("type")
payment_metadata.update(
{
"user_id": str(user_id),
"amount_kopeks": str(amount_kopeks),
"type": "balance_topup_sbp",
"type": existing_type or "balance_topup_sbp",
}
)
@@ -664,7 +668,6 @@ class YooKassaPaymentMixin:
# Уведомление пользователю
if getattr(self, "bot", None):
try:
from app.config import settings
await self.bot.send_message(
chat_id=user.telegram_id,
text=(
@@ -56,6 +56,8 @@ class AutoExtendContext:
traffic_limit_gb: Optional[int] = None
squad_uuid: Optional[str] = None
consume_promo_offer: bool = False
tariff_id: Optional[int] = None
allowed_squads: Optional[list] = None
async def _prepare_auto_purchase(
@@ -273,6 +275,7 @@ async def _prepare_auto_extend_context(
squad_uuid = cart_data.get("squad_uuid")
consume_promo_offer = bool(cart_data.get("consume_promo_offer"))
allowed_squads = cart_data.get("allowed_squads")
return AutoExtendContext(
subscription=subscription,
@@ -283,16 +286,26 @@ async def _prepare_auto_extend_context(
traffic_limit_gb=traffic_limit_gb,
squad_uuid=squad_uuid,
consume_promo_offer=consume_promo_offer,
tariff_id=tariff_id,
allowed_squads=allowed_squads,
)
def _apply_extension_updates(context: AutoExtendContext) -> None:
"""
Применяет обновления лимитов подписки (трафик, устройства, серверы).
Применяет обновления лимитов подписки (трафик, устройства, серверы, тариф).
НЕ изменяет is_trial - это делается позже после успешного коммита продления.
"""
subscription = context.subscription
# Обновляем tariff_id если указан в контексте
if context.tariff_id is not None:
subscription.tariff_id = context.tariff_id
# Обновляем allowed_squads если указаны (заменяем полностью)
if context.allowed_squads is not None:
subscription.connected_squads = context.allowed_squads
# Обновляем лимиты для триальной подписки
if subscription.is_trial:
# НЕ удаляем триал здесь! Это будет сделано после успешного extend_subscription()
@@ -373,14 +386,25 @@ async def _auto_extend_subscription(
subscription = prepared.subscription
old_end_date = subscription.end_date
was_trial = subscription.is_trial # Запоминаем, была ли подписка триальной
old_tariff_id = subscription.tariff_id # Запоминаем старый тариф для определения смены
_apply_extension_updates(prepared)
# Определяем, произошла ли смена тарифа
is_tariff_change = (
prepared.tariff_id is not None
and old_tariff_id != prepared.tariff_id
)
try:
# При смене тарифа передаём traffic_limit_gb для сброса трафика в БД
updated_subscription = await extend_subscription(
db,
subscription,
prepared.period_days,
tariff_id=prepared.tariff_id if is_tariff_change else None,
traffic_limit_gb=prepared.traffic_limit_gb if is_tariff_change else None,
device_limit=prepared.device_limit if is_tariff_change else None,
)
# НОВОЕ: Конвертируем триал в платную подписку ТОЛЬКО после успешного продления
@@ -424,12 +448,14 @@ async def _auto_extend_subscription(
)
subscription_service = SubscriptionService()
# При смене тарифа ВСЕГДА сбрасываем трафик, иначе по настройке
should_reset_traffic = is_tariff_change or settings.RESET_TRAFFIC_ON_PAYMENT
try:
await subscription_service.update_remnawave_user(
db,
updated_subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="продление подписки",
reset_traffic=should_reset_traffic,
reset_reason="смена тарифа" if is_tariff_change else "продление подписки",
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
@@ -528,6 +554,466 @@ async def _auto_extend_subscription(
return True
async def _auto_purchase_tariff(
db: AsyncSession,
user: User,
cart_data: dict,
*,
bot: Optional[Bot] = None,
) -> bool:
"""Автоматическая покупка периодного тарифа из сохранённой корзины."""
from datetime import datetime
from app.database.crud.tariff import get_tariff_by_id
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id, extend_subscription
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.crud.server_squad import get_all_server_squads
from app.database.models import TransactionType
tariff_id = _safe_int(cart_data.get("tariff_id"))
period_days = _safe_int(cart_data.get("period_days"))
discount_percent = _safe_int(cart_data.get("discount_percent"))
if not tariff_id or period_days <= 0:
logger.warning(
"🔁 Автопокупка тарифа: некорректные данные корзины для пользователя %s (tariff_id=%s, period=%s)",
user.telegram_id,
tariff_id,
period_days,
)
return False
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not tariff.is_active:
logger.warning(
"🔁 Автопокупка тарифа: тариф %s недоступен для пользователя %s",
tariff_id,
user.telegram_id,
)
return False
# Получаем актуальную цену тарифа
prices = tariff.period_prices or {}
base_price = prices.get(str(period_days))
if base_price is None:
logger.warning(
"🔁 Автопокупка тарифа: период %s дней недоступен для тарифа %s",
period_days,
tariff_id,
)
return False
final_price = _apply_promo_discount_for_tariff(base_price, discount_percent)
if user.balance_kopeks < final_price:
logger.info(
"🔁 Автопокупка тарифа: у пользователя %s недостаточно средств (%s < %s)",
user.telegram_id,
user.balance_kopeks,
final_price,
)
return False
# Списываем баланс
try:
description = f"Покупка тарифа {tariff.name} на {period_days} дней"
success = await subtract_user_balance(db, user, final_price, description)
if not success:
logger.warning(
"❌ Автопокупка тарифа: не удалось списать баланс пользователя %s",
user.telegram_id,
)
return False
except Exception as error:
logger.error(
"❌ Автопокупка тарифа: ошибка списания баланса пользователя %s: %s",
user.telegram_id,
error,
exc_info=True,
)
return False
# Получаем список серверов из тарифа
squads = tariff.allowed_squads or []
if not squads:
all_servers, _ = await get_all_server_squads(db, available_only=True)
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
# Проверяем есть ли уже подписка
existing_subscription = await get_subscription_by_user_id(db, user.id)
try:
if existing_subscription:
# Продлеваем существующую подписку
subscription = await extend_subscription(
db,
existing_subscription,
days=period_days,
tariff_id=tariff.id,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
connected_squads=squads,
)
was_trial_conversion = existing_subscription.is_trial
if was_trial_conversion:
subscription.is_trial = False
subscription.status = "active"
user.has_had_paid_subscription = True
await db.commit()
else:
# Создаём новую подписку
subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=period_days,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
connected_squads=squads,
tariff_id=tariff.id,
)
was_trial_conversion = False
except Exception as error:
logger.error(
"❌ Автопокупка тарифа: ошибка создания подписки для пользователя %s: %s",
user.telegram_id,
error,
exc_info=True,
)
await db.rollback()
return False
# Создаём транзакцию
try:
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=final_price,
description=description,
)
except Exception as error:
logger.warning(
"⚠️ Автопокупка тарифа: не удалось создать транзакцию для пользователя %s: %s",
user.telegram_id,
error,
)
transaction = None
# Обновляем Remnawave
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason="покупка тарифа",
)
except Exception as error:
logger.warning(
"⚠️ Автопокупка тарифа: не удалось обновить Remnawave для пользователя %s: %s",
user.telegram_id,
error,
)
# Очищаем корзину
await user_cart_service.delete_user_cart(user.id)
await clear_subscription_checkout_draft(user.id)
# Уведомления
if bot:
texts = get_texts(getattr(user, "language", "ru"))
period_label = format_period_description(period_days, getattr(user, "language", "ru"))
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_purchase_notification(
db, user, subscription, transaction, period_days, was_trial_conversion
)
except Exception as error:
logger.warning(
"⚠️ Автопокупка тарифа: не удалось уведомить админов о покупке пользователя %s: %s",
user.telegram_id,
error,
)
try:
message = texts.t(
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS",
"✅ Подписка на {period} автоматически оформлена после пополнения баланса.",
).format(period=period_label)
hint = texts.t(
"AUTO_PURCHASE_SUBSCRIPTION_HINT",
"Перейдите в раздел «Моя подписка», чтобы получить ссылку.",
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
callback_data="menu_subscription",
)],
[InlineKeyboardButton(
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "🏠 Главное меню"),
callback_data="back_to_menu",
)],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=f"{message}\n\n{hint}",
reply_markup=keyboard,
parse_mode="HTML",
)
except Exception as error:
logger.warning(
"⚠️ Автопокупка тарифа: не удалось уведомить пользователя %s: %s",
user.telegram_id,
error,
)
logger.info(
"✅ Автопокупка тарифа: подписка на тариф %s (%s дней) оформлена для пользователя %s",
tariff.name,
period_days,
user.telegram_id,
)
return True
async def _auto_purchase_daily_tariff(
db: AsyncSession,
user: User,
cart_data: dict,
*,
bot: Optional[Bot] = None,
) -> bool:
"""Автоматическая покупка суточного тарифа из сохранённой корзины."""
from datetime import datetime, timedelta
from app.database.crud.tariff import get_tariff_by_id
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.crud.server_squad import get_all_server_squads
from app.database.models import TransactionType
tariff_id = _safe_int(cart_data.get("tariff_id"))
if not tariff_id:
logger.warning(
"🔁 Автопокупка суточного тарифа: нет tariff_id в корзине пользователя %s",
user.telegram_id,
)
return False
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not tariff.is_active:
logger.warning(
"🔁 Автопокупка суточного тарифа: тариф %s недоступен для пользователя %s",
tariff_id,
user.telegram_id,
)
return False
if not getattr(tariff, 'is_daily', False):
logger.warning(
"🔁 Автопокупка суточного тарифа: тариф %s не является суточным для пользователя %s",
tariff_id,
user.telegram_id,
)
return False
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price <= 0:
logger.warning(
"🔁 Автопокупка суточного тарифа: некорректная цена тарифа %s для пользователя %s",
tariff_id,
user.telegram_id,
)
return False
if user.balance_kopeks < daily_price:
logger.info(
"🔁 Автопокупка суточного тарифа: у пользователя %s недостаточно средств (%s < %s)",
user.telegram_id,
user.balance_kopeks,
daily_price,
)
return False
# Списываем баланс за первый день
try:
description = f"Активация суточного тарифа {tariff.name}"
success = await subtract_user_balance(db, user, daily_price, description)
if not success:
logger.warning(
"❌ Автопокупка суточного тарифа: не удалось списать баланс пользователя %s",
user.telegram_id,
)
return False
except Exception as error:
logger.error(
"❌ Автопокупка суточного тарифа: ошибка списания баланса пользователя %s: %s",
user.telegram_id,
error,
exc_info=True,
)
return False
# Получаем список серверов из тарифа
squads = tariff.allowed_squads or []
if not squads:
all_servers, _ = await get_all_server_squads(db, available_only=True)
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
# Проверяем есть ли уже подписка
existing_subscription = await get_subscription_by_user_id(db, user.id)
try:
if existing_subscription:
# Обновляем существующую подписку на суточный тариф
# Суточность определяется через tariff.is_daily, поэтому достаточно установить tariff_id
was_trial_conversion = existing_subscription.is_trial # Сохраняем до изменения
existing_subscription.tariff_id = tariff.id
existing_subscription.traffic_limit_gb = tariff.traffic_limit_gb
existing_subscription.device_limit = tariff.device_limit
existing_subscription.connected_squads = squads
existing_subscription.status = "active"
existing_subscription.is_trial = False
existing_subscription.last_daily_charge_at = datetime.utcnow()
existing_subscription.is_daily_paused = False
existing_subscription.end_date = datetime.utcnow() + timedelta(days=1)
if was_trial_conversion:
user.has_had_paid_subscription = True
await db.commit()
await db.refresh(existing_subscription)
subscription = existing_subscription
else:
# Создаём новую суточную подписку
# Суточность определяется через tariff.is_daily
subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=1,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
connected_squads=squads,
tariff_id=tariff.id,
)
# Устанавливаем параметры для суточного списания
subscription.last_daily_charge_at = datetime.utcnow()
subscription.is_daily_paused = False
await db.commit()
was_trial_conversion = False
except Exception as error:
logger.error(
"❌ Автопокупка суточного тарифа: ошибка создания подписки для пользователя %s: %s",
user.telegram_id,
error,
exc_info=True,
)
await db.rollback()
return False
# Создаём транзакцию
try:
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
description=description,
)
except Exception as error:
logger.warning(
"⚠️ Автопокупка суточного тарифа: не удалось создать транзакцию для пользователя %s: %s",
user.telegram_id,
error,
)
transaction = None
# Обновляем Remnawave
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason="активация суточного тарифа",
)
except Exception as error:
logger.warning(
"⚠️ Автопокупка суточного тарифа: не удалось обновить Remnawave для пользователя %s: %s",
user.telegram_id,
error,
)
# Очищаем корзину
await user_cart_service.delete_user_cart(user.id)
await clear_subscription_checkout_draft(user.id)
# Уведомления
if bot:
texts = get_texts(getattr(user, "language", "ru"))
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_purchase_notification(
db, user, subscription, transaction, 1, was_trial_conversion
)
except Exception as error:
logger.warning(
"⚠️ Автопокупка суточного тарифа: не удалось уведомить админов о покупке пользователя %s: %s",
user.telegram_id,
error,
)
try:
message = (
f"✅ <b>Суточный тариф «{tariff.name}» активирован!</b>\n\n"
f"💰 Списано: {daily_price / 100:.0f} ₽ за первый день\n"
f"🔄 Средства будут списываться автоматически раз в сутки.\n\n"
f"ℹ️ Вы можете приостановить подписку в любой момент."
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
callback_data="menu_subscription",
)],
[InlineKeyboardButton(
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "🏠 Главное меню"),
callback_data="back_to_menu",
)],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=message,
reply_markup=keyboard,
parse_mode="HTML",
)
except Exception as error:
logger.warning(
"⚠️ Автопокупка суточного тарифа: не удалось уведомить пользователя %s: %s",
user.telegram_id,
error,
)
logger.info(
"✅ Автопокупка суточного тарифа: тариф %s активирован для пользователя %s",
tariff.name,
user.telegram_id,
)
return True
async def auto_purchase_saved_cart_after_topup(
db: AsyncSession,
user: User,
@@ -551,9 +1037,19 @@ async def auto_purchase_saved_cart_after_topup(
)
cart_mode = cart_data.get("cart_mode") or cart_data.get("mode")
# Обработка продления подписки
if cart_mode == "extend":
return await _auto_extend_subscription(db, user, cart_data, bot=bot)
# Обработка покупки периодного тарифа
if cart_mode == "tariff_purchase":
return await _auto_purchase_tariff(db, user, cart_data, bot=bot)
# Обработка покупки суточного тарифа
if cart_mode == "daily_tariff_purchase":
return await _auto_purchase_daily_tariff(db, user, cart_data, bot=bot)
try:
prepared = await _prepare_auto_purchase(db, user, cart_data)
except PurchaseValidationError as error:
+11 -10
View File
@@ -620,7 +620,7 @@ class MiniAppSubscriptionPurchaseService:
maximum = max(default_devices, settings.DEFAULT_DEVICE_LIMIT) + 10
return PurchaseDevicesConfig(
minimum=1,
minimum=settings.DEFAULT_DEVICE_LIMIT,
maximum=maximum,
default=default_devices,
current=default_devices,
@@ -653,13 +653,13 @@ class MiniAppSubscriptionPurchaseService:
period = context.period_map[period_id]
traffic_value = (
selection_payload.get("traffic_value")
or selection_payload.get("trafficValue")
or selection_payload.get("traffic")
or selection_payload.get("traffic_gb")
or selection_payload.get("trafficGb")
)
# Don't use `or` chaining - 0 is valid for unlimited traffic
traffic_value = None
for key in ("traffic_value", "trafficValue", "traffic", "traffic_gb", "trafficGb"):
value = selection_payload.get(key)
if value is not None:
traffic_value = value
break
if period.traffic.selectable:
available_values = {option.value for option in period.traffic.options}
@@ -1156,19 +1156,20 @@ class MiniAppSubscriptionPurchaseService:
logger.error("Failed to register subscription servers: %s", error)
subscription_service = SubscriptionService()
# При покупке подписки ВСЕГДА сбрасываем трафик в панели
try:
if getattr(user, "remnawave_uuid", None):
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_traffic=True,
reset_reason="miniapp purchase",
)
else:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_traffic=True,
reset_reason="miniapp purchase",
)
except Exception as remnawave_error: # pragma: no cover - defensive logging
+43 -1
View File
@@ -12,7 +12,7 @@ from aiogram import Bot
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.server_squad import get_server_ids_by_uuids
from app.database.crud.server_squad import get_server_ids_by_uuids, get_server_squads_by_uuids
from app.database.crud.subscription import (
add_subscription_servers,
calculate_subscription_total_cost,
@@ -319,6 +319,9 @@ class SubscriptionRenewalService:
if connected_uuids:
server_ids = await get_server_ids_by_uuids(db, connected_uuids)
# Валидация: проверяем доступность серверов для промогруппы пользователя
await self._validate_servers_for_user_promo_group(db, user, connected_uuids)
# В режиме fixed_with_topup при продлении используем фиксированный лимит
if settings.is_traffic_fixed():
traffic_limit = settings.get_fixed_traffic_limit()
@@ -530,6 +533,45 @@ class SubscriptionRenewalService:
old_end_date=old_end_date,
)
async def _validate_servers_for_user_promo_group(
self,
db: AsyncSession,
user: User,
server_uuids: List[str],
) -> None:
"""
Проверяет, что все серверы подписки доступны для промогруппы пользователя.
Логирует предупреждения если серверы недоступны.
"""
if not server_uuids:
return
try:
await db.refresh(user, ["user_promo_groups", "promo_group"])
except Exception:
pass
user_promo_group = user.get_primary_promo_group() if user else None
if not user_promo_group:
return
servers = await get_server_squads_by_uuids(db, server_uuids)
unavailable_servers = []
for server in servers:
if server.allowed_promo_groups:
allowed_ids = {pg.id for pg in server.allowed_promo_groups}
if user_promo_group.id not in allowed_ids:
unavailable_servers.append(server.display_name or server.squad_uuid)
if unavailable_servers:
logger.warning(
f"⚠️ Пользователь {user.telegram_id} (promo_group={user_promo_group.name}) "
f"продлевает подписку с серверами, недоступными для его промогруппы: "
f"{', '.join(unavailable_servers)}. "
f"Это может привести к неправильному расчёту цены!"
)
def build_option_payload(
self,
pricing: SubscriptionRenewalPricing,
+9 -3
View File
@@ -320,11 +320,17 @@ class SubscriptionService:
if not user or not user.remnawave_uuid:
logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}")
return None
# Загружаем tariff заранее, чтобы избежать lazy loading в async контексте
try:
await db.refresh(subscription, ["tariff"])
except Exception:
pass # tariff может быть None или уже загружен
current_time = datetime.utcnow()
is_actually_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
is_actually_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date > current_time)
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
+29
View File
@@ -219,3 +219,32 @@ class SupportSettingsService:
return cls._save()
return True
# Cabinet notifications (веб-кабинет)
@classmethod
def get_cabinet_user_notifications_enabled(cls) -> bool:
"""Уведомления юзерам в кабинет о ответе админа на тикет."""
cls._load()
if "cabinet_user_notifications_enabled" in cls._data:
return bool(cls._data["cabinet_user_notifications_enabled"])
return True # По умолчанию включено
@classmethod
def set_cabinet_user_notifications_enabled(cls, enabled: bool) -> bool:
cls._load()
cls._data["cabinet_user_notifications_enabled"] = bool(enabled)
return cls._save()
@classmethod
def get_cabinet_admin_notifications_enabled(cls) -> bool:
"""Уведомления админам в кабинет о новых тикетах."""
cls._load()
if "cabinet_admin_notifications_enabled" in cls._data:
return bool(cls._data["cabinet_admin_notifications_enabled"])
return True # По умолчанию включено
@classmethod
def set_cabinet_admin_notifications_enabled(cls, enabled: bool) -> bool:
cls._load()
cls._data["cabinet_admin_notifications_enabled"] = bool(enabled)
return cls._save()
+63
View File
@@ -276,6 +276,18 @@ class BotConfigurationService:
"TRAFFIC_MONITORING_INTERVAL_HOURS": "MONITORING",
"TRAFFIC_MONITORED_NODES": "MONITORING",
"TRAFFIC_SNAPSHOT_TTL_HOURS": "MONITORING",
"TRAFFIC_FAST_CHECK_ENABLED": "MONITORING",
"TRAFFIC_FAST_CHECK_INTERVAL_MINUTES": "MONITORING",
"TRAFFIC_FAST_CHECK_THRESHOLD_GB": "MONITORING",
"TRAFFIC_DAILY_CHECK_ENABLED": "MONITORING",
"TRAFFIC_DAILY_CHECK_TIME": "MONITORING",
"TRAFFIC_DAILY_THRESHOLD_GB": "MONITORING",
"TRAFFIC_IGNORED_NODES": "MONITORING",
"TRAFFIC_EXCLUDED_USER_UUIDS": "MONITORING",
"TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES": "MONITORING",
"SUSPICIOUS_NOTIFICATIONS_TOPIC_ID": "MONITORING",
"TRAFFIC_CHECK_BATCH_SIZE": "MONITORING",
"TRAFFIC_CHECK_CONCURRENCY": "MONITORING",
"ENABLE_LOGO_MODE": "INTERFACE_BRANDING",
"LOGO_FILE": "INTERFACE_BRANDING",
"HIDE_SUBSCRIPTION_LINK": "INTERFACE_SUBSCRIPTION",
@@ -780,6 +792,57 @@ class BotConfigurationService:
),
"dependencies": "TRAFFIC_MONITORING_ENABLED, Redis",
},
"TRAFFIC_FAST_CHECK_ENABLED": {
"description": (
"Включает быструю проверку трафика. "
"Система сравнивает текущий трафик со snapshot и уведомляет о превышениях дельты."
),
"format": "Булево значение.",
"example": "true",
"warning": "Требует Redis для хранения snapshot. При отключении проверки не выполняются.",
"dependencies": "Redis, TRAFFIC_FAST_CHECK_INTERVAL_MINUTES, TRAFFIC_FAST_CHECK_THRESHOLD_GB",
},
"TRAFFIC_FAST_CHECK_INTERVAL_MINUTES": {
"description": "Интервал быстрой проверки трафика в минутах.",
"format": "Целое число минут (минимум 1).",
"example": "10",
"warning": "Слишком малый интервал создаёт нагрузку на Remnawave API.",
"dependencies": "TRAFFIC_FAST_CHECK_ENABLED",
},
"TRAFFIC_FAST_CHECK_THRESHOLD_GB": {
"description": "Порог дельты трафика в ГБ для быстрой проверки. При превышении отправляется уведомление.",
"format": "Число с плавающей точкой.",
"example": "5.0",
"warning": "Слишком низкий порог приведёт к частым уведомлениям.",
"dependencies": "TRAFFIC_FAST_CHECK_ENABLED",
},
"TRAFFIC_DAILY_CHECK_ENABLED": {
"description": "Включает суточную проверку трафика через bandwidth-stats API.",
"format": "Булево значение.",
"example": "true",
"warning": "Проверка выполняется в указанное время (TRAFFIC_DAILY_CHECK_TIME).",
"dependencies": "TRAFFIC_DAILY_CHECK_TIME, TRAFFIC_DAILY_THRESHOLD_GB",
},
"TRAFFIC_DAILY_CHECK_TIME": {
"description": "Время суточной проверки трафика в формате HH:MM (UTC).",
"format": "Строка времени HH:MM.",
"example": "00:00",
"warning": "Время указывается в UTC.",
"dependencies": "TRAFFIC_DAILY_CHECK_ENABLED",
},
"TRAFFIC_DAILY_THRESHOLD_GB": {
"description": "Порог суточного трафика в ГБ. При превышении за 24 часа отправляется уведомление.",
"format": "Число с плавающей точкой.",
"example": "50.0",
"warning": "Учитывается весь трафик за последние 24 часа.",
"dependencies": "TRAFFIC_DAILY_CHECK_ENABLED",
},
"TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES": {
"description": "Кулдаун уведомлений по одному пользователю в минутах.",
"format": "Целое число минут.",
"example": "60",
"warning": "Защита от спама уведомлениями по одному и тому же пользователю.",
},
}
@classmethod
+57 -3
View File
@@ -33,6 +33,7 @@ from app.database.crud.wheel import (
)
from app.database.crud.user import add_user_balance
from app.database.crud.subscription import get_subscription_by_user_id
from app.services.subscription_service import SubscriptionService
logger = logging.getLogger(__name__)
@@ -339,6 +340,14 @@ class FortuneWheelService:
logger.info(f"📅 Списано {config.spin_cost_days} дней подписки у user_id={user.id}")
# Синхронизируем с RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
logger.info(f"✅ Списание дней синхронизировано с RemnaWave для user_id={user.id}")
except Exception as e:
logger.error(f"⚠️ Ошибка синхронизации списания дней с RemnaWave: {e}")
return kopeks
async def _apply_prize(
@@ -372,9 +381,46 @@ class FortuneWheelService:
# Дни подписки
subscription = await get_subscription_by_user_id(db, user.id)
if subscription:
subscription.end_date += timedelta(days=prize.prize_value)
subscription.updated_at = datetime.utcnow()
logger.info(f"📅 Начислено {prize.prize_value} дней подписки user_id={user.id}")
# Проверяем суточный тариф - для него конвертируем дни в баланс
is_daily = getattr(subscription, 'is_daily', False) or (
subscription.tariff and getattr(subscription.tariff, 'is_daily', False)
)
if is_daily:
# Для суточных тарифов: дни * суточная_цена = баланс
daily_price = 0
if subscription.tariff and hasattr(subscription.tariff, 'daily_price_kopeks'):
daily_price = subscription.tariff.daily_price_kopeks or 0
if daily_price > 0:
balance_bonus = prize.prize_value * daily_price
await add_user_balance(
db, user, balance_bonus,
description=f"Выигрыш в колесе удачи: {prize.prize_value} дней → {balance_bonus/100:.2f}",
create_transaction=True,
)
logger.info(f"💰 Суточный тариф: {prize.prize_value} дней конвертированы в {balance_bonus/100:.2f}₽ для user_id={user.id}")
else:
# Если нет цены - используем prize_value_kopeks
await add_user_balance(
db, user, prize.prize_value_kopeks,
description=f"Выигрыш в колесе удачи: {prize.prize_value} дней (на баланс)",
create_transaction=True,
)
logger.info(f"💰 Дни конвертированы в баланс для user_id={user.id}")
else:
# Обычная подписка - добавляем дни и синхронизируем с RemnaWave
subscription.end_date += timedelta(days=prize.prize_value)
subscription.updated_at = datetime.utcnow()
logger.info(f"📅 Начислено {prize.prize_value} дней подписки user_id={user.id}")
# Синхронизируем с RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
logger.info(f"✅ Синхронизировано с RemnaWave для user_id={user.id}")
except Exception as e:
logger.error(f"⚠️ Ошибка синхронизации с RemnaWave: {e}")
else:
# Если нет подписки - начисляем на баланс эквивалент
await add_user_balance(
@@ -392,6 +438,14 @@ class FortuneWheelService:
subscription.traffic_limit_gb += prize.prize_value
subscription.updated_at = datetime.utcnow()
logger.info(f"📊 Начислено {prize.prize_value}GB трафика user_id={user.id}")
# Синхронизируем с RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
logger.info(f"✅ Трафик синхронизирован с RemnaWave для user_id={user.id}")
except Exception as e:
logger.error(f"⚠️ Ошибка синхронизации трафика с RemnaWave: {e}")
else:
# Если безлимит или нет подписки - на баланс
await add_user_balance(
+1
View File
@@ -134,6 +134,7 @@ class AdminStates(StatesGroup):
editing_faq_title = State()
editing_faq_content = State()
editing_notification_value = State()
editing_traffic_setting = State()
confirming_sync = State()
+58 -6
View File
@@ -9,6 +9,12 @@ from app.localization.texts import get_texts
LOGO_PATH = Path(settings.LOGO_FILE)
_PRIVACY_RESTRICTED_CODE = "BUTTON_USER_PRIVACY_RESTRICTED"
_TOPIC_REQUIRED_ERRORS = (
"topic must be specified",
"TOPIC_CLOSED",
"TOPIC_DELETED",
"FORUM_CLOSED",
)
def is_qr_message(message: Message) -> bool:
@@ -80,6 +86,15 @@ def is_privacy_restricted_error(error: Exception) -> bool:
return _PRIVACY_RESTRICTED_CODE in message or _PRIVACY_RESTRICTED_CODE in description
def is_topic_required_error(error: Exception) -> bool:
"""Проверяет, является ли ошибка связанной с топиками/форумами."""
if not isinstance(error, TelegramBadRequest):
return False
description = str(error).lower()
return any(err.lower() in description for err in _TOPIC_REQUIRED_ERRORS)
async def _answer_with_photo(self: Message, text: str = None, **kwargs):
# Уважаем флаг в рантайме: если логотип выключен — не подменяем ответ
if not settings.ENABLE_LOGO_MODE:
@@ -97,15 +112,38 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs):
# Отправляем caption как есть; при ошибке парсинга ниже сработает фоллбек
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
except TelegramBadRequest as error:
if is_topic_required_error(error):
# Канал с топиками — просто игнорируем, нельзя ответить без message_thread_id
return None
if is_privacy_restricted_error(error):
fallback_text = append_privacy_hint(text, language)
safe_kwargs = prepare_privacy_safe_kwargs(kwargs)
return await _original_answer(self, fallback_text, **safe_kwargs)
try:
return await _original_answer(self, fallback_text, **safe_kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
raise
# Фоллбек, если Telegram ругается на caption или другое ограничение: отправим как текст
return await _original_answer(self, text, **kwargs)
try:
return await _original_answer(self, text, **kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
raise
except Exception:
return await _original_answer(self, text, **kwargs)
return await _original_answer(self, text, **kwargs)
try:
return await _original_answer(self, text, **kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
raise
try:
return await _original_answer(self, text, **kwargs)
except TelegramBadRequest as error:
if is_topic_required_error(error):
return None
raise
async def _edit_with_photo(self: Message, text: str, **kwargs):
@@ -142,6 +180,8 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
try:
return await self.edit_media(InputMediaPhoto(**media_kwargs), **edit_kwargs)
except TelegramBadRequest as error:
if is_topic_required_error(error):
return None
if is_privacy_restricted_error(error):
fallback_text = append_privacy_hint(text, language)
safe_kwargs = prepare_privacy_safe_kwargs(kwargs)
@@ -149,17 +189,29 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
await self.delete()
except Exception:
pass
return await _original_answer(self, fallback_text, **safe_kwargs)
try:
return await _original_answer(self, fallback_text, **safe_kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
raise
# Фоллбек: удалим и отправим обычный текст без фото
try:
await self.delete()
except Exception:
pass
return await _original_answer(self, text, **kwargs)
try:
return await _original_answer(self, text, **kwargs)
except TelegramBadRequest as inner_error:
if is_topic_required_error(inner_error):
return None
raise
# Обработка ошибок MESSAGE_ID_INVALID для сообщений без фото
try:
return await _original_edit_text(self, text, **kwargs)
except TelegramBadRequest as error:
if is_topic_required_error(error):
return None
if "MESSAGE_ID_INVALID" in str(error) or "message to edit not found" in str(error).lower():
# Сообщение удалено или недоступно — просто игнорируем
return None
+4 -1
View File
@@ -184,7 +184,10 @@ def resolve_hwid_device_limit(subscription: Optional[Subscription]) -> Optional[
if not settings.is_devices_selection_enabled():
forced_limit = settings.get_disabled_mode_device_limit()
return forced_limit
if forced_limit is not None:
return forced_limit
# Если forced_limit не задан, используем device_limit из подписки
# чтобы при смене тарифа лимит устройств обновлялся в панели
limit = getattr(subscription, "device_limit", None)
if limit is None or limit <= 0:
+60 -2
View File
@@ -209,6 +209,17 @@ router = APIRouter()
promo_code_service = PromoCodeService()
renewal_service = SubscriptionRenewalService()
# Кешированный Bot для проверки подписки на канал (снижает нагрузку)
_channel_check_bot: Optional[Bot] = None
def _get_channel_check_bot() -> Bot:
"""Получить или создать Bot для проверки подписки на канал."""
global _channel_check_bot
if _channel_check_bot is None:
_channel_check_bot = Bot(token=settings.BOT_TOKEN)
return _channel_check_bot
_CRYPTOBOT_MIN_USD = 1.0
_CRYPTOBOT_MAX_USD = 1000.0
@@ -3089,6 +3100,18 @@ async def get_subscription_details(
payload: MiniAppSubscriptionRequest,
db: AsyncSession = Depends(get_db_session),
) -> MiniAppSubscriptionResponse:
# Check maintenance mode first
if maintenance_service.is_maintenance_active():
status_info = maintenance_service.get_status_info()
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"code": "maintenance",
"message": maintenance_service.get_maintenance_message() or "Service is under maintenance",
"reason": status_info.get("reason"),
},
)
try:
webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN)
except TelegramWebAppAuthError as error:
@@ -3112,6 +3135,31 @@ async def get_subscription_details(
detail="Invalid Telegram user identifier",
) from None
# Check required channel subscription
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
try:
bot = _get_channel_check_bot()
chat_member = await bot.get_chat_member(
chat_id=settings.CHANNEL_SUB_ID,
user_id=telegram_id
)
# Не закрываем сессию - бот переиспользуется
if chat_member.status not in ["member", "administrator", "creator"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"code": "channel_subscription_required",
"message": "Please subscribe to our channel to continue",
"channel_link": settings.CHANNEL_LINK,
},
)
except HTTPException:
raise
except Exception as e:
logger.warning(f"Failed to check channel subscription for user {telegram_id}: {e}")
# Don't block user if check fails
user = await get_user_by_telegram_id(db, telegram_id)
purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip()
@@ -3367,6 +3415,7 @@ async def get_subscription_details(
subscription_url: Optional[str] = None
subscription_crypto_link: Optional[str] = None
happ_redirect_link: Optional[str] = None
hide_subscription_link: bool = False
remnawave_short_uuid: Optional[str] = None
status_actual = "missing"
subscription_status_value = "none"
@@ -3381,6 +3430,8 @@ async def get_subscription_details(
status_actual = subscription.actual_status
subscription_status_value = subscription.status
links_payload = await _load_subscription_links(subscription)
# Флаг скрытия ссылки (скрывается только текст, кнопки работают)
hide_subscription_link = settings.should_hide_subscription_link()
subscription_url = (
links_payload.get("subscription_url") or subscription.subscription_url
)
@@ -3533,6 +3584,7 @@ async def get_subscription_details(
remnawave_short_uuid=remnawave_short_uuid,
user=response_user,
subscription_url=subscription_url,
hide_subscription_link=hide_subscription_link,
subscription_crypto_link=subscription_crypto_link,
subscription_purchase_url=purchase_url or None,
links=links,
@@ -3543,7 +3595,7 @@ async def get_subscription_details(
connected_devices=devices,
happ=links_payload.get("happ") if subscription else None,
happ_link=links_payload.get("happ_link") if subscription else None,
happ_crypto_link=links_payload.get("happ_crypto_link") if subscription else None,
happ_crypto_link=subscription_crypto_link, # Используем уже вычисленное значение с fallback
happ_cryptolink_redirect_link=happ_redirect_link,
happ_cryptolink_redirect_template=settings.get_happ_cryptolink_redirect_template(),
balance_kopeks=user.balance_kopeks,
@@ -6738,8 +6790,14 @@ async def purchase_tariff_endpoint(
await db.refresh(subscription)
# Синхронизируем с RemnaWave
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
service = SubscriptionService()
await service.update_remnawave_user(db, subscription)
await service.update_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason="покупка тарифа (miniapp)",
)
# Сохраняем корзину для автопродления
try:
+10 -6
View File
@@ -51,25 +51,29 @@ async def verify_websocket_token(
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint для real-time обновлений."""
client_host = websocket.client.host if websocket.client else "unknown"
logger.info("WebSocket connection attempt from %s", client_host)
logger.debug("WebSocket connection attempt from %s", client_host)
# Сначала проверяем авторизацию ДО принятия соединения
token = websocket.query_params.get("token") or websocket.query_params.get("api_key")
if not token:
logger.warning("WebSocket: No token provided from %s", client_host)
logger.debug("WebSocket: No token provided from %s", client_host)
# Принимаем и сразу закрываем с кодом ошибки
await websocket.accept()
await websocket.close(code=1008, reason="Unauthorized: No token provided")
return
if not await verify_websocket_token(websocket, token):
logger.warning("WebSocket: Invalid token from %s", client_host)
logger.debug("WebSocket: Invalid token from %s", client_host)
# Принимаем и сразу закрываем с кодом ошибки
await websocket.accept()
await websocket.close(code=1008, reason="Unauthorized: Invalid token")
return
# Только после успешной проверки принимаем соединение
try:
await websocket.accept()
logger.info("WebSocket connection accepted from %s", client_host)
logger.debug("WebSocket connection accepted from %s", client_host)
except Exception as e:
logger.error("WebSocket: Failed to accept connection from %s: %s", client_host, e)
return
@@ -104,7 +108,7 @@ async def websocket_endpoint(websocket: WebSocket):
logger.exception("Error processing WebSocket message: %s", error)
except WebSocketDisconnect:
logger.info("WebSocket client disconnected")
logger.debug("WebSocket client disconnected")
except Exception as error:
logger.exception("WebSocket error: %s", error)
finally:
+1
View File
@@ -696,6 +696,7 @@ class MiniAppSubscriptionResponse(BaseModel):
user: MiniAppSubscriptionUser
traffic_purchases: List[MiniAppTrafficPurchase] = Field(default_factory=list)
subscription_url: Optional[str] = None
hide_subscription_link: bool = False # Скрывать ли отображение ссылки (но кнопки работают)
subscription_crypto_link: Optional[str] = None
subscription_purchase_url: Optional[str] = None
links: List[str] = Field(default_factory=list)
+32 -1
View File
@@ -25,14 +25,45 @@ class WebAPIServer:
logger.warning("WEB_API_WORKERS > 1 не поддерживается в embed-режиме, используем 1")
workers = 1
# Кастомный конфиг логирования - скрываем спам от WebSocket
log_config = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"()": "uvicorn.logging.DefaultFormatter",
"fmt": "%(levelprefix)s %(message)s",
"use_colors": None,
},
},
"handlers": {
"default": {
"formatter": "default",
"class": "logging.StreamHandler",
"stream": "ext://sys.stderr",
},
},
"loggers": {
"uvicorn": {"handlers": ["default"], "level": "WARNING", "propagate": False},
"uvicorn.error": {"level": "WARNING", "propagate": False},
"uvicorn.access": {"level": "ERROR", "propagate": False},
"uvicorn.protocols": {"level": "WARNING", "propagate": False},
"uvicorn.protocols.websockets": {"level": "WARNING", "propagate": False},
"uvicorn.protocols.websockets.websockets_impl": {"level": "WARNING", "propagate": False},
"websockets": {"level": "WARNING", "propagate": False},
"websockets.server": {"level": "WARNING", "propagate": False},
},
}
self._config = uvicorn.Config(
app=self._app,
host=settings.WEB_API_HOST,
port=int(settings.WEB_API_PORT or 8080),
log_level=settings.LOG_LEVEL.lower(),
log_level="warning",
workers=workers,
lifespan="on",
access_log=False,
log_config=log_config,
)
self._server = uvicorn.Server(self._config)
self._task: Optional[asyncio.Task[None]] = None
+1 -1
View File
@@ -6,7 +6,7 @@
- `GET /contests/daily/templates?enabled_only=false` — список шаблонов игр.
- `GET /contests/daily/templates/{id}` — получить шаблон.
- `PATCH /contests/daily/templates/{id}` — обновить поля: `name`, `description`, `prize_days`, `max_winners`, `attempts_per_user`, `times_per_day`, `schedule_times`, `cooldown_hours`, `payload` (dict), `is_enabled`.
- `PATCH /contests/daily/templates/{id}` — обновить поля: `name`, `description`, `prize_type`, `prize_value`, `max_winners`, `attempts_per_user`, `times_per_day`, `schedule_times`, `cooldown_hours`, `payload` (dict), `is_enabled`.
- `POST /contests/daily/templates/{id}/start-round` — запустить раунд вручную. Тело:
```json
{
+7 -3
View File
@@ -148,6 +148,10 @@ async def main():
logging.getLogger("aiogram").setLevel(logging.WARNING)
logging.getLogger("uvicorn.access").setLevel(logging.ERROR)
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
# Скрываем спам от WebSocket подключений (connection open/closed)
logging.getLogger("uvicorn.protocols.websockets.websockets_impl").setLevel(logging.WARNING)
logging.getLogger("websockets.server").setLevel(logging.WARNING)
logging.getLogger("websockets").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
timeline = StartupTimeline(logger, "Bedolaga Remnawave Bot")
@@ -578,7 +582,7 @@ async def main():
await bot.set_webhook(
url=webhook_url,
secret_token=settings.WEBHOOK_SECRET_TOKEN,
drop_pending_updates=settings.WEBHOOK_DROP_PENDING_UPDATES,
drop_pending_updates=False, # Обрабатываем накопившиеся обновления
allowed_updates=allowed_updates,
)
stage.log(f"Webhook установлен: {webhook_url}")
@@ -664,8 +668,8 @@ async def main():
success_message="Aiogram polling запущен",
) as stage:
if polling_enabled:
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True))
stage.log("skip_updates=True")
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=False))
stage.log("skip_updates=False — накопившиеся обновления будут обработаны")
else:
polling_task = None
stage.skip("Polling отключен режимом работы")
+1
View File
@@ -14,6 +14,7 @@ redis==5.0.1
PyYAML==6.0.2
fastapi==0.115.6
uvicorn==0.32.1
websockets>=12.0
python-multipart==0.0.9
# YooKassa SDK