Compare commits
167 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fcf84aa41e | |||
| db01725582 | |||
| 9269770703 | |||
| e91cc23156 | |||
| ffc9453b76 | |||
| 3a9404c349 | |||
| de2f3de28a | |||
| 25318c1c41 | |||
| c233ba8a8c | |||
| 2a82b037d8 | |||
| 67083980a3 | |||
| 05f65af8e9 | |||
| 085459dfd3 | |||
| 65af46cdae | |||
| f218852f5f | |||
| 6635666112 | |||
| 83f9d05fe3 | |||
| d47a65c29f | |||
| 626c67a7a7 | |||
| e9c6ea9fc9 | |||
| 318dda9e04 | |||
| c73b0433b9 | |||
| 0c2293fef2 | |||
| 9f5971563b | |||
| 5930506972 | |||
| 6b6d79257e | |||
| 86c2092eff | |||
| 7bd838f0b0 | |||
| 5563314718 | |||
| db69af159b | |||
| 4a16bcbccf | |||
| 8dec623f2d | |||
| 0cb714b3a9 | |||
| 2c3c4ba09c | |||
| 48c6c8dd63 | |||
| d01dd47d57 | |||
| d51d51db55 | |||
| 060ae9decf | |||
| 275c797566 | |||
| d9a4af341e | |||
| a4f337a502 | |||
| 1f55d76459 | |||
| 2760a744db | |||
| f169c08275 | |||
| c69b371c53 | |||
| a45d667c89 | |||
| 894e4e02b2 | |||
| c289b96f1a | |||
| 6425cfb0fb | |||
| a56daca368 | |||
| d0628eebda | |||
| 6a36504699 | |||
| 8db061553f | |||
| 7eb8750d0f | |||
| ae7f63aed0 | |||
| c18b4a3cbb | |||
| c0cada8fb5 | |||
| 1117a1dd34 | |||
| abd312dacc | |||
| 7aa64521d2 | |||
| b99ff79920 | |||
| dff723aede | |||
| 86097b300e | |||
| f6b795e555 | |||
| 7719d035a1 | |||
| 33e11fb25a | |||
| fcdda41541 | |||
| c5183f5a9f | |||
| f200f90150 | |||
| e42421d2ff | |||
| c783884ace | |||
| 6ca1bae6f8 | |||
| e1aeff55d7 | |||
| 10107964ba | |||
| ade2794d52 | |||
| a462657f96 | |||
| 91af4a3818 | |||
| 05f29c25ec | |||
| 5b851f1047 | |||
| fac258a40d | |||
| d92071e8aa | |||
| f6c9304a40 | |||
| b994a5ffd6 | |||
| 0413a0ee6a | |||
| 1fc8d38c70 | |||
| f50fbe232d | |||
| 80e1b68863 | |||
| 4f54ffcc2a | |||
| 6fed2542eb | |||
| c331c33321 | |||
| 338ee832ea | |||
| b7a05f7cd1 | |||
| dd396aa16d | |||
| 814d224359 | |||
| 95ab0236d9 | |||
| 4fbe07919c | |||
| 1752f52197 | |||
| 5fe37e57a6 | |||
| da9997609b | |||
| 92b7c0b602 | |||
| 44b3a0dfab | |||
| b3bbd48723 | |||
| e51afc6bcf | |||
| 0ae70d702e | |||
| 722b800f1d | |||
| cd9287236e | |||
| 18e8e38855 | |||
| a63d587aad | |||
| aba2df927d | |||
| d78f85735b | |||
| a7e0f52e27 | |||
| b9ca73a306 | |||
| c26ccb8e96 | |||
| 38bf8633b7 | |||
| 73d52f2047 | |||
| d781290652 | |||
| 9d453283ec | |||
| 98d01cff49 | |||
| 255c623143 | |||
| 2d69d98d81 | |||
| 8ad2d50b25 | |||
| 372654683a | |||
| 6fd470a02f | |||
| cb4f79dd6e | |||
| b38e06383d | |||
| 5523c6bf76 | |||
| 57981b69c7 | |||
| 6a3e716ad2 | |||
| c21804f8db | |||
| 3937bbffc9 | |||
| 70441da7de | |||
| df2626adae | |||
| 9bffeb4151 | |||
| 8524e2c023 | |||
| 3553ff615d | |||
| ca0682e48f | |||
| 1e1c0e89bc | |||
| 93227b7f41 | |||
| 0a51e4d9ea | |||
| 9e69b0cf22 | |||
| aaaaa25231 | |||
| 10fea329ad | |||
| 408c5c520e | |||
| 6775b06b2e | |||
| 9460c86f7a | |||
| f8d7b3288c | |||
| bd6498fb73 | |||
| cdb3507a56 | |||
| 0b18c16f47 | |||
| f452434b13 | |||
| e6e688a395 | |||
| b1206a84c7 | |||
| 63e45e12de | |||
| 792ff22471 | |||
| 346806bce0 | |||
| 5630e99812 | |||
| 67c3dba1cc | |||
| c1e901ba5e | |||
| b6f2052464 | |||
| c63db708cc | |||
| 3fc702ec65 | |||
| 0aff94de74 | |||
| d8c60ce19f | |||
| f3995c7ca8 | |||
| 1330f394bb | |||
| ec27d63f70 | |||
| 78d785f83f |
+18
-1
@@ -214,7 +214,7 @@ TRIAL_ACTIVATION_PRICE=0
|
||||
# Сколько устройств доступно по дефолту при покупке платной подписки
|
||||
DEFAULT_DEVICE_LIMIT=3
|
||||
|
||||
# Максимум устройств достопных к покупке (0 = Нет лимита)
|
||||
# Максимум устройств доступных к покупке (0 = Нет лимита)
|
||||
MAX_DEVICES_LIMIT=15
|
||||
|
||||
# Дефолт параметры для подписок выданных через админку
|
||||
@@ -581,6 +581,23 @@ FREEKASSA_PAYMENT_SYSTEM_ID=
|
||||
# Использовать API для создания заказов (обязательно для NSPK СБП)
|
||||
FREEKASSA_USE_API=false
|
||||
|
||||
# ===== KASSA AI (api.fk.life) =====
|
||||
# Отдельная платёжная система, работает параллельно с Freekassa
|
||||
KASSA_AI_ENABLED=false
|
||||
KASSA_AI_SHOP_ID=
|
||||
KASSA_AI_API_KEY=
|
||||
# Секретное слово 2 (для webhook)
|
||||
KASSA_AI_SECRET_WORD_2=
|
||||
KASSA_AI_DISPLAY_NAME=KassaAI
|
||||
KASSA_AI_CURRENCY=RUB
|
||||
KASSA_AI_MIN_AMOUNT_KOPEKS=10000
|
||||
KASSA_AI_MAX_AMOUNT_KOPEKS=100000000
|
||||
KASSA_AI_WEBHOOK_PATH=/kassa-ai-webhook
|
||||
KASSA_AI_WEBHOOK_HOST=0.0.0.0
|
||||
KASSA_AI_WEBHOOK_PORT=8089
|
||||
# Способ оплаты: 44 = СБП (QR), 36 = Карты РФ, 43 = SberPay
|
||||
KASSA_AI_PAYMENT_SYSTEM_ID=44
|
||||
|
||||
# ===== WATA =====
|
||||
WATA_ENABLED=false
|
||||
WATA_BASE_URL=https://api.wata.pro
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🚀 Remnawave Bedolaga Bot
|
||||
|
||||
> **🆕 Новый веб-кабинет (Cabinet WebApp)**
|
||||
> **🆕 Новый веб-кабинет (Cabinet WebApp) https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/**
|
||||
>
|
||||
> Вышла новая версия личного кабинета пользователя — веб-интерфейс для управления подписками!
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -2,13 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.discount_offer import (
|
||||
count_discount_offers,
|
||||
list_discount_offers,
|
||||
@@ -24,9 +32,12 @@ from app.database.crud.promo_offer_template import (
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate, User
|
||||
from app.handlers.admin.messages import get_custom_users, get_target_users
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/promo-offers", tags=["Admin Promo Offers"])
|
||||
|
||||
|
||||
@@ -110,12 +121,18 @@ class PromoOfferBroadcastRequest(BaseModel):
|
||||
target: Optional[str] = None
|
||||
user_id: Optional[int] = None
|
||||
telegram_id: Optional[int] = None
|
||||
# Telegram notification options
|
||||
send_notification: bool = Field(False, description="Send Telegram notification to users")
|
||||
message_text: Optional[str] = Field(None, description="Custom message text (HTML)")
|
||||
button_text: Optional[str] = Field(None, description="Button text")
|
||||
|
||||
|
||||
class PromoOfferBroadcastResponse(BaseModel):
|
||||
created_offers: int
|
||||
user_ids: List[int]
|
||||
target: Optional[str] = None
|
||||
notifications_sent: int = 0
|
||||
notifications_failed: int = 0
|
||||
|
||||
|
||||
class PromoOfferLogOfferInfo(BaseModel):
|
||||
@@ -344,13 +361,137 @@ async def list_offers(
|
||||
)
|
||||
|
||||
|
||||
def _get_bot() -> Bot:
|
||||
"""Create bot instance for sending notifications."""
|
||||
return Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
|
||||
|
||||
def _build_default_promo_message(
|
||||
discount_percent: int,
|
||||
bonus_amount_kopeks: int,
|
||||
valid_hours: int,
|
||||
) -> str:
|
||||
"""Build default promo notification message."""
|
||||
lines = ["🎁 <b>Специальное предложение для вас!</b>\n"]
|
||||
|
||||
if discount_percent > 0:
|
||||
lines.append(f"🔥 Скидка <b>{discount_percent}%</b> на подписку")
|
||||
if bonus_amount_kopeks > 0:
|
||||
bonus_rub = bonus_amount_kopeks / 100
|
||||
lines.append(f"💰 Бонус <b>{bonus_rub:.0f}₽</b> на баланс")
|
||||
|
||||
lines.append(f"\n⏰ Предложение действует <b>{valid_hours} ч.</b>")
|
||||
lines.append("\nНажмите кнопку ниже, чтобы активировать!")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _send_promo_notifications(
|
||||
offers_to_notify: List[tuple[User, DiscountOffer]],
|
||||
message_text: Optional[str],
|
||||
button_text: Optional[str],
|
||||
discount_percent: int,
|
||||
bonus_amount_kopeks: int,
|
||||
valid_hours: int,
|
||||
) -> tuple[int, int]:
|
||||
"""Send Telegram notifications for promo offers.
|
||||
|
||||
Returns:
|
||||
Tuple of (sent_count, failed_count)
|
||||
"""
|
||||
if not offers_to_notify:
|
||||
return 0, 0
|
||||
|
||||
bot = _get_bot()
|
||||
sent = 0
|
||||
failed = 0
|
||||
|
||||
# Build message text
|
||||
text = message_text or _build_default_promo_message(
|
||||
discount_percent=discount_percent,
|
||||
bonus_amount_kopeks=bonus_amount_kopeks,
|
||||
valid_hours=valid_hours,
|
||||
)
|
||||
|
||||
# Default button text
|
||||
btn_text = button_text or "🎁 Получить"
|
||||
|
||||
semaphore = asyncio.Semaphore(20)
|
||||
|
||||
async def send_single(user: User, offer: DiscountOffer) -> bool:
|
||||
async with semaphore:
|
||||
try:
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text=btn_text,
|
||||
callback_data=f"claim_discount_{offer.id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="❌ Закрыть",
|
||||
callback_data="promo_offer_close",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return True
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
logger.warning(
|
||||
"Failed to send promo notification to user %s: %s",
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Error sending promo notification to user %s: %s",
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
# Send in batches
|
||||
batch_size = 50
|
||||
for i in range(0, len(offers_to_notify), batch_size):
|
||||
batch = offers_to_notify[i : i + batch_size]
|
||||
tasks = [send_single(user, offer) for user, offer in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, bool) and result:
|
||||
sent += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# Small delay between batches
|
||||
if i + batch_size < len(offers_to_notify):
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Close bot session
|
||||
await bot.session.close()
|
||||
|
||||
return sent, failed
|
||||
|
||||
|
||||
@router.post("/broadcast", response_model=PromoOfferBroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def broadcast_offer(
|
||||
payload: PromoOfferBroadcastRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoOfferBroadcastResponse:
|
||||
"""Broadcast promo offer to users."""
|
||||
"""Broadcast promo offer to users with optional Telegram notification."""
|
||||
recipients: dict[int, User] = {}
|
||||
|
||||
# Resolve target segment
|
||||
@@ -386,8 +527,10 @@ async def broadcast_offer(
|
||||
"No recipients: specify target or user",
|
||||
)
|
||||
|
||||
# Create offers for all recipients
|
||||
# Create offers for all recipients and collect (user, offer) pairs
|
||||
created_offers = 0
|
||||
offers_to_notify: List[tuple[User, DiscountOffer]] = []
|
||||
|
||||
for recipient in recipients.values():
|
||||
offer = await upsert_discount_offer(
|
||||
db,
|
||||
@@ -402,11 +545,28 @@ async def broadcast_offer(
|
||||
)
|
||||
if offer:
|
||||
created_offers += 1
|
||||
offers_to_notify.append((recipient, offer))
|
||||
|
||||
# Send Telegram notifications if requested
|
||||
notifications_sent = 0
|
||||
notifications_failed = 0
|
||||
|
||||
if payload.send_notification and offers_to_notify:
|
||||
notifications_sent, notifications_failed = await _send_promo_notifications(
|
||||
offers_to_notify=offers_to_notify,
|
||||
message_text=payload.message_text,
|
||||
button_text=payload.button_text,
|
||||
discount_percent=payload.discount_percent,
|
||||
bonus_amount_kopeks=payload.bonus_amount_kopeks,
|
||||
valid_hours=payload.valid_hours,
|
||||
)
|
||||
|
||||
return PromoOfferBroadcastResponse(
|
||||
created_offers=created_offers,
|
||||
user_ids=list(recipients.keys()),
|
||||
target=payload.target,
|
||||
notifications_sent=notifications_sent,
|
||||
notifications_failed=notifications_failed,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Authentication routes for cabinet."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import datetime
|
||||
@@ -94,6 +95,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 +112,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)
|
||||
@@ -282,11 +297,12 @@ async def register_email(
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Send verification email
|
||||
if email_service.is_configured():
|
||||
# Send verification email asynchronously (smtplib is blocking)
|
||||
if settings.is_cabinet_email_verification_enabled() and email_service.is_configured():
|
||||
# TODO: Get actual verification URL from settings
|
||||
verification_url = "https://example.com/cabinet/verify-email"
|
||||
email_service.send_verification_email(
|
||||
await asyncio.to_thread(
|
||||
email_service.send_verification_email,
|
||||
to_email=request.email,
|
||||
verification_token=verification_token,
|
||||
verification_url=verification_url,
|
||||
@@ -361,15 +377,26 @@ async def resend_verification(
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Send verification email
|
||||
if email_service.is_configured():
|
||||
# Send verification email asynchronously (smtplib is blocking)
|
||||
if settings.is_cabinet_email_verification_enabled() and email_service.is_configured():
|
||||
verification_url = "https://example.com/cabinet/verify-email"
|
||||
email_service.send_verification_email(
|
||||
await asyncio.to_thread(
|
||||
email_service.send_verification_email,
|
||||
to_email=user.email,
|
||||
verification_token=verification_token,
|
||||
verification_url=verification_url,
|
||||
username=user.first_name,
|
||||
)
|
||||
elif not settings.is_cabinet_email_verification_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email verification is disabled",
|
||||
)
|
||||
elif not email_service.is_configured():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Email service is not configured",
|
||||
)
|
||||
|
||||
return {"message": "Verification email sent"}
|
||||
|
||||
@@ -534,10 +561,11 @@ async def forgot_password(
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Send reset email
|
||||
# Send reset email asynchronously (smtplib is blocking)
|
||||
if email_service.is_configured():
|
||||
reset_url = "https://example.com/cabinet/reset-password"
|
||||
email_service.send_password_reset_email(
|
||||
await asyncio.to_thread(
|
||||
email_service.send_password_reset_email,
|
||||
to_email=user.email,
|
||||
reset_token=reset_token,
|
||||
reset_url=reset_url,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -645,6 +650,7 @@ async def create_topup(
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
telegram_id=user.telegram_id,
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
+589
-107
@@ -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,9 @@ 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 app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.subscription import (
|
||||
@@ -129,6 +133,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 +155,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 [],
|
||||
@@ -324,31 +332,100 @@ async def renew_subscription(
|
||||
detail="Invalid renewal period",
|
||||
)
|
||||
|
||||
# Apply discount
|
||||
discount_percent = 0
|
||||
# Apply promo group discount
|
||||
original_price_kopeks = price_kopeks
|
||||
promo_group_discount_percent = 0
|
||||
if hasattr(user, "get_promo_discount"):
|
||||
discount_percent = user.get_promo_discount("period", request.period_days)
|
||||
promo_group_discount_percent = user.get_promo_discount("period", request.period_days)
|
||||
|
||||
if discount_percent > 0:
|
||||
price_kopeks = int(price_kopeks * (100 - discount_percent) / 100)
|
||||
if promo_group_discount_percent > 0:
|
||||
price_kopeks = int(price_kopeks * (100 - promo_group_discount_percent) / 100)
|
||||
|
||||
# Apply promo offer discount (temporary discount from promo offers)
|
||||
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
|
||||
promo_offer_discount_value = 0
|
||||
if promo_offer_discount_percent > 0:
|
||||
promo_offer_discount_value = price_kopeks * promo_offer_discount_percent // 100
|
||||
price_kopeks = price_kopeks - promo_offer_discount_value
|
||||
|
||||
# Combined discount percent for display
|
||||
discount_percent = promo_group_discount_percent
|
||||
if promo_offer_discount_percent > 0 and original_price_kopeks > 0:
|
||||
total_discount = original_price_kopeks - price_kopeks
|
||||
discount_percent = int(total_discount * 100 / original_price_kopeks)
|
||||
|
||||
# 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
|
||||
user.balance_kopeks -= price_kopeks
|
||||
|
||||
# Consume promo offer discount if it was used
|
||||
if promo_offer_discount_value > 0:
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
|
||||
# Extend from end_date or now if expired
|
||||
now = datetime.utcnow()
|
||||
if user.subscription.end_date and user.subscription.end_date > now:
|
||||
from datetime import timedelta
|
||||
user.subscription.end_date = user.subscription.end_date + timedelta(days=request.period_days)
|
||||
else:
|
||||
from datetime import timedelta
|
||||
user.subscription.end_date = now + timedelta(days=request.period_days)
|
||||
user.subscription.start_date = now
|
||||
|
||||
@@ -357,12 +434,20 @@ async def renew_subscription(
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
response = {
|
||||
"message": "Subscription renewed successfully",
|
||||
"new_end_date": user.subscription.end_date.isoformat(),
|
||||
"amount_paid_kopeks": price_kopeks,
|
||||
}
|
||||
|
||||
# Add discount info to response
|
||||
if promo_offer_discount_value > 0:
|
||||
response["promo_discount_percent"] = promo_offer_discount_percent
|
||||
response["promo_discount_amount_kopeks"] = promo_offer_discount_value
|
||||
response["original_price_kopeks"] = original_price_kopeks
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/traffic-packages", response_model=List[TrafficPackageResponse])
|
||||
async def get_traffic_packages(
|
||||
@@ -586,7 +671,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}")
|
||||
|
||||
@@ -891,8 +979,8 @@ async def _build_tariff_response(
|
||||
periods = []
|
||||
if tariff.period_prices:
|
||||
for period_str, price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])):
|
||||
if int(price_kopeks) <= 0:
|
||||
continue # Skip disabled periods
|
||||
if int(price_kopeks) < 0:
|
||||
continue # Skip disabled periods (negative price)
|
||||
period_days = int(period_str)
|
||||
|
||||
# Apply promo group discount for this period
|
||||
@@ -1077,7 +1165,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 +1210,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 +1249,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}")
|
||||
@@ -1262,15 +1390,68 @@ async def purchase_tariff(
|
||||
traffic_limit_gb = request.traffic_gb
|
||||
price_kopeks += traffic_price_kopeks
|
||||
|
||||
# Apply promo offer discount (temporary discount from promo offers)
|
||||
price_before_promo_offer = price_kopeks
|
||||
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
|
||||
promo_offer_discount_value = 0
|
||||
if promo_offer_discount_percent > 0:
|
||||
promo_offer_discount_value = price_kopeks * promo_offer_discount_percent // 100
|
||||
price_kopeks = price_kopeks - promo_offer_discount_value
|
||||
|
||||
# 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'],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1292,6 +1473,8 @@ async def purchase_tariff(
|
||||
description = f"Покупка тарифа '{tariff.name}' на {period_days} дней"
|
||||
if discount_percent > 0:
|
||||
description += f" (скидка {discount_percent}%)"
|
||||
if promo_offer_discount_value > 0:
|
||||
description += f" (промо -{promo_offer_discount_percent}%)"
|
||||
success = await subtract_user_balance(db, user, price_kopeks, description)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
@@ -1299,6 +1482,12 @@ async def purchase_tariff(
|
||||
detail="Failed to charge balance",
|
||||
)
|
||||
|
||||
# Consume promo offer discount if it was used
|
||||
if promo_offer_discount_value > 0:
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
|
||||
# Create transaction
|
||||
await create_transaction(
|
||||
db=db,
|
||||
@@ -1339,8 +1528,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:
|
||||
@@ -1378,11 +1584,18 @@ async def purchase_tariff(
|
||||
response["discount_percent"] = discount_percent
|
||||
response["original_price_kopeks"] = original_price
|
||||
response["original_price_label"] = settings.format_price(original_price)
|
||||
response["discount_amount_kopeks"] = original_price - price_kopeks
|
||||
response["discount_label"] = settings.format_price(original_price - price_kopeks)
|
||||
response["discount_amount_kopeks"] = original_price - price_before_promo_offer
|
||||
response["discount_label"] = settings.format_price(original_price - price_before_promo_offer)
|
||||
if promo_group:
|
||||
response["promo_group_name"] = promo_group.name
|
||||
|
||||
# Add promo offer discount info if it was applied
|
||||
if promo_offer_discount_value > 0:
|
||||
response["promo_offer_discount_percent"] = promo_offer_discount_percent
|
||||
response["promo_offer_discount_amount_kopeks"] = promo_offer_discount_value
|
||||
response["promo_offer_discount_label"] = settings.format_price(promo_offer_discount_value)
|
||||
response["price_before_promo_offer_kopeks"] = price_before_promo_offer
|
||||
|
||||
return response
|
||||
|
||||
except HTTPException:
|
||||
@@ -1420,25 +1633,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 +1674,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 +1706,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 +1762,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 +1808,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 +1823,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 +1895,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 +2045,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 +2125,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 +2178,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 +2189,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 +2383,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 +2505,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 +2540,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 +2559,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 +3060,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 +3274,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 +3293,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",
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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] = []
|
||||
|
||||
+66
-1
@@ -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"
|
||||
@@ -481,6 +488,21 @@ class Settings(BaseSettings):
|
||||
# Публичный IP сервера для Freekassa API (если не задан - определяется автоматически)
|
||||
SERVER_PUBLIC_IP: Optional[str] = None
|
||||
|
||||
# KassaAI (api.fk.life) - отдельная платёжка
|
||||
KASSA_AI_ENABLED: bool = False
|
||||
KASSA_AI_SHOP_ID: Optional[int] = None
|
||||
KASSA_AI_API_KEY: Optional[str] = None
|
||||
KASSA_AI_SECRET_WORD_2: Optional[str] = None # Для webhook
|
||||
KASSA_AI_DISPLAY_NAME: str = "KassaAI"
|
||||
KASSA_AI_CURRENCY: str = "RUB"
|
||||
KASSA_AI_MIN_AMOUNT_KOPEKS: int = 10000 # 100 руб
|
||||
KASSA_AI_MAX_AMOUNT_KOPEKS: int = 100000000 # 1 000 000 руб
|
||||
KASSA_AI_WEBHOOK_PATH: str = "/kassa-ai-webhook"
|
||||
KASSA_AI_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
KASSA_AI_WEBHOOK_PORT: int = 8089
|
||||
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
|
||||
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
|
||||
|
||||
MAIN_MENU_MODE: str = "default"
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
MINIAPP_CUSTOM_URL: str = ""
|
||||
@@ -1481,6 +1503,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 +1526,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 +1537,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 +1579,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 +1667,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 +1678,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
|
||||
@@ -1652,6 +1698,21 @@ class Settings(BaseSettings):
|
||||
def get_freekassa_display_name_html(self) -> str:
|
||||
return html.escape(self.get_freekassa_display_name())
|
||||
|
||||
def is_kassa_ai_enabled(self) -> bool:
|
||||
return (
|
||||
self.KASSA_AI_ENABLED
|
||||
and self.KASSA_AI_SHOP_ID is not None
|
||||
and self.KASSA_AI_API_KEY is not None
|
||||
and self.KASSA_AI_SECRET_WORD_2 is not None
|
||||
)
|
||||
|
||||
def get_kassa_ai_display_name(self) -> str:
|
||||
name = (self.KASSA_AI_DISPLAY_NAME or "").strip()
|
||||
return name if name else "KassaAI"
|
||||
|
||||
def get_kassa_ai_display_name_html(self) -> str:
|
||||
return html.escape(self.get_kassa_ai_display_name())
|
||||
|
||||
def is_payment_verification_auto_check_enabled(self) -> bool:
|
||||
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
|
||||
|
||||
@@ -1931,7 +1992,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()
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""CRUD операции для платежей KassaAI."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import KassaAiPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_kassa_ai_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = "RUB",
|
||||
description: Optional[str] = None,
|
||||
payment_url: Optional[str] = None,
|
||||
payment_system_id: Optional[int] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
metadata_json: Optional[str] = None,
|
||||
) -> KassaAiPayment:
|
||||
"""Создает запись о платеже KassaAI."""
|
||||
payment = KassaAiPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_system_id=payment_system_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=json.loads(metadata_json) if metadata_json else None,
|
||||
status="pending",
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(f"Создан платеж KassaAI: order_id={order_id}, user_id={user_id}")
|
||||
return payment
|
||||
|
||||
|
||||
async def get_kassa_ai_payment_by_order_id(
|
||||
db: AsyncSession, order_id: str
|
||||
) -> Optional[KassaAiPayment]:
|
||||
"""Получает платеж по order_id."""
|
||||
result = await db.execute(
|
||||
select(KassaAiPayment).where(KassaAiPayment.order_id == order_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_kassa_ai_payment_by_external_order_id(
|
||||
db: AsyncSession, kassa_ai_order_id: str
|
||||
) -> Optional[KassaAiPayment]:
|
||||
"""Получает платеж по ID от KassaAI (orderId)."""
|
||||
result = await db.execute(
|
||||
select(KassaAiPayment).where(
|
||||
KassaAiPayment.kassa_ai_order_id == kassa_ai_order_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_kassa_ai_payment_by_id(
|
||||
db: AsyncSession, payment_id: int
|
||||
) -> Optional[KassaAiPayment]:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(
|
||||
select(KassaAiPayment).where(KassaAiPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_kassa_ai_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: KassaAiPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool = False,
|
||||
kassa_ai_order_id: Optional[str] = None,
|
||||
payment_system_id: Optional[int] = None,
|
||||
callback_payload: Optional[dict] = None,
|
||||
transaction_id: Optional[int] = None,
|
||||
) -> KassaAiPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.is_paid = is_paid
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.utcnow()
|
||||
if kassa_ai_order_id:
|
||||
payment.kassa_ai_order_id = kassa_ai_order_id
|
||||
if payment_system_id is not None:
|
||||
payment.payment_system_id = payment_system_id
|
||||
if callback_payload:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
f"Обновлен статус платежа KassaAI: order_id={payment.order_id}, "
|
||||
f"status={status}, is_paid={is_paid}"
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_kassa_ai_payments(
|
||||
db: AsyncSession, user_id: int
|
||||
) -> List[KassaAiPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(KassaAiPayment).where(
|
||||
KassaAiPayment.user_id == user_id,
|
||||
KassaAiPayment.status == "pending",
|
||||
KassaAiPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_user_kassa_ai_payments(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[KassaAiPayment]:
|
||||
"""Получает платежи пользователя с пагинацией."""
|
||||
result = await db.execute(
|
||||
select(KassaAiPayment)
|
||||
.where(KassaAiPayment.user_id == user_id)
|
||||
.order_by(KassaAiPayment.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_kassa_ai_payments(
|
||||
db: AsyncSession,
|
||||
) -> List[KassaAiPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(KassaAiPayment).where(
|
||||
KassaAiPayment.status == "pending",
|
||||
KassaAiPayment.is_paid == False,
|
||||
KassaAiPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -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:
|
||||
"""
|
||||
Проверяет и синхронизирует серверы при запуске.
|
||||
|
||||
@@ -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,
|
||||
@@ -1557,6 +1606,15 @@ async def check_and_update_subscription_status(
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
|
||||
# Детальное логирование для отладки проблемы с деактивацией
|
||||
time_diff = current_time - subscription.end_date
|
||||
logger.warning(
|
||||
f"⏰ DEACTIVATION: подписка {subscription.id} (user_id={subscription.user_id}) "
|
||||
f"деактивируется в check_and_update_subscription_status. "
|
||||
f"end_date={subscription.end_date}, current_time={current_time}, "
|
||||
f"просрочена на {time_diff}"
|
||||
)
|
||||
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.updated_at = current_time
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -9,6 +9,23 @@ from app.database.models import Transaction, TransactionType, PaymentMethod, Use
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Реальные платёжные методы для подсчёта дохода
|
||||
# Исключены: MANUAL (админские), BALANCE (оплата с баланса), NULL (колесо, промокоды, бонусы)
|
||||
REAL_PAYMENT_METHODS = [
|
||||
PaymentMethod.TELEGRAM_STARS.value,
|
||||
PaymentMethod.TRIBUTE.value,
|
||||
PaymentMethod.YOOKASSA.value,
|
||||
PaymentMethod.CRYPTOBOT.value,
|
||||
PaymentMethod.HELEKET.value,
|
||||
PaymentMethod.MULENPAY.value,
|
||||
PaymentMethod.PAL24.value,
|
||||
PaymentMethod.WATA.value,
|
||||
PaymentMethod.PLATEGA.value,
|
||||
PaymentMethod.CLOUDPAYMENTS.value,
|
||||
PaymentMethod.FREEKASSA.value,
|
||||
PaymentMethod.KASSA_AI.value,
|
||||
]
|
||||
|
||||
|
||||
async def create_transaction(
|
||||
db: AsyncSession,
|
||||
@@ -209,6 +226,7 @@ async def get_transactions_statistics(
|
||||
if not end_date:
|
||||
end_date = datetime.utcnow()
|
||||
|
||||
# Доход считаем только по реальным платежам (исключаем колесо, промокоды, админские пополнения)
|
||||
income_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
@@ -216,7 +234,8 @@ async def get_transactions_statistics(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date
|
||||
Transaction.created_at <= end_date,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS)
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -297,13 +316,15 @@ async def get_transactions_statistics(
|
||||
)
|
||||
transactions_today = today_result.scalar()
|
||||
|
||||
# Доход за сегодня - только реальные платежи
|
||||
today_income_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
|
||||
Transaction.created_at >= today,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS)
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -333,9 +354,9 @@ async def get_revenue_by_period(
|
||||
db: AsyncSession,
|
||||
days: int = 30
|
||||
) -> List[dict]:
|
||||
|
||||
"""Доход по дням - только реальные платежи."""
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.date(Transaction.created_at).label('date'),
|
||||
@@ -345,13 +366,14 @@ async def get_revenue_by_period(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS)
|
||||
)
|
||||
)
|
||||
.group_by(func.date(Transaction.created_at))
|
||||
.order_by(func.date(Transaction.created_at))
|
||||
)
|
||||
|
||||
|
||||
return [{"date": row.date, "amount_kopeks": row.amount} for row in result]
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -108,6 +108,7 @@ class PaymentMethod(Enum):
|
||||
PLATEGA = "platega"
|
||||
CLOUDPAYMENTS = "cloudpayments"
|
||||
FREEKASSA = "freekassa"
|
||||
KASSA_AI = "kassa_ai"
|
||||
MANUAL = "manual"
|
||||
BALANCE = "balance"
|
||||
|
||||
@@ -649,6 +650,74 @@ class FreekassaPayment(Base):
|
||||
)
|
||||
|
||||
|
||||
class KassaAiPayment(Base):
|
||||
"""Платежи через KassaAI (api.fk.life)."""
|
||||
__tablename__ = "kassa_ai_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш ID заказа
|
||||
kassa_ai_order_id = Column(String(64), unique=True, nullable=True, index=True) # orderId от KassaAI
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default="RUB")
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default="pending") # pending, success, failed, expired
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True)
|
||||
payment_system_id = Column(Integer, nullable=True) # ID платежной системы (44=СБП, 36=Карты, 43=SberPay)
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", backref="kassa_ai_payments")
|
||||
transaction = relationship("Transaction", backref="kassa_ai_payment")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == "pending"
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == "success" and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ["failed", "expired"]
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return (
|
||||
"<KassaAiPayment(id={0}, order_id={1}, amount={2}₽, status={3})>".format(
|
||||
self.id,
|
||||
self.order_id,
|
||||
self.amount_rubles,
|
||||
self.status,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = "promo_groups"
|
||||
|
||||
@@ -2563,3 +2632,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}>"
|
||||
|
||||
@@ -1401,6 +1401,118 @@ async def create_freekassa_payments_table():
|
||||
return False
|
||||
|
||||
|
||||
async def create_kassa_ai_payments_table():
|
||||
"""Создаёт таблицу kassa_ai_payments для платежей через KassaAI."""
|
||||
table_exists = await check_table_exists('kassa_ai_payments')
|
||||
if table_exists:
|
||||
logger.info("Таблица kassa_ai_payments уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE kassa_ai_payments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
order_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
kassa_ai_order_id VARCHAR(64) NULL UNIQUE,
|
||||
amount_kopeks INTEGER NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
|
||||
description TEXT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
is_paid BOOLEAN NOT NULL DEFAULT 0,
|
||||
payment_url TEXT NULL,
|
||||
payment_system_id INTEGER NULL,
|
||||
metadata_json JSON NULL,
|
||||
callback_payload JSON NULL,
|
||||
paid_at DATETIME NULL,
|
||||
expires_at DATETIME NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
transaction_id INTEGER NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_kassa_ai_user_id ON kassa_ai_payments(user_id);
|
||||
CREATE UNIQUE INDEX idx_kassa_ai_order_id ON kassa_ai_payments(order_id);
|
||||
CREATE UNIQUE INDEX idx_kassa_ai_kai_order_id ON kassa_ai_payments(kassa_ai_order_id);
|
||||
"""
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE kassa_ai_payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
order_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
kassa_ai_order_id VARCHAR(64) NULL UNIQUE,
|
||||
amount_kopeks INTEGER NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
|
||||
description TEXT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
is_paid BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
payment_url TEXT NULL,
|
||||
payment_system_id INTEGER NULL,
|
||||
metadata_json JSON NULL,
|
||||
callback_payload JSON NULL,
|
||||
paid_at TIMESTAMP NULL,
|
||||
expires_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
transaction_id INTEGER NULL REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_kassa_ai_user_id ON kassa_ai_payments(user_id);
|
||||
CREATE UNIQUE INDEX idx_kassa_ai_order_id ON kassa_ai_payments(order_id);
|
||||
CREATE UNIQUE INDEX idx_kassa_ai_kai_order_id ON kassa_ai_payments(kassa_ai_order_id);
|
||||
"""
|
||||
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE kassa_ai_payments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
order_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
kassa_ai_order_id VARCHAR(64) NULL UNIQUE,
|
||||
amount_kopeks INT NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
|
||||
description TEXT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
is_paid BOOLEAN NOT NULL DEFAULT 0,
|
||||
payment_url TEXT NULL,
|
||||
payment_system_id INT NULL,
|
||||
metadata_json JSON NULL,
|
||||
callback_payload JSON NULL,
|
||||
paid_at DATETIME NULL,
|
||||
expires_at DATETIME NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
transaction_id INT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_kassa_ai_user_id ON kassa_ai_payments(user_id);
|
||||
CREATE UNIQUE INDEX idx_kassa_ai_order_id ON kassa_ai_payments(order_id);
|
||||
CREATE UNIQUE INDEX idx_kassa_ai_kai_order_id ON kassa_ai_payments(kassa_ai_order_id);
|
||||
"""
|
||||
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для таблицы kassa_ai_payments: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("Таблица kassa_ai_payments успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы kassa_ai_payments: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_discount_offers_table():
|
||||
table_exists = await check_table_exists('discount_offers')
|
||||
if table_exists:
|
||||
@@ -6333,6 +6445,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей Freekassa payments")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ KASSA_AI ===")
|
||||
kassa_ai_created = await create_kassa_ai_payments_table()
|
||||
if kassa_ai_created:
|
||||
logger.info("✅ Таблица KassaAI payments готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей KassaAI payments")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===")
|
||||
discount_created = await create_discount_offers_table()
|
||||
if discount_created:
|
||||
|
||||
Vendored
+1
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
"""Handler for KassaAI balance top-up."""
|
||||
|
||||
import logging
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _create_kassa_ai_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
):
|
||||
"""
|
||||
Common logic for creating KassaAI payment and sending response.
|
||||
|
||||
Args:
|
||||
message_or_callback: Either a Message or CallbackQuery object
|
||||
db_user: User object
|
||||
db: Database session
|
||||
amount_kopeks: Amount in kopeks
|
||||
edit_message: Whether to edit existing message or send new one
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description="Пополнение баланса",
|
||||
)
|
||||
|
||||
result = await payment_service.create_kassa_ai_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, "email", None),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
"PAYMENT_CREATE_ERROR",
|
||||
"Не удалось создать платёж. Попробуйте позже.",
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
error_text,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get("payment_url")
|
||||
display_name = settings.get_kassa_ai_display_name()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"PAY_BUTTON",
|
||||
"💳 Оплатить {amount}₽",
|
||||
).format(amount=f"{amount_rub:.0f}"),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_BUTTON", "◀️ Назад"),
|
||||
callback_data="menu_balance",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
"KASSA_AI_PAYMENT_CREATED",
|
||||
"💳 <b>Оплата через {name}</b>\n\n"
|
||||
"Сумма: <b>{amount}₽</b>\n\n"
|
||||
"Нажмите кнопку ниже для оплаты.\n"
|
||||
"После успешной оплаты баланс будет пополнен автоматически.",
|
||||
).format(name=display_name, amount=f"{amount_rub:.2f}")
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"KassaAI payment created: user=%s, amount=%s₽",
|
||||
db_user.telegram_id,
|
||||
amount_rub,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_kassa_ai_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, "restriction_topup", False):
|
||||
reason = (
|
||||
getattr(db_user, "restriction_reason", None)
|
||||
or "Действие ограничено администратором"
|
||||
)
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append(
|
||||
[InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]
|
||||
)
|
||||
keyboard.append(
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAYMENT_AMOUNT_TOO_LOW",
|
||||
"Минимальная сумма пополнения: {min_amount}₽",
|
||||
).format(min_amount=min_amount // 100),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAYMENT_AMOUNT_TOO_HIGH",
|
||||
"Максимальная сумма пополнения: {max_amount}₽",
|
||||
).format(max_amount=max_amount // 100),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_kassa_ai_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_kassa_ai_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Start KassaAI top-up process - ask for amount.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, "restriction_topup", False):
|
||||
reason = (
|
||||
getattr(db_user, "restriction_reason", None)
|
||||
or "Действие ограничено администратором"
|
||||
)
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append(
|
||||
[InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]
|
||||
)
|
||||
keyboard.append(
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="kassa_ai")
|
||||
|
||||
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_kassa_ai_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_BUTTON", "◀️ Назад"),
|
||||
callback_data="menu_balance",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"KASSA_AI_ENTER_AMOUNT",
|
||||
"💳 <b>Пополнение через {name}</b>\n\n"
|
||||
"Введите сумму пополнения в рублях.\n\n"
|
||||
"Минимум: {min_amount}₽\n"
|
||||
"Максимум: {max_amount}₽",
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f"{max_amount:,}".replace(",", " "),
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_kassa_ai_custom_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process custom amount input for KassaAI payment.
|
||||
"""
|
||||
data = await state.get_data()
|
||||
if data.get("payment_method") != "kassa_ai":
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
amount_text = message.text.replace(",", ".").replace(" ", "").strip()
|
||||
amount_rubles = float(amount_text)
|
||||
amount_kopeks = int(amount_rubles * 100)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAYMENT_INVALID_AMOUNT",
|
||||
"Введите корректную сумму числом.",
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
await process_kassa_ai_payment_amount(
|
||||
message=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
state=state,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_kassa_ai_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process quick amount selection for KassaAI payment.
|
||||
Called when user clicks a predefined amount button.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_kassa_ai_enabled():
|
||||
await callback.answer(
|
||||
texts.t("KASSA_AI_NOT_AVAILABLE", "KassaAI временно недоступен"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|kassa_ai|{amount_kopeks}
|
||||
try:
|
||||
parts = callback.data.split("|")
|
||||
if len(parts) >= 3:
|
||||
amount_kopeks = int(parts[2])
|
||||
else:
|
||||
await callback.answer("Invalid callback data", show_alert=True)
|
||||
return
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("Invalid amount", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, "restriction_topup", False):
|
||||
reason = (
|
||||
getattr(db_user, "restriction_reason", None)
|
||||
or "Действие ограничено администратором"
|
||||
)
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append(
|
||||
[InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]
|
||||
)
|
||||
keyboard.append(
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await callback.answer(
|
||||
texts.t("AMOUNT_TOO_LOW_SHORT", "Сумма слишком мала"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await callback.answer(
|
||||
texts.t("AMOUNT_TOO_HIGH_SHORT", "Сумма слишком велика"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
await state.clear()
|
||||
|
||||
await _create_kassa_ai_payment_and_respond(
|
||||
message_or_callback=callback.message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=True,
|
||||
)
|
||||
@@ -118,6 +118,12 @@ async def route_payment_by_method(
|
||||
await process_freekassa_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == "kassa_ai":
|
||||
from .kassa_ai import process_kassa_ai_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_kassa_ai_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -990,6 +996,16 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
F.data.startswith("topup_amount|freekassa|")
|
||||
)
|
||||
|
||||
from .kassa_ai import start_kassa_ai_topup, process_kassa_ai_quick_amount
|
||||
dp.callback_query.register(
|
||||
start_kassa_ai_topup,
|
||||
F.data == "topup_kassa_ai"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
process_kassa_ai_quick_amount,
|
||||
F.data.startswith("topup_amount|kassa_ai|")
|
||||
)
|
||||
|
||||
from .mulenpay import check_mulenpay_payment_status
|
||||
dp.callback_query.register(
|
||||
check_mulenpay_payment_status,
|
||||
|
||||
+66
-20
@@ -48,6 +48,10 @@ from app.utils.promo_offer import (
|
||||
)
|
||||
from app.utils.timezone import format_local_datetime
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from app.middlewares.channel_checker import (
|
||||
get_pending_payload_from_redis,
|
||||
delete_pending_payload_from_redis,
|
||||
)
|
||||
from app.database.crud.subscription import decrement_subscription_server_counts
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
|
||||
@@ -327,6 +331,19 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
campaign_notification_sent = data.pop("campaign_notification_sent", False)
|
||||
state_needs_update = had_pending_payload or had_campaign_notification_flag
|
||||
|
||||
# Если в FSM state нет payload, пробуем получить из Redis (резервный механизм)
|
||||
if not pending_start_payload:
|
||||
redis_payload = await get_pending_payload_from_redis(message.from_user.id)
|
||||
if redis_payload:
|
||||
pending_start_payload = redis_payload
|
||||
state_needs_update = True
|
||||
logger.info(
|
||||
"📦 START: Payload '%s' восстановлен из Redis (fallback)",
|
||||
pending_start_payload,
|
||||
)
|
||||
# Очищаем Redis после получения
|
||||
await delete_pending_payload_from_redis(message.from_user.id)
|
||||
|
||||
referral_code = None
|
||||
campaign = None
|
||||
start_args = message.text.split()
|
||||
@@ -1832,6 +1849,17 @@ async def required_sub_channel_check(
|
||||
state_data = await state.get_data() or {}
|
||||
|
||||
pending_start_payload = state_data.pop("pending_start_payload", None)
|
||||
|
||||
# Если в FSM state нет payload, пробуем получить из Redis (резервный механизм)
|
||||
if not pending_start_payload:
|
||||
redis_payload = await get_pending_payload_from_redis(query.from_user.id)
|
||||
if redis_payload:
|
||||
pending_start_payload = redis_payload
|
||||
logger.info(
|
||||
"📦 CHANNEL CHECK: Payload '%s' восстановлен из Redis (fallback)",
|
||||
pending_start_payload,
|
||||
)
|
||||
|
||||
state_updated = pending_start_payload is not None
|
||||
|
||||
if pending_start_payload:
|
||||
@@ -1840,27 +1868,27 @@ async def required_sub_channel_check(
|
||||
pending_start_payload,
|
||||
)
|
||||
|
||||
if "campaign_id" not in state_data and "referral_code" not in state_data:
|
||||
campaign = await get_campaign_by_start_parameter(
|
||||
db,
|
||||
pending_start_payload,
|
||||
only_active=True,
|
||||
)
|
||||
# Очищаем Redis после получения payload
|
||||
await delete_pending_payload_from_redis(query.from_user.id)
|
||||
|
||||
if campaign:
|
||||
state_data["campaign_id"] = campaign.id
|
||||
logger.info(
|
||||
"📣 CHANNEL CHECK: Кампания %s восстановлена из payload",
|
||||
campaign.id,
|
||||
)
|
||||
else:
|
||||
state_data["referral_code"] = pending_start_payload
|
||||
logger.info(
|
||||
"🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код",
|
||||
)
|
||||
# Всегда обновляем referral_code если есть новый payload
|
||||
# (исправление бага с устаревшими данными в state)
|
||||
campaign = await get_campaign_by_start_parameter(
|
||||
db,
|
||||
pending_start_payload,
|
||||
only_active=True,
|
||||
)
|
||||
|
||||
if campaign:
|
||||
state_data["campaign_id"] = campaign.id
|
||||
logger.info(
|
||||
"📣 CHANNEL CHECK: Кампания %s восстановлена из payload",
|
||||
campaign.id,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"ℹ️ CHANNEL CHECK: Payload уже обработан ранее, пропускаем восстановление",
|
||||
state_data["referral_code"] = pending_start_payload
|
||||
logger.info(
|
||||
"🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код",
|
||||
)
|
||||
|
||||
if state_updated:
|
||||
@@ -1986,9 +2014,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 +2036,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
|
||||
|
||||
@@ -599,7 +599,18 @@ async def show_trial_offer(
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if db_user.subscription or db_user.has_had_paid_subscription:
|
||||
# Проверяем, использовал ли пользователь триал
|
||||
# PENDING триальные подписки не считаются - пользователь может повторить оплату
|
||||
trial_blocked = False
|
||||
if db_user.has_had_paid_subscription:
|
||||
trial_blocked = True
|
||||
elif db_user.subscription:
|
||||
sub = db_user.subscription
|
||||
# Разрешаем если это PENDING триальная подписка (повторная попытка оплаты)
|
||||
if not (sub.status == SubscriptionStatus.PENDING.value and sub.is_trial):
|
||||
trial_blocked = True
|
||||
|
||||
if trial_blocked:
|
||||
await callback.message.edit_text(
|
||||
texts.TRIAL_ALREADY_USED,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
@@ -771,6 +782,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,
|
||||
@@ -807,7 +825,18 @@ async def activate_trial(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if db_user.subscription or db_user.has_had_paid_subscription:
|
||||
# Проверяем, использовал ли пользователь триал
|
||||
# PENDING триальные подписки не считаются - пользователь может повторить оплату
|
||||
trial_blocked = False
|
||||
if db_user.has_had_paid_subscription:
|
||||
trial_blocked = True
|
||||
elif db_user.subscription:
|
||||
sub = db_user.subscription
|
||||
# Разрешаем если это PENDING триальная подписка (повторная попытка оплаты)
|
||||
if not (sub.status == SubscriptionStatus.PENDING.value and sub.is_trial):
|
||||
trial_blocked = True
|
||||
|
||||
if trial_blocked:
|
||||
await callback.message.edit_text(
|
||||
texts.TRIAL_ALREADY_USED,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
@@ -1430,6 +1459,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 +2907,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 +2928,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="покупка подписки (повторная попытка)",
|
||||
)
|
||||
|
||||
@@ -3346,7 +3382,17 @@ async def handle_trial_pay_with_balance(
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверяем права на триал
|
||||
if db_user.subscription or db_user.has_had_paid_subscription:
|
||||
# PENDING триальные подписки не считаются - пользователь может повторить оплату
|
||||
trial_blocked = False
|
||||
if db_user.has_had_paid_subscription:
|
||||
trial_blocked = True
|
||||
elif db_user.subscription:
|
||||
sub = db_user.subscription
|
||||
# Разрешаем если это PENDING триальная подписка (повторная попытка оплаты)
|
||||
if not (sub.status == SubscriptionStatus.PENDING.value and sub.is_trial):
|
||||
trial_blocked = True
|
||||
|
||||
if trial_blocked:
|
||||
await callback.message.edit_text(
|
||||
texts.TRIAL_ALREADY_USED,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
@@ -3681,7 +3727,17 @@ async def handle_trial_payment_method(
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверяем права на триал
|
||||
if db_user.subscription or db_user.has_had_paid_subscription:
|
||||
# PENDING триальные подписки не считаются - пользователь может повторить оплату
|
||||
trial_blocked = False
|
||||
if db_user.has_had_paid_subscription:
|
||||
trial_blocked = True
|
||||
elif db_user.subscription:
|
||||
sub = db_user.subscription
|
||||
# Разрешаем если это PENDING триальная подписка (повторная попытка оплаты)
|
||||
if not (sub.status == SubscriptionStatus.PENDING.value and sub.is_trial):
|
||||
trial_blocked = True
|
||||
|
||||
if trial_blocked:
|
||||
await callback.message.edit_text(
|
||||
texts.TRIAL_ALREADY_USED,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
@@ -3758,6 +3814,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
|
||||
@@ -3793,11 +3850,12 @@ async def handle_trial_payment_method(
|
||||
elif payment_method == "yookassa":
|
||||
# Оплата через YooKassa карта
|
||||
payment_result = await payment_service.create_yookassa_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
|
||||
),
|
||||
user_id=db_user.id,
|
||||
metadata={
|
||||
"type": "trial",
|
||||
"subscription_id": pending_subscription.id,
|
||||
@@ -3825,20 +3883,37 @@ async def handle_trial_payment_method(
|
||||
|
||||
elif payment_method == "cryptobot":
|
||||
# Оплата через CryptoBot
|
||||
# Конвертируем копейки в USD
|
||||
from app.utils.currency_converter import currency_converter
|
||||
try:
|
||||
usd_rate = await currency_converter.get_usd_to_rub_rate()
|
||||
except Exception as rate_error:
|
||||
logger.warning("Не удалось получить курс USD: %s", rate_error)
|
||||
usd_rate = 95.0
|
||||
|
||||
amount_rubles = trial_price_kopeks / 100
|
||||
amount_usd = round(amount_rubles / usd_rate, 2)
|
||||
if amount_usd < 1:
|
||||
amount_usd = 1.0
|
||||
|
||||
payment_result = await payment_service.create_cryptobot_payment(
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_usd=amount_usd,
|
||||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
),
|
||||
user_id=db_user.id,
|
||||
metadata={
|
||||
"type": "trial",
|
||||
"subscription_id": pending_subscription.id,
|
||||
"user_id": db_user.id,
|
||||
},
|
||||
payload=f"trial_{pending_subscription.id}_{db_user.id}",
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("pay_url"):
|
||||
payment_url = (
|
||||
payment_result.get("mini_app_invoice_url")
|
||||
or payment_result.get("bot_invoice_url")
|
||||
or payment_result.get("web_app_invoice_url")
|
||||
) if payment_result else None
|
||||
|
||||
if not payment_result or not payment_url:
|
||||
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -3850,7 +3925,7 @@ async def handle_trial_payment_method(
|
||||
"💰 Сумма: {amount}"
|
||||
).format(amount=settings.format_price(trial_price_kopeks)),
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🪙 Оплатить", url=payment_result["pay_url"])],
|
||||
[InlineKeyboardButton(text="🪙 Оплатить", url=payment_url)],
|
||||
[InlineKeyboardButton(
|
||||
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
|
||||
callback_data=f"check_trial_cryptobot_{pending_subscription.id}"
|
||||
@@ -3863,19 +3938,16 @@ async def handle_trial_payment_method(
|
||||
elif payment_method == "heleket":
|
||||
# Оплата через Heleket
|
||||
payment_result = await payment_service.create_heleket_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
|
||||
),
|
||||
user_id=db_user.id,
|
||||
metadata={
|
||||
"type": "trial",
|
||||
"subscription_id": pending_subscription.id,
|
||||
"user_id": db_user.id,
|
||||
},
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("pay_url"):
|
||||
if not payment_result or not payment_result.get("payment_url"):
|
||||
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -3887,7 +3959,7 @@ async def handle_trial_payment_method(
|
||||
"💰 Сумма: {amount}"
|
||||
).format(amount=settings.format_price(trial_price_kopeks)),
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🪙 Оплатить", url=payment_result["pay_url"])],
|
||||
[InlineKeyboardButton(text="🪙 Оплатить", url=payment_result["payment_url"])],
|
||||
[InlineKeyboardButton(
|
||||
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
|
||||
callback_data=f"check_trial_heleket_{pending_subscription.id}"
|
||||
@@ -3900,19 +3972,16 @@ async def handle_trial_payment_method(
|
||||
elif payment_method == "mulenpay":
|
||||
# Оплата через MulenPay
|
||||
payment_result = await payment_service.create_mulenpay_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
|
||||
),
|
||||
user_id=db_user.id,
|
||||
metadata={
|
||||
"type": "trial",
|
||||
"subscription_id": pending_subscription.id,
|
||||
"user_id": db_user.id,
|
||||
},
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("pay_url"):
|
||||
if not payment_result or not payment_result.get("payment_url"):
|
||||
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -3925,7 +3994,7 @@ async def handle_trial_payment_method(
|
||||
"💰 Сумма: {amount}"
|
||||
).format(name=mulenpay_name, amount=settings.format_price(trial_price_kopeks)),
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["pay_url"])],
|
||||
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["payment_url"])],
|
||||
[InlineKeyboardButton(
|
||||
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
|
||||
callback_data=f"check_trial_mulenpay_{pending_subscription.id}"
|
||||
@@ -3938,19 +4007,16 @@ async def handle_trial_payment_method(
|
||||
elif payment_method == "pal24":
|
||||
# Оплата через PAL24
|
||||
payment_result = await payment_service.create_pal24_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
|
||||
),
|
||||
user_id=db_user.id,
|
||||
metadata={
|
||||
"type": "trial",
|
||||
"subscription_id": pending_subscription.id,
|
||||
"user_id": db_user.id,
|
||||
},
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("pay_url"):
|
||||
if not payment_result or not payment_result.get("payment_url"):
|
||||
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -3962,7 +4028,7 @@ async def handle_trial_payment_method(
|
||||
"💰 Сумма: {amount}"
|
||||
).format(amount=settings.format_price(trial_price_kopeks)),
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["pay_url"])],
|
||||
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["payment_url"])],
|
||||
[InlineKeyboardButton(
|
||||
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
|
||||
callback_data=f"check_trial_pal24_{pending_subscription.id}"
|
||||
@@ -3975,19 +4041,16 @@ async def handle_trial_payment_method(
|
||||
elif payment_method == "wata":
|
||||
# Оплата через WATA
|
||||
payment_result = await payment_service.create_wata_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
|
||||
),
|
||||
user_id=db_user.id,
|
||||
metadata={
|
||||
"type": "trial",
|
||||
"subscription_id": pending_subscription.id,
|
||||
"user_id": db_user.id,
|
||||
},
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("pay_url"):
|
||||
if not payment_result or not payment_result.get("payment_url"):
|
||||
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -3999,7 +4062,7 @@ async def handle_trial_payment_method(
|
||||
"💰 Сумма: {amount}"
|
||||
).format(amount=settings.format_price(trial_price_kopeks)),
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["pay_url"])],
|
||||
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["payment_url"])],
|
||||
[InlineKeyboardButton(
|
||||
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
|
||||
callback_data=f"check_trial_wata_{pending_subscription.id}"
|
||||
@@ -4009,6 +4072,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
],
|
||||
[
|
||||
|
||||
@@ -1471,6 +1471,16 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_kassa_ai_enabled():
|
||||
kassa_ai_name = settings.get_kassa_ai_display_name()
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_KASSA_AI", f"💳 {kassa_ai_name}"),
|
||||
callback_data=_build_callback("kassa_ai")
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_support_topup_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
|
||||
@@ -1187,6 +1187,8 @@
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>SBP (YooKassa)</b>",
|
||||
"PAYMENT_METHOD_WATA_DESCRIPTION": "via WATA",
|
||||
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Bank card (WATA)</b>",
|
||||
"PAYMENT_HELEKET_MARKUP_LABEL": "Provider markup",
|
||||
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Provider discount",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 Main menu",
|
||||
@@ -1552,6 +1554,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.",
|
||||
|
||||
@@ -1204,6 +1204,8 @@
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>СБП (YooKassa)</b>",
|
||||
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
|
||||
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банковская карта (WATA)</b>",
|
||||
"PAYMENT_HELEKET_MARKUP_LABEL": "Наценка провайдера",
|
||||
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Скидка провайдера",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На главную",
|
||||
@@ -1569,6 +1571,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": "❓ Не понимаю эту команду. Используйте кнопки меню.",
|
||||
|
||||
@@ -1125,6 +1125,8 @@
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банківська картка</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему швидких платежів YooKassa",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>СБП (YooKassa)</b>",
|
||||
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
|
||||
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банківська картка (WATA)</b>",
|
||||
"PAYMENT_HELEKET_MARKUP_LABEL": "Націнка провайдера",
|
||||
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Знижка провайдера",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На головну",
|
||||
|
||||
@@ -1123,6 +1123,8 @@
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME":"💳<b>银行卡</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION":"通过YooKassa快速支付系统",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_NAME":"🏦<b>SBP(YooKassa)</b>",
|
||||
"PAYMENT_METHOD_WATA_DESCRIPTION":"通过WATA",
|
||||
"PAYMENT_METHOD_WATA_NAME":"💳<b>银行卡(WATA)</b>",
|
||||
"PAYMENT_HELEKET_MARKUP_LABEL":"服务商加价",
|
||||
"PAYMENT_HELEKET_DISCOUNT_LABEL":"服务商折扣",
|
||||
"PAYMENT_RETURN_HOME_BUTTON":"🏠返回首页",
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -7,6 +7,7 @@ from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
@@ -23,6 +24,58 @@ from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Ключ для хранения pending_start_payload в Redis (резервный механизм)
|
||||
REDIS_PAYLOAD_KEY_PREFIX = "pending_start_payload:"
|
||||
REDIS_PAYLOAD_TTL = 3600 # 1 час
|
||||
|
||||
|
||||
async def save_pending_payload_to_redis(telegram_id: int, payload: str) -> bool:
|
||||
"""Сохраняет pending_start_payload в Redis напрямую (резервный механизм)."""
|
||||
try:
|
||||
redis_client = aioredis.from_url(settings.REDIS_URL)
|
||||
key = f"{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}"
|
||||
await redis_client.set(key, payload, ex=REDIS_PAYLOAD_TTL)
|
||||
await redis_client.aclose()
|
||||
logger.info(
|
||||
"💾 [Redis fallback] Сохранен payload '%s' для пользователя %s",
|
||||
payload,
|
||||
telegram_id,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"❌ [Redis fallback] Ошибка сохранения payload для %s: %s",
|
||||
telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def get_pending_payload_from_redis(telegram_id: int) -> Optional[str]:
|
||||
"""Получает pending_start_payload из Redis (резервный механизм)."""
|
||||
try:
|
||||
redis_client = aioredis.from_url(settings.REDIS_URL)
|
||||
key = f"{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}"
|
||||
payload = await redis_client.get(key)
|
||||
await redis_client.aclose()
|
||||
if payload:
|
||||
return payload.decode("utf-8") if isinstance(payload, bytes) else payload
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("❌ [Redis fallback] Ошибка получения payload для %s: %s", telegram_id, e)
|
||||
return None
|
||||
|
||||
|
||||
async def delete_pending_payload_from_redis(telegram_id: int) -> None:
|
||||
"""Удаляет pending_start_payload из Redis."""
|
||||
try:
|
||||
redis_client = aioredis.from_url(settings.REDIS_URL)
|
||||
key = f"{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}"
|
||||
await redis_client.delete(key)
|
||||
await redis_client.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
"""
|
||||
@@ -170,8 +223,11 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
event: TelegramObject,
|
||||
bot: Optional[Bot] = None,
|
||||
) -> None:
|
||||
if not state:
|
||||
return
|
||||
telegram_id = None
|
||||
if isinstance(event, Message):
|
||||
telegram_id = event.from_user.id if event.from_user else None
|
||||
elif isinstance(event, CallbackQuery):
|
||||
telegram_id = event.from_user.id if event.from_user else None
|
||||
|
||||
message: Optional[Message] = None
|
||||
if isinstance(event, Message):
|
||||
@@ -194,11 +250,26 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
|
||||
payload = parts[1]
|
||||
|
||||
state_data = await state.get_data() or {}
|
||||
if state_data.get("pending_start_payload") != payload:
|
||||
state_data["pending_start_payload"] = payload
|
||||
await state.set_data(state_data)
|
||||
logger.debug("💾 Сохранен start payload %s для последующей обработки", payload)
|
||||
# Сохраняем в FSM state
|
||||
if state:
|
||||
state_data = await state.get_data() or {}
|
||||
if state_data.get("pending_start_payload") != payload:
|
||||
state_data["pending_start_payload"] = payload
|
||||
await state.set_data(state_data)
|
||||
logger.info(
|
||||
"💾 Сохранен start payload '%s' для пользователя %s (FSM)",
|
||||
payload,
|
||||
telegram_id,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ _capture_start_payload: state=None для пользователя %s",
|
||||
telegram_id,
|
||||
)
|
||||
|
||||
# Также сохраняем в Redis как резерв (на случай потери FSM state)
|
||||
if telegram_id:
|
||||
await save_pending_payload_to_redis(telegram_id, payload)
|
||||
|
||||
if bot and message.from_user:
|
||||
await self._try_send_campaign_visit_notification(
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject
|
||||
|
||||
@@ -8,12 +8,18 @@ from app.database.models import SubscriptionStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Буфер времени перед деактивацией (защита от race condition при продлении)
|
||||
EXPIRATION_BUFFER_MINUTES = 5
|
||||
|
||||
|
||||
class SubscriptionStatusMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Проверяет статус подписки пользователя.
|
||||
ВАЖНО: Использует db и db_user из data, которые уже загружены в AuthMiddleware.
|
||||
Не создаёт дополнительных сессий БД.
|
||||
|
||||
Деактивирует подписку только если она истекла более чем на EXPIRATION_BUFFER_MINUTES минут.
|
||||
Это защищает от race conditions при продлении подписки.
|
||||
"""
|
||||
|
||||
async def __call__(
|
||||
@@ -35,11 +41,26 @@ class SubscriptionStatusMiddleware(BaseMiddleware):
|
||||
subscription.end_date and
|
||||
subscription.end_date <= current_time):
|
||||
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.updated_at = current_time
|
||||
await db.commit()
|
||||
# Вычисляем насколько давно истекла подписка
|
||||
time_since_expiry = current_time - subscription.end_date
|
||||
|
||||
logger.info(f"⏰ Middleware: Статус подписки пользователя {user.id} изменен на 'expired' (время истекло)")
|
||||
# Деактивируем только если прошло больше буфера (защита от race condition)
|
||||
if time_since_expiry > timedelta(minutes=EXPIRATION_BUFFER_MINUTES):
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.updated_at = current_time
|
||||
await db.commit()
|
||||
|
||||
logger.warning(
|
||||
f"⏰ Middleware DEACTIVATION: подписка {subscription.id} "
|
||||
f"(user_id={user.id}) деактивирована. "
|
||||
f"end_date={subscription.end_date}, просрочена на {time_since_expiry}"
|
||||
)
|
||||
else:
|
||||
# Подписка только что истекла - не деактивируем сразу (может быть продление)
|
||||
logger.debug(
|
||||
f"⏰ Middleware: подписка пользователя {user.id} истекла недавно "
|
||||
f"({time_since_expiry}), ждём буфер {EXPIRATION_BUFFER_MINUTES} мин"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса подписки: {e}")
|
||||
|
||||
@@ -8,6 +8,7 @@ import hmac
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import unquote_plus
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -278,9 +279,15 @@ class CloudPaymentsService:
|
||||
"""
|
||||
Verify CloudPayments webhook signature.
|
||||
|
||||
CloudPayments uses two different HMAC headers:
|
||||
- Content-HMAC: calculated from URL-encoded body (raw)
|
||||
- X-Content-HMAC: calculated from URL-decoded body
|
||||
|
||||
This method tries both variants to ensure compatibility.
|
||||
|
||||
Args:
|
||||
body: Raw request body bytes
|
||||
signature: Signature from X-Content-HMAC header
|
||||
signature: Signature from X-Content-HMAC or Content-HMAC header
|
||||
api_secret: CloudPayments API secret
|
||||
|
||||
Returns:
|
||||
@@ -289,15 +296,37 @@ class CloudPaymentsService:
|
||||
if not signature or not api_secret:
|
||||
return False
|
||||
|
||||
calculated = base64.b64encode(
|
||||
hmac.new(
|
||||
api_secret.encode(),
|
||||
body,
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
).decode()
|
||||
def calc_hmac(data: bytes) -> str:
|
||||
return base64.b64encode(
|
||||
hmac.new(
|
||||
api_secret.encode(),
|
||||
data,
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
).decode()
|
||||
|
||||
return hmac.compare_digest(calculated, signature)
|
||||
# Try with raw (URL-encoded) body first (for Content-HMAC)
|
||||
calculated_raw = calc_hmac(body)
|
||||
if hmac.compare_digest(calculated_raw, signature):
|
||||
return True
|
||||
|
||||
# Try with URL-decoded body (for X-Content-HMAC)
|
||||
calculated_decoded = None
|
||||
try:
|
||||
decoded_body = unquote_plus(body.decode("utf-8")).encode("utf-8")
|
||||
calculated_decoded = calc_hmac(decoded_body)
|
||||
if hmac.compare_digest(calculated_decoded, signature):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.warning(
|
||||
"CloudPayments signature mismatch: expected_raw=%s..., expected_decoded=%s..., got=%s...",
|
||||
calculated_raw[:20],
|
||||
calculated_decoded[:20] if calculated_decoded else "N/A",
|
||||
signature[:20],
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def parse_webhook_data(form_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Сервис для работы с API KassaAI (api.fk.life)."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Optional, Dict, Any, Set
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Кэш для публичного IP
|
||||
_cached_public_ip: Optional[str] = None
|
||||
_ip_fetch_lock = asyncio.Lock()
|
||||
|
||||
API_BASE_URL = "https://api.fk.life/v1"
|
||||
|
||||
# Сервисы для определения публичного IP
|
||||
IP_SERVICES = [
|
||||
"https://api.ipify.org",
|
||||
"https://ifconfig.me/ip",
|
||||
"https://icanhazip.com",
|
||||
"https://ipinfo.io/ip",
|
||||
]
|
||||
|
||||
|
||||
async def get_public_ip() -> str:
|
||||
"""
|
||||
Получает публичный IP сервера.
|
||||
1. Проверяет переменную окружения SERVER_PUBLIC_IP
|
||||
2. Если нет - запрашивает через внешние сервисы и кэширует
|
||||
"""
|
||||
global _cached_public_ip
|
||||
|
||||
env_ip = getattr(settings, 'SERVER_PUBLIC_IP', None)
|
||||
if env_ip:
|
||||
return env_ip
|
||||
|
||||
if _cached_public_ip:
|
||||
return _cached_public_ip
|
||||
|
||||
async with _ip_fetch_lock:
|
||||
if _cached_public_ip:
|
||||
return _cached_public_ip
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
for service_url in IP_SERVICES:
|
||||
try:
|
||||
async with session.get(
|
||||
service_url,
|
||||
timeout=aiohttp.ClientTimeout(total=5)
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
ip = (await response.text()).strip()
|
||||
if ip and len(ip.split('.')) == 4:
|
||||
_cached_public_ip = ip
|
||||
logger.info(f"KassaAI: определён публичный IP сервера: {ip}")
|
||||
return ip
|
||||
except Exception as e:
|
||||
logger.debug(f"KassaAI: не удалось получить IP от {service_url}: {e}")
|
||||
continue
|
||||
|
||||
fallback_ip = "127.0.0.1"
|
||||
logger.warning(f"KassaAI: не удалось определить публичный IP, используем fallback: {fallback_ip}")
|
||||
_cached_public_ip = fallback_ip
|
||||
return fallback_ip
|
||||
|
||||
|
||||
class KassaAiService:
|
||||
"""Сервис для работы с API KassaAI."""
|
||||
|
||||
def __init__(self):
|
||||
self._shop_id: Optional[int] = None
|
||||
self._api_key: Optional[str] = None
|
||||
self._secret2: Optional[str] = None
|
||||
|
||||
@property
|
||||
def shop_id(self) -> int:
|
||||
if self._shop_id is None:
|
||||
self._shop_id = settings.KASSA_AI_SHOP_ID
|
||||
return self._shop_id or 0
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
if self._api_key is None:
|
||||
self._api_key = settings.KASSA_AI_API_KEY
|
||||
return self._api_key or ""
|
||||
|
||||
@property
|
||||
def secret2(self) -> str:
|
||||
if self._secret2 is None:
|
||||
self._secret2 = settings.KASSA_AI_SECRET_WORD_2
|
||||
return self._secret2 or ""
|
||||
|
||||
def _generate_hmac_signature(self, params: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Генерирует подпись для API запроса (HMAC-SHA256).
|
||||
Сортирует ключи, соединяет значения через |
|
||||
"""
|
||||
sign_data = {k: v for k, v in params.items() if k != "signature"}
|
||||
sorted_keys = sorted(sign_data.keys())
|
||||
msg = "|".join(str(sign_data[k]) for k in sorted_keys)
|
||||
|
||||
return hmac.new(
|
||||
self.api_key.encode("utf-8"),
|
||||
msg.encode("utf-8"),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
def verify_webhook_signature(
|
||||
self, shop_id: int, amount: float, order_id: str, sign: str
|
||||
) -> bool:
|
||||
"""
|
||||
Проверяет подпись webhook уведомления.
|
||||
Формат: MD5(shop_id:amount:secret2:order_id)
|
||||
"""
|
||||
try:
|
||||
# Приводим amount к строке без лишних нулей
|
||||
if isinstance(amount, float) and amount.is_integer():
|
||||
amount_str = str(int(amount))
|
||||
else:
|
||||
amount_str = str(amount)
|
||||
|
||||
sign_str = f"{shop_id}:{amount_str}:{self.secret2}:{order_id}"
|
||||
expected_sign = hashlib.md5(sign_str.encode('utf-8')).hexdigest()
|
||||
|
||||
return expected_sign.lower() == sign.lower()
|
||||
except Exception as e:
|
||||
logger.error(f"KassaAI webhook verify error: {e}")
|
||||
return False
|
||||
|
||||
async def create_order(
|
||||
self,
|
||||
order_id: str,
|
||||
amount: float,
|
||||
currency: str = "RUB",
|
||||
email: Optional[str] = None,
|
||||
ip: Optional[str] = None,
|
||||
payment_system_id: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Создает заказ через API KassaAI.
|
||||
POST /orders/create
|
||||
|
||||
payment_system_id:
|
||||
- 44 = СБП (QR код)
|
||||
- 36 = Банковские карты РФ
|
||||
- 43 = SberPay
|
||||
"""
|
||||
# Приводим amount к int, если это целое число
|
||||
final_amount = int(amount) if float(amount).is_integer() else amount
|
||||
|
||||
# Payment system из настроек или default (44 = СБП)
|
||||
ps_id = payment_system_id or settings.KASSA_AI_PAYMENT_SYSTEM_ID or 44
|
||||
|
||||
# Email: используем telegram-формат если не указан
|
||||
target_email = email or f"user_{order_id}@telegram.org"
|
||||
|
||||
# Определяем публичный IP сервера
|
||||
server_ip = ip or await get_public_ip()
|
||||
|
||||
params = {
|
||||
"shopId": self.shop_id,
|
||||
"nonce": int(time.time_ns()),
|
||||
"paymentId": str(order_id),
|
||||
"i": ps_id,
|
||||
"email": target_email,
|
||||
"ip": server_ip,
|
||||
"amount": final_amount,
|
||||
"currency": currency,
|
||||
}
|
||||
|
||||
# Генерируем подпись HMAC-SHA256
|
||||
params["signature"] = self._generate_hmac_signature(params)
|
||||
|
||||
logger.info(f"KassaAI API create_order: shop_id={self.shop_id}, order_id={order_id}, amount={final_amount}, ps_id={ps_id}")
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{API_BASE_URL}/orders/create",
|
||||
json=params,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as response:
|
||||
text = await response.text()
|
||||
logger.info(f"KassaAI API response: {text}")
|
||||
|
||||
data = await response.json()
|
||||
|
||||
# Проверяем на ошибку
|
||||
if data.get("type") == "error":
|
||||
error_msg = data.get("error") or data.get("message") or "Unknown error"
|
||||
logger.error(f"KassaAI create_order error: {error_msg}")
|
||||
raise Exception(f"KassaAI API error: {error_msg}")
|
||||
|
||||
if data.get("type") == "success":
|
||||
return {
|
||||
"location": data.get("location"),
|
||||
"orderId": data.get("orderId"),
|
||||
"paymentId": data.get("paymentId"),
|
||||
}
|
||||
|
||||
# Неизвестный формат ответа
|
||||
logger.error(f"KassaAI unexpected response: {data}")
|
||||
raise Exception(f"KassaAI unexpected response format")
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception(f"KassaAI API connection error: {e}")
|
||||
raise
|
||||
|
||||
async def create_order_and_get_url(
|
||||
self,
|
||||
order_id: str,
|
||||
amount: float,
|
||||
currency: str = "RUB",
|
||||
email: Optional[str] = None,
|
||||
ip: Optional[str] = None,
|
||||
payment_system_id: Optional[int] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Создает заказ через API и возвращает URL для оплаты.
|
||||
"""
|
||||
result = await self.create_order(
|
||||
order_id=order_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
email=email,
|
||||
ip=ip,
|
||||
payment_system_id=payment_system_id,
|
||||
)
|
||||
location = result.get("location")
|
||||
if not location:
|
||||
raise Exception("KassaAI API did not return payment URL (location)")
|
||||
return location
|
||||
|
||||
async def get_order_status(self, order_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Получает статус заказа.
|
||||
POST /orders
|
||||
"""
|
||||
params = {
|
||||
"shopId": self.shop_id,
|
||||
"nonce": int(time.time_ns()),
|
||||
"paymentId": str(order_id),
|
||||
}
|
||||
params["signature"] = self._generate_hmac_signature(params)
|
||||
|
||||
logger.debug(f"KassaAI get_order_status: order_id={order_id}")
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{API_BASE_URL}/orders",
|
||||
json=params,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as response:
|
||||
text = await response.text()
|
||||
logger.debug(f"KassaAI get_order_status response: {text}")
|
||||
return await response.json()
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception(f"KassaAI API connection error: {e}")
|
||||
raise
|
||||
|
||||
async def get_balance(self) -> Dict[str, Any]:
|
||||
"""Получает баланс магазина."""
|
||||
params = {
|
||||
"shopId": self.shop_id,
|
||||
"nonce": int(time.time_ns()),
|
||||
}
|
||||
params["signature"] = self._generate_hmac_signature(params)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{API_BASE_URL}/balance",
|
||||
json=params,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as response:
|
||||
return await response.json()
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception(f"KassaAI API connection error: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# Singleton instance
|
||||
kassa_ai_service = KassaAiService()
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ from .platega import PlategaPaymentMixin
|
||||
from .wata import WataPaymentMixin
|
||||
from .cloudpayments import CloudPaymentsPaymentMixin
|
||||
from .freekassa import FreekassaPaymentMixin
|
||||
from .kassa_ai import KassaAiPaymentMixin
|
||||
|
||||
__all__ = [
|
||||
"PaymentCommonMixin",
|
||||
@@ -30,4 +31,5 @@ __all__ = [
|
||||
"WataPaymentMixin",
|
||||
"CloudPaymentsPaymentMixin",
|
||||
"FreekassaPaymentMixin",
|
||||
"KassaAiPaymentMixin",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
"""Mixin для интеграции с KassaAI (api.fk.life)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.kassa_ai_service import kassa_ai_service
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
|
||||
|
||||
class KassaAiPaymentMixin:
|
||||
"""Mixin для работы с платежами KassaAI."""
|
||||
|
||||
async def create_kassa_ai_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса",
|
||||
email: Optional[str] = None,
|
||||
language: str = "ru",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Создает платеж KassaAI.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
amount_kopeks: Сумма в копейках
|
||||
description: Описание платежа
|
||||
email: Email пользователя
|
||||
language: Язык интерфейса
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_kassa_ai_enabled():
|
||||
logger.error("KassaAI не настроен")
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.KASSA_AI_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"KassaAI: сумма %s меньше минимальной %s",
|
||||
amount_kopeks,
|
||||
settings.KASSA_AI_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.KASSA_AI_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"KassaAI: сумма %s больше максимальной %s",
|
||||
amount_kopeks,
|
||||
settings.KASSA_AI_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
# Генерируем уникальный order_id
|
||||
order_id = f"kai_{user_id}_{uuid.uuid4().hex[:12]}"
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.KASSA_AI_CURRENCY
|
||||
|
||||
# Срок действия платежа (1 час по умолчанию)
|
||||
expires_at = datetime.utcnow() + timedelta(hours=1)
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
"user_id": user_id,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"description": description,
|
||||
"language": language,
|
||||
"type": "balance_topup",
|
||||
}
|
||||
|
||||
try:
|
||||
# Используем API для создания заказа
|
||||
result = await kassa_ai_service.create_order(
|
||||
order_id=order_id,
|
||||
amount=amount_rubles,
|
||||
currency=currency,
|
||||
email=email,
|
||||
payment_system_id=settings.KASSA_AI_PAYMENT_SYSTEM_ID,
|
||||
)
|
||||
|
||||
payment_url = result.get("location")
|
||||
if not payment_url:
|
||||
logger.error("KassaAI API не вернул URL платежа")
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"KassaAI API: создан заказ order_id=%s, url=%s",
|
||||
order_id,
|
||||
payment_url,
|
||||
)
|
||||
|
||||
# Импортируем CRUD модуль
|
||||
kassa_ai_crud = import_module("app.database.crud.kassa_ai")
|
||||
|
||||
# Сохраняем в БД
|
||||
local_payment = await kassa_ai_crud.create_kassa_ai_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_system_id=settings.KASSA_AI_PAYMENT_SYSTEM_ID,
|
||||
expires_at=expires_at,
|
||||
metadata_json=json.dumps(metadata, ensure_ascii=False),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"KassaAI: создан платеж order_id=%s, user_id=%s, amount=%s %s",
|
||||
order_id,
|
||||
user_id,
|
||||
amount_rubles,
|
||||
currency,
|
||||
)
|
||||
|
||||
return {
|
||||
"order_id": order_id,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"amount_rubles": amount_rubles,
|
||||
"currency": currency,
|
||||
"payment_url": payment_url,
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"local_payment_id": local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("KassaAI: ошибка создания платежа: %s", e)
|
||||
return None
|
||||
|
||||
async def process_kassa_ai_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
merchant_id: int,
|
||||
amount: float,
|
||||
order_id: str,
|
||||
sign: str,
|
||||
intid: str,
|
||||
cur_id: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает webhook от KassaAI.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
merchant_id: ID магазина (MERCHANT_ID)
|
||||
amount: Сумма платежа (AMOUNT)
|
||||
order_id: Номер заказа (MERCHANT_ORDER_ID)
|
||||
sign: Подпись (SIGN)
|
||||
intid: ID транзакции KassaAI
|
||||
cur_id: ID валюты/платежной системы (CUR_ID)
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
# Проверка подписи
|
||||
if not kassa_ai_service.verify_webhook_signature(
|
||||
merchant_id, amount, order_id, sign
|
||||
):
|
||||
logger.warning(
|
||||
"KassaAI webhook: неверная подпись для order_id=%s", order_id
|
||||
)
|
||||
return False
|
||||
|
||||
# Импортируем CRUD модуль
|
||||
kassa_ai_crud = import_module("app.database.crud.kassa_ai")
|
||||
|
||||
# Получаем платеж из БД
|
||||
payment = await kassa_ai_crud.get_kassa_ai_payment_by_order_id(
|
||||
db, order_id
|
||||
)
|
||||
if not payment:
|
||||
logger.warning(
|
||||
"KassaAI webhook: платеж не найден order_id=%s", order_id
|
||||
)
|
||||
return False
|
||||
|
||||
# Проверка дублирования
|
||||
if payment.is_paid:
|
||||
logger.info(
|
||||
"KassaAI webhook: платеж уже обработан order_id=%s", order_id
|
||||
)
|
||||
return True
|
||||
|
||||
# Проверка суммы
|
||||
expected_amount = payment.amount_kopeks / 100
|
||||
if abs(amount - expected_amount) > 0.01:
|
||||
logger.warning(
|
||||
"KassaAI webhook: несоответствие суммы ожидалось=%s, получено=%s",
|
||||
expected_amount,
|
||||
amount,
|
||||
)
|
||||
return False
|
||||
|
||||
# Обновляем статус платежа
|
||||
callback_payload = {
|
||||
"merchant_id": merchant_id,
|
||||
"amount": amount,
|
||||
"order_id": order_id,
|
||||
"intid": intid,
|
||||
"cur_id": cur_id,
|
||||
}
|
||||
|
||||
payment = await kassa_ai_crud.update_kassa_ai_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status="success",
|
||||
is_paid=True,
|
||||
kassa_ai_order_id=intid,
|
||||
payment_system_id=cur_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
# Финализируем платеж (начисляем баланс, создаем транзакцию)
|
||||
return await self._finalize_kassa_ai_payment(
|
||||
db, payment, intid=intid, trigger="webhook"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("KassaAI webhook: ошибка обработки: %s", e)
|
||||
return False
|
||||
|
||||
async def _finalize_kassa_ai_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
intid: Optional[str],
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
"KassaAI платеж %s уже привязан к транзакции (trigger=%s)",
|
||||
payment.order_id,
|
||||
trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
# Получаем пользователя
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error(
|
||||
"Пользователь %s не найден для KassaAI платежа %s (trigger=%s)",
|
||||
payment.user_id,
|
||||
payment.order_id,
|
||||
trigger,
|
||||
)
|
||||
return False
|
||||
|
||||
# Создаем транзакцию
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через KassaAI (#{intid or payment.order_id})",
|
||||
payment_method=PaymentMethod.KASSA_AI,
|
||||
external_id=str(intid) if intid else payment.order_id,
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
# Связываем платеж с транзакцией
|
||||
kassa_ai_crud = import_module("app.database.crud.kassa_ai")
|
||||
await kassa_ai_crud.update_kassa_ai_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=payment.status,
|
||||
transaction_id=transaction.id,
|
||||
)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
# Начисляем баланс
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "Первое пополнение" if was_first_topup else "Пополнение"
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Обработка реферального пополнения
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db, user.id, payment.amount_kopeks, getattr(self, "bot", None)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения KassaAI: %s", error
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(payment)
|
||||
|
||||
# Отправка уведомления админам
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления KassaAI: %s", error
|
||||
)
|
||||
|
||||
# Отправка уведомления пользователю
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
display_name = settings.get_kassa_ai_display_name()
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
f"💳 Способ: {display_name}\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления пользователю KassaAI: %s", error
|
||||
)
|
||||
|
||||
# Автопокупка подписки
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from aiogram import types
|
||||
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
auto_purchase_success = False
|
||||
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Отправляем уведомление только если его ещё не отправили
|
||||
if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER",
|
||||
"У вас есть незавершенное оформление подписки. Вернуться?",
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"BALANCE_TOPUP_CART_BUTTON",
|
||||
"🛒 Продолжить оформление",
|
||||
),
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=(
|
||||
"✅ Баланс пополнен на "
|
||||
f"{settings.format_price(payment.amount_kopeks)}!\n\n"
|
||||
f"{cart_message}"
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
|
||||
user.id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан KassaAI платеж %s для пользователя %s (trigger=%s)",
|
||||
payment.order_id,
|
||||
payment.user_id,
|
||||
trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_kassa_ai_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Проверяет статус платежа через API.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
order_id: Номер заказа
|
||||
|
||||
Returns:
|
||||
Данные о статусе платежа
|
||||
"""
|
||||
try:
|
||||
status_data = await kassa_ai_service.get_order_status(order_id)
|
||||
return status_data
|
||||
except Exception as e:
|
||||
logger.exception("KassaAI: ошибка проверки статуса: %s", e)
|
||||
return None
|
||||
@@ -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=(
|
||||
|
||||
@@ -30,6 +30,7 @@ from app.services.payment import (
|
||||
)
|
||||
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
|
||||
from app.services.payment.freekassa import FreekassaPaymentMixin
|
||||
from app.services.payment.kassa_ai import KassaAiPaymentMixin
|
||||
from app.services.yookassa_service import YooKassaService
|
||||
from app.services.wata_service import WataService
|
||||
from app.services.cloudpayments_service import CloudPaymentsService
|
||||
@@ -171,6 +172,11 @@ async def get_wata_payment_by_id(*args, **kwargs):
|
||||
return await wata_crud.get_wata_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
# Алиас для совместимости с хендлерами
|
||||
async def get_wata_payment_by_local_id(*args, **kwargs):
|
||||
return await get_wata_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_wata_payment_by_order_id(*args, **kwargs):
|
||||
wata_crud = import_module("app.database.crud.wata")
|
||||
return await wata_crud.get_wata_payment_by_order_id(*args, **kwargs)
|
||||
@@ -299,6 +305,7 @@ class PaymentService(
|
||||
WataPaymentMixin,
|
||||
CloudPaymentsPaymentMixin,
|
||||
FreekassaPaymentMixin,
|
||||
KassaAiPaymentMixin,
|
||||
):
|
||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||
|
||||
|
||||
@@ -1430,13 +1430,30 @@ class RemnaWaveService:
|
||||
logger.warning(f"⚠️ Не удалось удалить серверы подписки: {servers_error}")
|
||||
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
|
||||
# Проверяем, была ли это платная подписка
|
||||
was_paid = (
|
||||
not subscription.is_trial
|
||||
or getattr(db_user, 'has_had_paid_subscription', False)
|
||||
)
|
||||
|
||||
subscription.status = SubscriptionStatus.DISABLED.value
|
||||
subscription.is_trial = True
|
||||
subscription.end_date = datetime.utcnow()
|
||||
subscription.traffic_limit_gb = 0
|
||||
subscription.traffic_used_gb = 0.0
|
||||
subscription.device_limit = 1
|
||||
|
||||
if was_paid:
|
||||
# Для платных подписок - НЕ сбрасываем is_trial и end_date!
|
||||
# Сохраняем оригинальные значения чтобы можно было восстановить
|
||||
logger.warning(
|
||||
f"⚠️ ПЛАТНАЯ подписка пользователя {telegram_id} отключена (нет в панели), "
|
||||
f"но is_trial={subscription.is_trial} и end_date={subscription.end_date} СОХРАНЕНЫ"
|
||||
)
|
||||
else:
|
||||
# Для триальных подписок - сбрасываем как раньше
|
||||
subscription.is_trial = True
|
||||
subscription.end_date = datetime.utcnow()
|
||||
subscription.traffic_limit_gb = 0
|
||||
subscription.traffic_used_gb = 0.0
|
||||
subscription.device_limit = 1
|
||||
|
||||
subscription.connected_squads = []
|
||||
subscription.autopay_enabled = False
|
||||
subscription.remnawave_short_uuid = None
|
||||
@@ -1620,21 +1637,43 @@ class RemnaWaveService:
|
||||
|
||||
if expire_at_str:
|
||||
expire_at = self._parse_remnawave_date(expire_at_str)
|
||||
|
||||
if abs((subscription.end_date - expire_at).total_seconds()) > 60:
|
||||
subscription.end_date = expire_at
|
||||
logger.debug(f"Обновлена дата окончания подписки до {expire_at}")
|
||||
|
||||
# КРИТИЧНО: НЕ перезаписываем end_date если локальная дата ПОЗЖЕ
|
||||
# Это защищает от ситуации когда подписка была продлена в боте,
|
||||
# но RemnaWave ещё не получил обновление или вернул старую дату
|
||||
if abs((subscription.end_date - expire_at).total_seconds()) > 60:
|
||||
if expire_at > subscription.end_date:
|
||||
# RemnaWave имеет более позднюю дату - обновляем
|
||||
subscription.end_date = expire_at
|
||||
logger.debug(f"Обновлена дата окончания подписки до {expire_at}")
|
||||
else:
|
||||
# Локальная дата позже - НЕ перезаписываем, логируем предупреждение
|
||||
logger.warning(
|
||||
f"⚠️ Sync: пропускаем обновление end_date для user {getattr(user, 'telegram_id', '?')}: "
|
||||
f"локальная дата ({subscription.end_date}) позже чем в RemnaWave ({expire_at})"
|
||||
)
|
||||
|
||||
current_time = self._now_utc()
|
||||
if panel_status == 'ACTIVE' and subscription.end_date > current_time:
|
||||
new_status = SubscriptionStatus.ACTIVE.value
|
||||
elif subscription.end_date <= current_time:
|
||||
new_status = SubscriptionStatus.EXPIRED.value
|
||||
elif panel_status == 'DISABLED':
|
||||
new_status = SubscriptionStatus.DISABLED.value
|
||||
elif subscription.end_date <= current_time:
|
||||
# КРИТИЧНО: НЕ деактивируем если текущий статус ACTIVE
|
||||
# Это защищает от race condition когда sync использует старую end_date из памяти,
|
||||
# а реальная end_date уже обновлена продлением
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value:
|
||||
logger.warning(
|
||||
f"⚠️ Sync: пропускаем деактивацию подписки user {getattr(user, 'telegram_id', '?')}: "
|
||||
f"статус ACTIVE, end_date в памяти ({subscription.end_date}) <= now. "
|
||||
f"Деактивация будет выполнена через middleware с буфером."
|
||||
)
|
||||
new_status = subscription.status # Сохраняем текущий статус
|
||||
else:
|
||||
new_status = SubscriptionStatus.EXPIRED.value
|
||||
else:
|
||||
new_status = subscription.status
|
||||
|
||||
new_status = subscription.status
|
||||
|
||||
if subscription.status != new_status:
|
||||
subscription.status = new_status
|
||||
logger.debug(f"Обновлен статус подписки: {new_status}")
|
||||
@@ -2143,7 +2182,25 @@ class RemnaWaveService:
|
||||
return False
|
||||
|
||||
async def force_cleanup_user_data(self, db: AsyncSession, user: User) -> bool:
|
||||
"""
|
||||
ОПАСНАЯ ФУНКЦИЯ: Полностью сбрасывает все данные пользователя включая баланс!
|
||||
Используйте только для полной очистки пользователя.
|
||||
"""
|
||||
try:
|
||||
# Предупреждение для платных пользователей
|
||||
was_paid = (
|
||||
user.has_had_paid_subscription
|
||||
or (user.subscription and not user.subscription.is_trial)
|
||||
or user.balance_kopeks > 0
|
||||
)
|
||||
if was_paid:
|
||||
logger.warning(
|
||||
f"⚠️ ВНИМАНИЕ: force_cleanup_user_data вызвана для ПЛАТНОГО пользователя {user.telegram_id}! "
|
||||
f"has_had_paid_subscription={user.has_had_paid_subscription}, "
|
||||
f"balance={user.balance_kopeks}, "
|
||||
f"is_trial={user.subscription.is_trial if user.subscription else 'N/A'}"
|
||||
)
|
||||
|
||||
logger.info(f"🗑️ ПРИНУДИТЕЛЬНАЯ полная очистка данных пользователя {user.telegram_id}")
|
||||
|
||||
if user.remnawave_uuid:
|
||||
@@ -2384,8 +2441,15 @@ class RemnaWaveService:
|
||||
issues_fixed = 0
|
||||
|
||||
current_time = self._now_utc()
|
||||
if subscription.end_date <= current_time and subscription.status == SubscriptionStatus.ACTIVE.value:
|
||||
logger.info(f"🔧 Исправляем статус просроченной подписки {user.telegram_id}")
|
||||
# Добавляем буфер 5 минут для защиты от race condition при продлении
|
||||
expiry_buffer = timedelta(minutes=5)
|
||||
if (subscription.end_date + expiry_buffer <= current_time and
|
||||
subscription.status == SubscriptionStatus.ACTIVE.value):
|
||||
time_since_expiry = current_time - subscription.end_date
|
||||
logger.warning(
|
||||
f"🔧 fix_data_issues: деактивируем подписку {subscription.id} "
|
||||
f"(user={user.telegram_id}), просрочена на {time_since_expiry}"
|
||||
)
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
issues_fixed += 1
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from sqlalchemy.sql import false, true
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import get_subscriptions_statistics
|
||||
from app.database.crud.transaction import REAL_PAYMENT_METHODS
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import (
|
||||
PaymentMethod,
|
||||
@@ -491,6 +492,10 @@ class ReportingService:
|
||||
)
|
||||
|
||||
def _deposit_query_excluding_referrals(self, start_utc: datetime, end_utc: datetime):
|
||||
"""Запрос депозитов только по реальным платежам.
|
||||
|
||||
Исключаются: колесо удачи, промокоды, админские пополнения, оплата с баланса.
|
||||
"""
|
||||
return select(
|
||||
func.count(Transaction.id),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
|
||||
@@ -500,11 +505,8 @@ class ReportingService:
|
||||
Transaction.created_at >= start_utc,
|
||||
Transaction.created_at < end_utc,
|
||||
self._exclude_referral_deposits_condition(),
|
||||
# Исключаем ручные (админские) пополнения из статистики
|
||||
or_(
|
||||
Transaction.payment_method.is_(None),
|
||||
Transaction.payment_method != PaymentMethod.MANUAL.value,
|
||||
),
|
||||
# Только реальные платежи (исключаем колесо, промокоды, админские, баланс)
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
|
||||
async def _get_top_referrers(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -320,19 +320,27 @@ 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
|
||||
# Определяем актуальный статус для отправки в RemnaWave
|
||||
# НЕ меняем статус подписки здесь - это задача scheduled job
|
||||
is_actually_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date > current_time)
|
||||
|
||||
|
||||
# Логируем если статус и end_date не согласованы (для отладки)
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.updated_at = current_time
|
||||
await db.commit()
|
||||
is_actually_active = False
|
||||
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
|
||||
logger.warning(
|
||||
f"⚠️ update_remnawave_user: подписка {subscription.id} имеет статус ACTIVE, "
|
||||
f"но end_date ({subscription.end_date}) <= now ({current_time}). "
|
||||
f"Отправляем в RemnaWave как EXPIRED, но НЕ меняем статус в БД."
|
||||
)
|
||||
|
||||
user_tag = self._resolve_user_tag(subscription)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ class BotConfigurationService:
|
||||
"HELEKET": "🪙 Heleket",
|
||||
"CLOUDPAYMENTS": "💳 CloudPayments",
|
||||
"FREEKASSA": "💳 Freekassa",
|
||||
"KASSA_AI": "💳 KassaAI",
|
||||
"YOOKASSA": "🟣 YooKassa",
|
||||
"PLATEGA": "💳 {platega_name}",
|
||||
"TRIBUTE": "🎁 Tribute",
|
||||
@@ -143,6 +144,7 @@ class BotConfigurationService:
|
||||
"HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.",
|
||||
"CLOUDPAYMENTS": "CloudPayments: оплата банковскими картами, Public ID, API Secret и вебхуки.",
|
||||
"FREEKASSA": "Freekassa: ID магазина, API ключ, секретные слова и вебхуки.",
|
||||
"KASSA_AI": "KassaAI: отдельная платёжка api.fk.life с СБП, картами и SberPay.",
|
||||
"PLATEGA": "{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.",
|
||||
"MULENPAY": "Платежи {mulenpay_name} и параметры магазина.",
|
||||
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
|
||||
@@ -276,6 +278,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",
|
||||
@@ -325,6 +339,7 @@ class BotConfigurationService:
|
||||
"HELEKET_": "HELEKET",
|
||||
"CLOUDPAYMENTS_": "CLOUDPAYMENTS",
|
||||
"FREEKASSA_": "FREEKASSA",
|
||||
"KASSA_AI_": "KASSA_AI",
|
||||
"PLATEGA_": "PLATEGA",
|
||||
"MULENPAY_": "MULENPAY",
|
||||
"PAL24_": "PAL24",
|
||||
@@ -780,6 +795,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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+169
-57
@@ -679,18 +679,39 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
@router.post(settings.CLOUDPAYMENTS_WEBHOOK_PATH + "/check")
|
||||
async def cloudpayments_check_webhook(request: Request) -> JSONResponse:
|
||||
"""Check webhook - вызывается перед списанием, можно отклонить платёж."""
|
||||
raw_body = await request.body()
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
|
||||
# Проверяем подпись
|
||||
signature = request.headers.get("X-Content-HMAC") or request.headers.get("Content-HMAC") or ""
|
||||
if settings.CLOUDPAYMENTS_API_SECRET and not cloudpayments_service.verify_webhook_signature(
|
||||
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
|
||||
):
|
||||
logger.warning("CloudPayments check webhook: invalid signature")
|
||||
return JSONResponse({"code": 13}) # Отклонить
|
||||
# Логируем для диагностики
|
||||
logger.info(
|
||||
"CloudPayments check webhook received, body_len=%d, all_headers=%s",
|
||||
len(raw_body),
|
||||
dict(request.headers),
|
||||
)
|
||||
|
||||
# Разрешаем платёж
|
||||
return JSONResponse({"code": 0})
|
||||
# Проверяем подпись только если она пришла и API_SECRET настроен
|
||||
# CloudPayments использует заголовок X-Content-HMAC или Content-HMAC
|
||||
signature = request.headers.get("X-Content-HMAC") or request.headers.get("Content-HMAC") or ""
|
||||
if settings.CLOUDPAYMENTS_API_SECRET and signature:
|
||||
if not cloudpayments_service.verify_webhook_signature(
|
||||
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
|
||||
):
|
||||
logger.warning(
|
||||
"CloudPayments check webhook: invalid signature, sig=%s...",
|
||||
signature[:20] if signature else "empty",
|
||||
)
|
||||
return JSONResponse({"code": 13}) # Отклонить
|
||||
elif settings.CLOUDPAYMENTS_API_SECRET and not signature:
|
||||
# Подпись не пришла, но API_SECRET настроен - пропускаем проверку с предупреждением
|
||||
logger.warning("CloudPayments check webhook: no signature header, skipping verification")
|
||||
|
||||
# Разрешаем платёж
|
||||
logger.info("CloudPayments check webhook: allowing payment, returning code=0")
|
||||
return JSONResponse({"code": 0})
|
||||
except Exception as e:
|
||||
logger.exception("CloudPayments check webhook error: %s", e)
|
||||
# В случае ошибки всё равно разрешаем платёж
|
||||
return JSONResponse({"code": 0})
|
||||
|
||||
# CloudPayments Pay webhook (успешная оплата)
|
||||
@router.post(settings.CLOUDPAYMENTS_WEBHOOK_PATH + "/pay")
|
||||
@@ -698,13 +719,14 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
"""Pay webhook - вызывается после успешной оплаты."""
|
||||
raw_body = await request.body()
|
||||
|
||||
# Проверяем подпись
|
||||
# Проверяем подпись только если она пришла и API_SECRET настроен
|
||||
signature = request.headers.get("X-Content-HMAC") or request.headers.get("Content-HMAC") or ""
|
||||
if settings.CLOUDPAYMENTS_API_SECRET and not cloudpayments_service.verify_webhook_signature(
|
||||
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
|
||||
):
|
||||
logger.warning("CloudPayments pay webhook: invalid signature")
|
||||
return JSONResponse({"code": 13})
|
||||
if settings.CLOUDPAYMENTS_API_SECRET and signature:
|
||||
if not cloudpayments_service.verify_webhook_signature(
|
||||
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
|
||||
):
|
||||
logger.warning("CloudPayments pay webhook: invalid signature")
|
||||
return JSONResponse({"code": 13})
|
||||
|
||||
# Парсим данные формы
|
||||
try:
|
||||
@@ -729,13 +751,14 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
"""Fail webhook - вызывается при неуспешной оплате."""
|
||||
raw_body = await request.body()
|
||||
|
||||
# Проверяем подпись
|
||||
# Проверяем подпись только если она пришла и API_SECRET настроен
|
||||
signature = request.headers.get("X-Content-HMAC") or request.headers.get("Content-HMAC") or ""
|
||||
if settings.CLOUDPAYMENTS_API_SECRET and not cloudpayments_service.verify_webhook_signature(
|
||||
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
|
||||
):
|
||||
logger.warning("CloudPayments fail webhook: invalid signature")
|
||||
return JSONResponse({"code": 13})
|
||||
if settings.CLOUDPAYMENTS_API_SECRET and signature:
|
||||
if not cloudpayments_service.verify_webhook_signature(
|
||||
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
|
||||
):
|
||||
logger.warning("CloudPayments fail webhook: invalid signature")
|
||||
return JSONResponse({"code": 13})
|
||||
|
||||
# Парсим данные формы
|
||||
try:
|
||||
@@ -758,43 +781,60 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
@router.post(settings.CLOUDPAYMENTS_WEBHOOK_PATH)
|
||||
async def cloudpayments_webhook(request: Request) -> JSONResponse:
|
||||
"""Универсальный webhook endpoint."""
|
||||
raw_body = await request.body()
|
||||
|
||||
# Проверяем подпись
|
||||
signature = request.headers.get("X-Content-HMAC") or request.headers.get("Content-HMAC") or ""
|
||||
if settings.CLOUDPAYMENTS_API_SECRET and not cloudpayments_service.verify_webhook_signature(
|
||||
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
|
||||
):
|
||||
logger.warning("CloudPayments webhook: invalid signature")
|
||||
return JSONResponse({"code": 13})
|
||||
|
||||
# Парсим данные формы
|
||||
try:
|
||||
form_data = await request.form()
|
||||
webhook_data = cloudpayments_service.parse_webhook_data(dict(form_data))
|
||||
except Exception as error:
|
||||
logger.error("CloudPayments webhook parse error: %s", error)
|
||||
raw_body = await request.body()
|
||||
|
||||
# Логируем для диагностики
|
||||
logger.info(
|
||||
"CloudPayments universal webhook received, body_len=%d, headers=%s",
|
||||
len(raw_body),
|
||||
dict(request.headers),
|
||||
)
|
||||
|
||||
# Проверяем подпись только если она пришла и API_SECRET настроен
|
||||
signature = request.headers.get("X-Content-HMAC") or request.headers.get("Content-HMAC") or ""
|
||||
if settings.CLOUDPAYMENTS_API_SECRET and signature:
|
||||
if not cloudpayments_service.verify_webhook_signature(
|
||||
raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET
|
||||
):
|
||||
logger.warning("CloudPayments webhook: invalid signature")
|
||||
return JSONResponse({"code": 13})
|
||||
|
||||
# Парсим данные формы
|
||||
try:
|
||||
form_data = await request.form()
|
||||
webhook_data = cloudpayments_service.parse_webhook_data(dict(form_data))
|
||||
logger.info("CloudPayments webhook parsed data: %s", webhook_data)
|
||||
except Exception as error:
|
||||
logger.error("CloudPayments webhook parse error: %s", error)
|
||||
# Может быть это Check уведомление - просто разрешаем
|
||||
return JSONResponse({"code": 0})
|
||||
|
||||
# Определяем тип webhook по статусу
|
||||
status_value = webhook_data.get("status", "")
|
||||
|
||||
if status_value in ("Completed", "Authorized"):
|
||||
# Успешная оплата
|
||||
await _process_payment_service_callback(
|
||||
payment_service,
|
||||
webhook_data,
|
||||
"process_cloudpayments_pay_webhook",
|
||||
)
|
||||
elif status_value in ("Declined", "Cancelled"):
|
||||
# Неуспешная оплата
|
||||
await _process_payment_service_callback(
|
||||
payment_service,
|
||||
webhook_data,
|
||||
"process_cloudpayments_fail_webhook",
|
||||
)
|
||||
else:
|
||||
# Check или другой тип уведомления - просто разрешаем
|
||||
logger.info("CloudPayments webhook: status=%s, allowing (code=0)", status_value)
|
||||
|
||||
return JSONResponse({"code": 0})
|
||||
except Exception as e:
|
||||
logger.exception("CloudPayments universal webhook error: %s", e)
|
||||
return JSONResponse({"code": 0})
|
||||
|
||||
# Определяем тип webhook по статусу
|
||||
status_value = webhook_data.get("status", "")
|
||||
|
||||
if status_value in ("Completed", "Authorized"):
|
||||
# Успешная оплата
|
||||
await _process_payment_service_callback(
|
||||
payment_service,
|
||||
webhook_data,
|
||||
"process_cloudpayments_pay_webhook",
|
||||
)
|
||||
elif status_value in ("Declined", "Cancelled"):
|
||||
# Неуспешная оплата
|
||||
await _process_payment_service_callback(
|
||||
payment_service,
|
||||
webhook_data,
|
||||
"process_cloudpayments_fail_webhook",
|
||||
)
|
||||
|
||||
return JSONResponse({"code": 0})
|
||||
|
||||
routes_registered = True
|
||||
|
||||
@@ -892,6 +932,77 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# KassaAI webhook
|
||||
if settings.is_kassa_ai_enabled():
|
||||
@router.get(settings.KASSA_AI_WEBHOOK_PATH)
|
||||
async def kassa_ai_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "kassa_ai_webhook",
|
||||
"enabled": settings.is_kassa_ai_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.KASSA_AI_WEBHOOK_PATH)
|
||||
async def kassa_ai_webhook(request: Request) -> Response:
|
||||
# Получаем данные формы
|
||||
try:
|
||||
form_data = await request.form()
|
||||
except Exception:
|
||||
logger.error("KassaAI webhook: не удалось прочитать данные формы")
|
||||
return Response("Error reading form data", status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Извлекаем параметры (те же что и у Freekassa)
|
||||
merchant_id = form_data.get("MERCHANT_ID")
|
||||
amount = form_data.get("AMOUNT")
|
||||
order_id = form_data.get("MERCHANT_ORDER_ID")
|
||||
sign = form_data.get("SIGN")
|
||||
intid = form_data.get("intid")
|
||||
cur_id = form_data.get("CUR_ID")
|
||||
|
||||
if not all([merchant_id, amount, order_id, sign, intid]):
|
||||
logger.warning("KassaAI webhook: отсутствуют обязательные параметры")
|
||||
return Response("Missing parameters", status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
merchant_id_int = int(merchant_id)
|
||||
amount_float = float(amount)
|
||||
cur_id_int = int(cur_id) if cur_id else None
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.error("KassaAI webhook: некорректные параметры - %s", e)
|
||||
return Response("Invalid parameters", status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Обрабатываем webhook
|
||||
db_generator = get_db()
|
||||
try:
|
||||
db = await db_generator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
return Response("DB Error", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
try:
|
||||
success = await payment_service.process_kassa_ai_webhook(
|
||||
db,
|
||||
merchant_id=merchant_id_int,
|
||||
amount=amount_float,
|
||||
order_id=order_id,
|
||||
sign=sign,
|
||||
intid=intid,
|
||||
cur_id=cur_id_int,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
await db_generator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
|
||||
if success:
|
||||
return Response("YES", status_code=status.HTTP_200_OK)
|
||||
|
||||
return Response("Error", status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
if routes_registered:
|
||||
@router.get("/health/payment-webhooks")
|
||||
async def payment_webhooks_health() -> JSONResponse:
|
||||
@@ -908,6 +1019,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
"platega_enabled": settings.is_platega_enabled(),
|
||||
"cloudpayments_enabled": settings.is_cloudpayments_enabled(),
|
||||
"freekassa_enabled": settings.is_freekassa_enabled(),
|
||||
"kassa_ai_enabled": settings.is_kassa_ai_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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 отключен режимом работы")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Middlewares tests package
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Тесты для функций сохранения/получения pending_start_payload в channel_checker."""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch, create_autospec
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from aiogram.types import Message, User
|
||||
|
||||
|
||||
class TestRedisPayloadFunctions:
|
||||
"""Тесты для Redis-функций сохранения payload."""
|
||||
|
||||
async def test_save_pending_payload_to_redis_success(self, monkeypatch):
|
||||
"""Тест успешного сохранения payload в Redis."""
|
||||
from app.middlewares import channel_checker
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.set = AsyncMock(return_value=True)
|
||||
mock_redis.aclose = AsyncMock()
|
||||
|
||||
with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis:
|
||||
mock_aioredis.from_url = MagicMock(return_value=mock_redis)
|
||||
|
||||
result = await channel_checker.save_pending_payload_to_redis(123456, "ref_test123")
|
||||
|
||||
assert result is True
|
||||
mock_redis.set.assert_awaited_once()
|
||||
call_args = mock_redis.set.await_args
|
||||
assert "pending_start_payload:123456" in call_args.args[0]
|
||||
assert call_args.args[1] == "ref_test123"
|
||||
assert call_args.kwargs.get("ex") == 3600
|
||||
mock_redis.aclose.assert_awaited_once()
|
||||
|
||||
async def test_save_pending_payload_to_redis_failure(self, monkeypatch):
|
||||
"""Тест обработки ошибки при сохранении в Redis."""
|
||||
from app.middlewares import channel_checker
|
||||
|
||||
with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis:
|
||||
mock_aioredis.from_url = MagicMock(side_effect=Exception("Redis connection failed"))
|
||||
|
||||
result = await channel_checker.save_pending_payload_to_redis(123456, "ref_test123")
|
||||
|
||||
assert result is False
|
||||
|
||||
async def test_get_pending_payload_from_redis_success(self, monkeypatch):
|
||||
"""Тест успешного получения payload из Redis."""
|
||||
from app.middlewares import channel_checker
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.get = AsyncMock(return_value=b"ref_test123")
|
||||
mock_redis.aclose = AsyncMock()
|
||||
|
||||
with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis:
|
||||
mock_aioredis.from_url = MagicMock(return_value=mock_redis)
|
||||
|
||||
result = await channel_checker.get_pending_payload_from_redis(123456)
|
||||
|
||||
assert result == "ref_test123"
|
||||
mock_redis.get.assert_awaited_once()
|
||||
mock_redis.aclose.assert_awaited_once()
|
||||
|
||||
async def test_get_pending_payload_from_redis_not_found(self, monkeypatch):
|
||||
"""Тест когда payload не найден в Redis."""
|
||||
from app.middlewares import channel_checker
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.get = AsyncMock(return_value=None)
|
||||
mock_redis.aclose = AsyncMock()
|
||||
|
||||
with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis:
|
||||
mock_aioredis.from_url = MagicMock(return_value=mock_redis)
|
||||
|
||||
result = await channel_checker.get_pending_payload_from_redis(123456)
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_get_pending_payload_from_redis_failure(self, monkeypatch):
|
||||
"""Тест обработки ошибки при получении из Redis."""
|
||||
from app.middlewares import channel_checker
|
||||
|
||||
with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis:
|
||||
mock_aioredis.from_url = MagicMock(side_effect=Exception("Redis connection failed"))
|
||||
|
||||
result = await channel_checker.get_pending_payload_from_redis(123456)
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_delete_pending_payload_from_redis(self, monkeypatch):
|
||||
"""Тест удаления payload из Redis."""
|
||||
from app.middlewares import channel_checker
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.delete = AsyncMock(return_value=1)
|
||||
mock_redis.aclose = AsyncMock()
|
||||
|
||||
with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis:
|
||||
mock_aioredis.from_url = MagicMock(return_value=mock_redis)
|
||||
|
||||
# Не должно бросать исключение
|
||||
await channel_checker.delete_pending_payload_from_redis(123456)
|
||||
|
||||
mock_redis.delete.assert_awaited_once()
|
||||
|
||||
async def test_delete_pending_payload_from_redis_handles_error(self, monkeypatch):
|
||||
"""Тест что удаление не бросает исключение при ошибке."""
|
||||
from app.middlewares import channel_checker
|
||||
|
||||
with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis:
|
||||
mock_aioredis.from_url = MagicMock(side_effect=Exception("Redis error"))
|
||||
|
||||
# Не должно бросать исключение
|
||||
await channel_checker.delete_pending_payload_from_redis(123456)
|
||||
|
||||
|
||||
def _create_mock_message(text: str, user_id: int):
|
||||
"""Создаёт мок Message с нужными атрибутами."""
|
||||
mock_msg = MagicMock(spec=Message)
|
||||
mock_msg.text = text
|
||||
mock_msg.from_user = SimpleNamespace(id=user_id)
|
||||
return mock_msg
|
||||
|
||||
|
||||
class TestCaptureStartPayload:
|
||||
"""Тесты для метода _capture_start_payload."""
|
||||
|
||||
async def test_capture_saves_to_fsm_state(self, monkeypatch):
|
||||
"""Тест сохранения payload в FSM state."""
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
|
||||
middleware = ChannelCheckerMiddleware()
|
||||
|
||||
mock_state = AsyncMock()
|
||||
mock_state.get_data = AsyncMock(return_value={})
|
||||
mock_state.set_data = AsyncMock()
|
||||
|
||||
mock_message = _create_mock_message("/start ref_abc123", 123456)
|
||||
|
||||
with patch("app.middlewares.channel_checker.save_pending_payload_to_redis", new_callable=AsyncMock) as mock_save_redis:
|
||||
await middleware._capture_start_payload(mock_state, mock_message, None)
|
||||
|
||||
mock_state.set_data.assert_awaited_once()
|
||||
saved_data = mock_state.set_data.await_args.args[0]
|
||||
assert saved_data["pending_start_payload"] == "ref_abc123"
|
||||
|
||||
# Также должен сохраняться в Redis
|
||||
mock_save_redis.assert_awaited_once_with(123456, "ref_abc123")
|
||||
|
||||
async def test_capture_saves_to_redis_when_state_none(self, monkeypatch):
|
||||
"""Тест сохранения payload в Redis когда FSM state недоступен."""
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
|
||||
middleware = ChannelCheckerMiddleware()
|
||||
|
||||
mock_message = _create_mock_message("/start ref_xyz789", 999888)
|
||||
|
||||
with patch("app.middlewares.channel_checker.save_pending_payload_to_redis", new_callable=AsyncMock) as mock_save_redis:
|
||||
await middleware._capture_start_payload(None, mock_message, None)
|
||||
|
||||
# Должен сохраняться в Redis даже если state=None
|
||||
mock_save_redis.assert_awaited_once_with(999888, "ref_xyz789")
|
||||
|
||||
async def test_capture_ignores_message_without_payload(self, monkeypatch):
|
||||
"""Тест что сообщение без payload игнорируется."""
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
|
||||
middleware = ChannelCheckerMiddleware()
|
||||
|
||||
mock_state = AsyncMock()
|
||||
mock_state.get_data = AsyncMock(return_value={})
|
||||
mock_state.set_data = AsyncMock()
|
||||
|
||||
mock_message = _create_mock_message("/start", 123456) # Без payload
|
||||
|
||||
with patch("app.middlewares.channel_checker.save_pending_payload_to_redis", new_callable=AsyncMock) as mock_save_redis:
|
||||
await middleware._capture_start_payload(mock_state, mock_message, None)
|
||||
|
||||
mock_state.set_data.assert_not_awaited()
|
||||
mock_save_redis.assert_not_awaited()
|
||||
|
||||
async def test_capture_ignores_non_start_message(self, monkeypatch):
|
||||
"""Тест что не-start сообщения игнорируются."""
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
|
||||
middleware = ChannelCheckerMiddleware()
|
||||
|
||||
mock_state = AsyncMock()
|
||||
mock_state.get_data = AsyncMock(return_value={})
|
||||
mock_state.set_data = AsyncMock()
|
||||
|
||||
mock_message = _create_mock_message("/help something", 123456) # Не /start
|
||||
|
||||
with patch("app.middlewares.channel_checker.save_pending_payload_to_redis", new_callable=AsyncMock) as mock_save_redis:
|
||||
await middleware._capture_start_payload(mock_state, mock_message, None)
|
||||
|
||||
mock_state.set_data.assert_not_awaited()
|
||||
mock_save_redis.assert_not_awaited()
|
||||
|
||||
async def test_capture_does_not_overwrite_same_payload(self, monkeypatch):
|
||||
"""Тест что одинаковый payload не перезаписывается в FSM state."""
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
|
||||
middleware = ChannelCheckerMiddleware()
|
||||
|
||||
mock_state = AsyncMock()
|
||||
mock_state.get_data = AsyncMock(return_value={"pending_start_payload": "ref_same"})
|
||||
mock_state.set_data = AsyncMock()
|
||||
|
||||
mock_message = _create_mock_message("/start ref_same", 123456) # Тот же payload
|
||||
|
||||
with patch("app.middlewares.channel_checker.save_pending_payload_to_redis", new_callable=AsyncMock) as mock_save_redis:
|
||||
await middleware._capture_start_payload(mock_state, mock_message, None)
|
||||
|
||||
# FSM state не должен перезаписываться
|
||||
mock_state.set_data.assert_not_awaited()
|
||||
# Но в Redis всё равно сохраняем (для надёжности)
|
||||
mock_save_redis.assert_awaited_once()
|
||||
|
||||
|
||||
class TestPayloadIntegration:
|
||||
"""Интеграционные тесты для потока сохранения/восстановления payload."""
|
||||
|
||||
async def test_full_flow_fsm_state_works(self, monkeypatch):
|
||||
"""Тест полного потока когда FSM state работает корректно."""
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
|
||||
middleware = ChannelCheckerMiddleware()
|
||||
|
||||
# Сохраняем payload
|
||||
state_storage = {}
|
||||
|
||||
mock_state = AsyncMock()
|
||||
mock_state.get_data = AsyncMock(return_value=state_storage)
|
||||
mock_state.set_data = AsyncMock(side_effect=lambda d: state_storage.update(d))
|
||||
|
||||
mock_message = _create_mock_message("/start ref_flow_test", 111222)
|
||||
|
||||
with patch("app.middlewares.channel_checker.save_pending_payload_to_redis", new_callable=AsyncMock):
|
||||
await middleware._capture_start_payload(mock_state, mock_message, None)
|
||||
|
||||
# Проверяем что payload сохранён
|
||||
assert state_storage.get("pending_start_payload") == "ref_flow_test"
|
||||
|
||||
async def test_payload_retrieved_from_redis_fallback(self, monkeypatch):
|
||||
"""Тест что payload восстанавливается из Redis если в FSM state его нет."""
|
||||
from app.middlewares.channel_checker import get_pending_payload_from_redis
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.get = AsyncMock(return_value=b"ref_from_redis")
|
||||
mock_redis.aclose = AsyncMock()
|
||||
|
||||
with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis:
|
||||
mock_aioredis.from_url = MagicMock(return_value=mock_redis)
|
||||
|
||||
result = await get_pending_payload_from_redis(333444)
|
||||
|
||||
assert result == "ref_from_redis"
|
||||
Reference in New Issue
Block a user